diff --git a/app/db/crud/admin.py b/app/db/crud/admin.py index d4d15be6b..890eef15b 100644 --- a/app/db/crud/admin.py +++ b/app/db/crud/admin.py @@ -379,6 +379,13 @@ async def get_admins( if load_role: stmt = stmt.options(selectinload(Admin.role)) + # For non-compact path, eagerly load users and usage_logs to avoid N+1 queries + if not compact: + stmt = stmt.options( + selectinload(Admin.users), + selectinload(Admin.usage_logs), + ) + # Apply filters consistently if params.ids: stmt = stmt.where(Admin.id.in_(params.ids)) @@ -405,9 +412,8 @@ async def get_admins( for admin, total_users, reseted_usage in rows: admins.append(build_admin_details(admin, total_users=total_users, reseted_usage=reseted_usage)) else: - admins = list((await db.execute(stmt)).scalars().all()) - for admin in admins: - await load_admin_attrs(admin, load_role=load_role) + # users, usage_logs, and role already eagerly loaded via selectinload above + admins = list((await db.execute(stmt)).unique().scalars().all()) if return_with_count: return admins, total, active, disabled, limited @@ -524,20 +530,60 @@ async def get_usage_percentage_reached_admins( return list((await db.execute(stmt)).scalars().all()) -async def bulk_create_admin_notification_reminders(db: AsyncSession, reminder_data: list[dict]) -> None: +async def bulk_create_admin_notification_reminders( + db: AsyncSession, reminder_data: list[dict] +) -> list[dict]: """Bulk-insert admin reminder rows after successful sends.""" if not reminder_data: - return + return [] - db.add_all([AdminNotificationReminder(**data) for data in reminder_data]) - await db.commit() + # De-duplicate input list to preserve only the first occurrence + seen_input = set() + unique_reminder_data = [] + for data in reminder_data: + key = (data["admin_id"], data["type"], data["threshold"]) + if key not in seen_input: + seen_input.add(key) + unique_reminder_data.append(data) + + admin_ids = {d["admin_id"] for d in unique_reminder_data} + types = {d["type"] for d in unique_reminder_data} + + # Lock the Admin rows to serialize reminder checks/creation for these admins + await db.execute( + select(Admin.id) + .where(Admin.id.in_(list(admin_ids))) + .with_for_update() + ) + + # Fetch existing reminders that match these criteria + stmt = select(AdminNotificationReminder).where( + AdminNotificationReminder.admin_id.in_(list(admin_ids)), + AdminNotificationReminder.type.in_(list(types)), + ) + result = await db.execute(stmt) + existing = {(r.admin_id, r.type, r.threshold) for r in result.scalars().all()} + + to_insert = [] + inserted_data = [] + for data in unique_reminder_data: + key = (data["admin_id"], data["type"], data["threshold"]) + if key not in existing: + to_insert.append(AdminNotificationReminder(**data)) + inserted_data.append(data) + + if to_insert: + db.add_all(to_insert) + await db.commit() + + return inserted_data async def create_admin_notification_reminder_if_absent( db: AsyncSession, admin_id: int, reminder_type: ReminderType, - threshold: int | None = None, + threshold: int = 0, ) -> bool: """Create an admin reminder row and return whether this call claimed it.""" existing_reminder_id = await db.scalar( diff --git a/app/db/crud/bulk.py b/app/db/crud/bulk.py index dd7258204..b3fb27f0a 100644 --- a/app/db/crud/bulk.py +++ b/app/db/crud/bulk.py @@ -3,6 +3,7 @@ from sqlalchemy import and_, case, cast, delete, func, or_, select, text, update from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import joinedload, selectinload from app.db.models import ( Admin, @@ -443,9 +444,20 @@ async def update_users_proxy_settings( await db.execute(update_stmt) await db.commit() - # Refresh the user objects to get updated values - for user in users_to_update: - await db.refresh(user) - await load_user_attrs(user, load_admin_role=True) + # Re-fetch all updated users in a single query instead of N individual refreshes + updated_user_ids = [u.id for u in users_to_update] + result = await db.execute( + select(User) + .where(User.id.in_(updated_user_ids)) + .options( + joinedload(User.admin).selectinload(Admin.role), + joinedload(User.next_plan), + selectinload(User.usage_logs), + selectinload(User.groups), + ) + ) + refreshed_users = result.scalars().all() + refreshed_users_map = {user.id: user for user in refreshed_users} + ordered_refreshed_users = [refreshed_users_map[uid] for uid in updated_user_ids if uid in refreshed_users_map] - return users_to_update, count_effctive_users + return ordered_refreshed_users, count_effctive_users diff --git a/app/db/crud/group.py b/app/db/crud/group.py index f74580b43..64140c0c6 100644 --- a/app/db/crud/group.py +++ b/app/db/crud/group.py @@ -105,11 +105,15 @@ async def get_group(db: AsyncSession, query: GroupListQuery) -> tuple[list[Group - list[Group]: A list of Group objects - int: The total count of groups """ - groups = select(Group) + groups = select(Group).options(selectinload(Group.users), selectinload(Group.inbounds)) if query.ids: groups = groups.where(Group.id.in_(query.ids)) - count_query = select(func.count()).select_from(groups.subquery()) + # Build count on the base filter before adding pagination or eager loads + base_stmt = select(Group) + if query.ids: + base_stmt = base_stmt.where(Group.id.in_(query.ids)) + count_query = select(func.count()).select_from(base_stmt.subquery()) if query.offset: groups = groups.offset(query.offset) @@ -118,10 +122,8 @@ async def get_group(db: AsyncSession, query: GroupListQuery) -> tuple[list[Group count = (await db.execute(count_query)).scalar_one() - all_groups = (await db.execute(groups)).scalars().all() - - for group in all_groups: - await load_group_attrs(group) + # users and inbounds already eagerly loaded via selectinload above + all_groups = (await db.execute(groups)).unique().scalars().all() return all_groups, count diff --git a/app/db/crud/node.py b/app/db/crud/node.py index 2dac73408..94baf5900 100644 --- a/app/db/crud/node.py +++ b/app/db/crud/node.py @@ -3,6 +3,7 @@ from sqlalchemy import and_, bindparam, case, delete, func, literal_column, or_, select, update from sqlalchemy.exc import InvalidRequestError from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from sqlalchemy.sql.functions import coalesce from app.db.compiles_types import DateDiff @@ -142,9 +143,10 @@ async def get_nodes( # Order by created_at and id for consistent results stmt = stmt.order_by(Node.created_at.asc(), Node.id.asc()) - db_nodes = (await db.execute(stmt)).scalars().all() - for node in db_nodes: - await load_node_attrs(node) + # Eagerly load usage_logs to avoid N+1 queries (one extra SELECT per node) + stmt = stmt.options(selectinload(Node.usage_logs)) + + db_nodes = (await db.execute(stmt)).unique().scalars().all() return db_nodes, count @@ -206,15 +208,13 @@ async def get_limited_nodes(db: AsyncSession) -> list[Node]: Returns: list[Node]: Nodes that should be limited """ - query = select(Node).where( + query = select(Node).options(selectinload(Node.usage_logs)).where( and_( Node.status.in_([NodeStatus.error, NodeStatus.connected, NodeStatus.connecting]), Node.is_limited, ) ) - nodes = (await db.execute(query)).scalars().all() - for node in nodes: - await load_node_attrs(node) + nodes = (await db.execute(query)).unique().scalars().all() return nodes @@ -611,6 +611,7 @@ async def get_nodes_to_reset_usage(db: AsyncSession) -> list[Node]: stmt = ( select(Node) + .options(selectinload(Node.usage_logs)) .outerjoin(last_reset_subq, Node.id == last_reset_subq.c.node_id) .where( Node.status.in_([NodeStatus.connected, NodeStatus.limited, NodeStatus.error, NodeStatus.connecting]), @@ -626,9 +627,7 @@ async def get_nodes_to_reset_usage(db: AsyncSession) -> list[Node]: nodes = list((await db.execute(stmt)).unique().scalars().all()) - # Load node attributes to avoid greenlet errors - for node in nodes: - await load_node_attrs(node) + # usage_logs already eagerly loaded via selectinload — no extra queries needed # For nodes with reset_time >= 0, filter based on absolute time @@ -796,10 +795,17 @@ async def bulk_reset_node_usage(db: AsyncSession, nodes: list[Node]) -> list[Nod db_node.status = NodeStatus.connecting await db.commit() - for node in nodes: - await db.refresh(node) - await load_node_attrs(node) - return nodes + + # Re-fetch all nodes in a single query instead of N individual refreshes + node_ids = [node.id for node in nodes] + refreshed = ( + await db.execute( + select(Node).options(selectinload(Node.usage_logs)).where(Node.id.in_(node_ids)) + ) + ).unique().scalars().all() + # Preserve input order + refreshed_by_id = {n.id: n for n in refreshed} + return [refreshed_by_id[nid] for nid in node_ids if nid in refreshed_by_id] async def remove_nodes(db: AsyncSession, node_ids: list[int]) -> None: diff --git a/app/db/crud/user.py b/app/db/crud/user.py index f21f77a7b..1c85cd417 100644 --- a/app/db/crud/user.py +++ b/app/db/crud/user.py @@ -619,17 +619,15 @@ async def get_usage_percentage_reached_users(db: AsyncSession, percentage: int) ) stmt = ( - select(User) + _build_user_select_stmt() .options(joinedload(User.notification_reminders)) .where(User.status == UserStatus.active) .where(User.usage_percentage >= percentage) .where(not_(existing_reminder_subq)) # Only users without existing reminders ) - users = list((await db.execute(stmt)).unique().scalars().all()) - for user in users: - await load_user_attrs(user) - return users + # All relations (admin, next_plan, usage_logs, groups) eagerly loaded via _build_user_select_stmt + return list((await db.execute(stmt)).unique().scalars().all()) async def get_days_left_reached_users(db: AsyncSession, days: int) -> list[User]: @@ -649,7 +647,7 @@ async def get_days_left_reached_users(db: AsyncSession, days: int) -> list[User] ) stmt = ( - select(User) + _build_user_select_stmt() .options(joinedload(User.notification_reminders)) .where(User.status == UserStatus.active) .where(User.expire.isnot(None)) @@ -657,10 +655,8 @@ async def get_days_left_reached_users(db: AsyncSession, days: int) -> list[User] .where(not_(existing_reminder_subq)) # Only users without existing reminders ) - users = list((await db.execute(stmt)).unique().scalars().all()) - for user in users: - await load_user_attrs(user) - return users + # All relations (admin, next_plan, usage_logs, groups) eagerly loaded via _build_user_select_stmt + return list((await db.execute(stmt)).unique().scalars().all()) async def get_user_usages( diff --git a/app/db/migrations/versions/bb4a32b7f5ce_add_admin_notification_reminders.py b/app/db/migrations/versions/bb4a32b7f5ce_add_admin_notification_reminders.py index 60370c7e9..1c2029537 100644 --- a/app/db/migrations/versions/bb4a32b7f5ce_add_admin_notification_reminders.py +++ b/app/db/migrations/versions/bb4a32b7f5ce_add_admin_notification_reminders.py @@ -18,6 +18,9 @@ depends_on = None +from app.db.compiles_types import SqliteCompatibleBigInteger + + def upgrade() -> None: bind = op.get_bind() dialect = bind.dialect.name @@ -30,7 +33,7 @@ def upgrade() -> None: op.create_table( "admin_notification_reminders", - sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("id", SqliteCompatibleBigInteger(), autoincrement=True, nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), sa.Column("admin_id", sa.BigInteger(), nullable=False), sa.Column("type", reminder_type, nullable=False), diff --git a/app/db/migrations/versions/fb32155473c1_add_unique_constraint_to_admin_.py b/app/db/migrations/versions/fb32155473c1_add_unique_constraint_to_admin_.py new file mode 100644 index 000000000..22d60a891 --- /dev/null +++ b/app/db/migrations/versions/fb32155473c1_add_unique_constraint_to_admin_.py @@ -0,0 +1,64 @@ +"""add unique constraint to admin notification reminders + +Revision ID: fb32155473c1 +Revises: 3c1a7e5b9d20 +Create Date: 2026-07-26 19:32:42.096475 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'fb32155473c1' +down_revision = '3c1a7e5b9d20' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1. Update any existing NULL thresholds to 0 + op.execute( + "UPDATE admin_notification_reminders SET threshold = 0 WHERE threshold IS NULL" + ) + + # 2. Clean up duplicate rows before creating the constraint + op.execute( + """ + DELETE FROM admin_notification_reminders + WHERE id NOT IN ( + SELECT min_id FROM ( + SELECT MIN(id) AS min_id + FROM admin_notification_reminders + GROUP BY admin_id, type, threshold + ) AS temp + ) + """ + ) + + # 3. Alter threshold column to be non-nullable with a server default of 0 and create the constraint + with op.batch_alter_table("admin_notification_reminders", schema=None) as batch_op: + batch_op.alter_column( + "threshold", + existing_type=sa.Integer(), + nullable=False, + server_default="0" + ) + batch_op.create_unique_constraint( + "uq_admin_notification_reminders_admin_id_type_threshold", + ["admin_id", "type", "threshold"] + ) + + +def downgrade() -> None: + with op.batch_alter_table("admin_notification_reminders", schema=None) as batch_op: + batch_op.drop_constraint( + "uq_admin_notification_reminders_admin_id_type_threshold", + type_="unique" + ) + batch_op.alter_column( + "threshold", + existing_type=sa.Integer(), + nullable=True, + server_default=None + ) diff --git a/app/db/models.py b/app/db/models.py index 88e433723..dc7581e20 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -749,11 +749,14 @@ class NotificationReminder(Base, CreatedAtUTCMixin): class AdminNotificationReminder(Base, CreatedAtUTCMixin): __tablename__ = "admin_notification_reminders" - __table_args__ = (Index("ix_admin_notification_reminders_admin_id_type", "admin_id", "type"),) + __table_args__ = ( + Index("ix_admin_notification_reminders_admin_id_type", "admin_id", "type"), + UniqueConstraint("admin_id", "type", "threshold", name="uq_admin_notification_reminders_admin_id_type_threshold"), + ) admin_id: Mapped[int] = fk_id_column("admins.id", ondelete="CASCADE") admin: Mapped[Admin] = relationship(back_populates="notification_reminders", init=False) type: Mapped[ReminderType] = mapped_column(SQLEnum(ReminderType)) - threshold: Mapped[int | None] = mapped_column(default=None) + threshold: Mapped[int] = mapped_column(server_default="0", default=0) class Group(Base, IdMixin): diff --git a/app/jobs/record_usages.py b/app/jobs/record_usages.py index 7a38e11d6..5db0c20fd 100644 --- a/app/jobs/record_usages.py +++ b/app/jobs/record_usages.py @@ -100,9 +100,17 @@ def _chunked(items: list, size: int): async def get_dialect() -> str: - """Get the database dialect name without holding the session open.""" + """Get the database dialect name. Cached after first call since the dialect never changes.""" + if _dialect_cache: + return _dialect_cache[0] async with GetDB() as db: - return db.bind.dialect.name + dialect = db.bind.dialect.name + _dialect_cache.append(dialect) + return dialect + + +# Simple one-element list used as a mutable cache container (set once, read many times) +_dialect_cache: list[str] = [] def build_node_user_usage_upsert(dialect: str, upsert_params: list[dict]): diff --git a/app/jobs/review_admins.py b/app/jobs/review_admins.py index d79507ceb..f927d8a19 100644 --- a/app/jobs/review_admins.py +++ b/app/jobs/review_admins.py @@ -11,13 +11,15 @@ from datetime import UTC, datetime as dt +from sqlalchemy import select + from app import notification, scheduler from app.db import GetDB from app.db.crud.admin import ( - create_admin_notification_reminder_if_absent, + bulk_create_admin_notification_reminders, get_usage_percentage_reached_admins, ) -from app.db.models import Admin, ReminderType +from app.db.models import Admin, AdminNotificationReminder, ReminderType from app.models.admin import AdminDetails, AdminRoleData from app.models.admin_role import RoleLimits from app.models.validators import ListValidator @@ -69,20 +71,51 @@ async def _send_usage_limit_warning_notifications(db): for threshold in default_thresholds: threshold_admins = await get_usage_percentage_reached_admins(db, threshold) - for admin in threshold_admins: - if not admin.data_limit or admin.data_limit <= 0: - continue - reminder_created = await create_admin_notification_reminder_if_absent( - db, - admin.id, - ReminderType.data_usage, - threshold, + + candidate_admins = [admin for admin in threshold_admins if admin.data_limit and admin.data_limit > 0] + if not candidate_admins: + continue + + candidate_ids = [admin.id for admin in candidate_admins] + + # Lock the Admin rows to serialize reminder checks/creation for these admins + await db.execute( + select(Admin.id) + .where(Admin.id.in_(candidate_ids)) + .with_for_update() + ) + + # Fetch existing reminders for this threshold to avoid duplicate notifications + result = await db.execute( + select(AdminNotificationReminder.admin_id) + .where( + AdminNotificationReminder.admin_id.in_(candidate_ids), + AdminNotificationReminder.type == ReminderType.data_usage, + AdminNotificationReminder.threshold == threshold, ) - if not reminder_created: + ) + existing_ids = set(result.scalars().all()) + + successfully_sent = [] + for admin in candidate_admins: + if admin.id in existing_ids: continue + usage_percentage = int((admin.used_traffic * 100) / admin.data_limit) admin_model = _admin_usage_warning_details(admin) - await notification.admin_usage_limit_reached(admin_model, usage_percentage, threshold) + + try: + # Send the notification first + await notification.admin_usage_limit_reached(admin_model, usage_percentage, threshold) + successfully_sent.append( + {"admin_id": admin.id, "type": ReminderType.data_usage, "threshold": threshold} + ) + except Exception as e: + logger.error(f"Failed to send usage limit warning to admin {admin.id}: {e}") + + # Bulk-insert only successfully sent reminders after sending + if successfully_sent: + await bulk_create_admin_notification_reminders(db, successfully_sent) async def limit_admins_job(): diff --git a/tests/test_review_admins_unit.py b/tests/test_review_admins_unit.py new file mode 100644 index 000000000..c31d20be1 --- /dev/null +++ b/tests/test_review_admins_unit.py @@ -0,0 +1,177 @@ +import pytest +from uuid import uuid4 +from sqlalchemy import select +from app.db.models import Admin, AdminNotificationReminder, AdminRole, ReminderType +from app.db.crud.admin import bulk_create_admin_notification_reminders +from app.jobs.review_admins import _send_usage_limit_warning_notifications +from tests.api import TestSession, engine +from unittest.mock import AsyncMock, MagicMock, patch + +@pytest.fixture(autouse=True) +async def setup_database(): + from app.db import base + + # MySQL/MariaDB do not allow defaults on JSON columns; strip them temporarily + proxy_default = None + proxy_column = None + is_mysql = engine.dialect.name == "mysql" + if is_mysql: + users_table = base.Base.metadata.tables["users"] + proxy_column = users_table.c.proxy_settings + proxy_default = proxy_column.server_default + proxy_column.server_default = None + + try: + async with engine.begin() as conn: + await conn.run_sync(base.Base.metadata.create_all) + finally: + if is_mysql and proxy_column is not None: + proxy_column.server_default = proxy_default + + async with TestSession() as session: + # Seed default roles if they do not exist (to satisfy FK constraints) + existing_roles = (await session.execute(select(AdminRole))).scalars().all() + if not existing_roles: + owner = AdminRole(name="owner", is_owner=True, permissions={}, limits={}, features={}, access={}) + owner.id = 1 + administrator = AdminRole(name="administrator", is_owner=False, permissions={}, limits={}, features={}, access={}) + administrator.id = 2 + operator = AdminRole(name="operator", is_owner=False, permissions={}, limits={}, features={}, access={}) + operator.id = 3 + session.add_all([owner, administrator, operator]) + await session.commit() + +@pytest.mark.asyncio +async def test_bulk_create_admin_notification_reminders_idempotency(): + async with TestSession() as session: + # Create an admin to associate reminders with + admin = Admin(username=f"idempotent_{uuid4().hex[:8]}", hashed_password="secret", role_id=3) + session.add(admin) + await session.flush() + + reminder_data = [ + {"admin_id": admin.id, "type": ReminderType.data_usage, "threshold": 80}, + {"admin_id": admin.id, "type": ReminderType.data_usage, "threshold": 80}, # duplicate input + ] + + # Call the bulk helper + inserted = await bulk_create_admin_notification_reminders(session, reminder_data) + + # Check that only one was inserted and returned + assert len(inserted) == 1 + assert inserted[0]["admin_id"] == admin.id + assert inserted[0]["threshold"] == 80 + + # Query DB to verify + db_reminders = (await session.execute( + select(AdminNotificationReminder).where(AdminNotificationReminder.admin_id == admin.id) + )).scalars().all() + assert len(db_reminders) == 1 + assert db_reminders[0].threshold == 80 + + # Run again with same data + inserted_second = await bulk_create_admin_notification_reminders(session, reminder_data) + assert len(inserted_second) == 0 + + # DB count should still be 1 + db_reminders_second = (await session.execute( + select(AdminNotificationReminder).where(AdminNotificationReminder.admin_id == admin.id) + )).scalars().all() + assert len(db_reminders_second) == 1 + +@pytest.mark.asyncio +@patch("app.jobs.review_admins.notification") +@patch("app.jobs.review_admins.notification_enable") +@patch("app.jobs.review_admins.get_usage_percentage_reached_admins") +@patch("app.jobs.review_admins._admin_usage_warning_details") +async def test_send_usage_limit_warning_notifications_idempotent( + mock_details, mock_get_admins, mock_notif_enable, mock_notification +): + async with TestSession() as session: + # Create test admin + admin = Admin( + username=f"notif_{uuid4().hex[:8]}", + hashed_password="secret", + role_id=3, + data_limit=1000, + used_traffic=850, # 85% usage + ) + session.add(admin) + await session.flush() + + # Setup mocks + mock_details.return_value = MagicMock() + + mock_notif_enable.return_value = AsyncMock() + mock_notif_enable.return_value.admin.usage_limit_warning = True + mock_notif_enable.return_value.admin.usage_limit_warning_percentages = [80] + + mock_get_admins.return_value = [admin] + + mock_notification.admin_usage_limit_reached = AsyncMock() + + # First call: should insert reminder and send notification + await _send_usage_limit_warning_notifications(session) + assert mock_notification.admin_usage_limit_reached.call_count == 1 + + # Reset mock + mock_notification.admin_usage_limit_reached.reset_mock() + + # Second call: should NOT send notification (since reminder exists) + await _send_usage_limit_warning_notifications(session) + assert mock_notification.admin_usage_limit_reached.call_count == 0 + +@pytest.mark.asyncio +@patch("app.jobs.review_admins.notification") +@patch("app.jobs.review_admins.notification_enable") +@patch("app.jobs.review_admins.get_usage_percentage_reached_admins") +@patch("app.jobs.review_admins._admin_usage_warning_details") +async def test_send_usage_limit_warning_notifications_failure_handling( + mock_details, mock_get_admins, mock_notif_enable, mock_notification +): + async with TestSession() as session: + # Create test admins + admin_ok = Admin( + username=f"ok_{uuid4().hex[:8]}", + hashed_password="secret", + role_id=3, + data_limit=1000, + used_traffic=850, + ) + admin_fail = Admin( + username=f"fail_{uuid4().hex[:8]}", + hashed_password="secret", + role_id=3, + data_limit=1000, + used_traffic=850, + ) + session.add_all([admin_ok, admin_fail]) + await session.flush() + + mock_details.side_effect = lambda admin: MagicMock(id=admin.id) + + mock_notif_enable.return_value = AsyncMock() + mock_notif_enable.return_value.admin.usage_limit_warning = True + mock_notif_enable.return_value.admin.usage_limit_warning_percentages = [80] + + mock_get_admins.return_value = [admin_ok, admin_fail] + + # Succeed for admin_ok, raise exception for admin_fail + async def side_effect(admin_model, usage_percentage, threshold): + if admin_model.id == admin_fail.id: + raise RuntimeError("Network failure") + return + + mock_notification.admin_usage_limit_reached = AsyncMock(side_effect=side_effect) + + # Call notifications + await _send_usage_limit_warning_notifications(session) + + # Query DB to verify reminders + reminders = (await session.execute( + select(AdminNotificationReminder) + )).scalars().all() + + admin_ids_with_reminders = {r.admin_id for r in reminders} + assert admin_ok.id in admin_ids_with_reminders + assert admin_fail.id not in admin_ids_with_reminders