From b77e06bd095b1409600885bb4ab62aac4edb9e3c Mon Sep 17 00:00:00 2001 From: wookie184 Date: Sun, 26 Feb 2023 17:36:48 +0000 Subject: [PATCH 01/12] Add nomination_discussion channel constant --- bot/constants.py | 1 + config-default.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index f1fb5471f1..4a08717b2c 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -444,6 +444,7 @@ class Channels(metaclass=YAMLGetter): mods: int nominations: int nomination_voting: int + nomination_discussion: int organisation: int admin_announcements: int diff --git a/config-default.yml b/config-default.yml index de0f7e4e89..362c8738bb 100644 --- a/config-default.yml +++ b/config-default.yml @@ -208,6 +208,7 @@ guild: mod_meta: 775412552795947058 nominations: 822920136150745168 nomination_voting: 822853512709931008 + nomination_discussion: 798959130634747914 organisation: &ORGANISATION 551789653284356126 staff_lounge: &STAFF_LOUNGE 464905259261755392 staff_info: &STAFF_INFO 396684402404622347 From c66efd745e639aac5166d6ccc53e64990b19341c Mon Sep 17 00:00:00 2001 From: wookie184 Date: Sun, 26 Feb 2023 17:37:19 +0000 Subject: [PATCH 02/12] Add nominations api method to get activity --- bot/exts/recruitment/talentpool/_api.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/bot/exts/recruitment/talentpool/_api.py b/bot/exts/recruitment/talentpool/_api.py index c00c8c09cd..7b6c67fc3e 100644 --- a/bot/exts/recruitment/talentpool/_api.py +++ b/bot/exts/recruitment/talentpool/_api.py @@ -112,3 +112,21 @@ async def post_nomination( } result = await self.site_api.post("bot/nominations", json=data) return Nomination.parse_obj(result) + + async def get_activity( + self, + user_ids: list[int], + *, + days: int, + ) -> dict[int, int]: + """ + Get the number of messages sent in the past `days` days by users with the given IDs. + + Returns a dictionary mapping user ID to message count. + """ + result = await self.site_api.post( + "bot/users/metricity_activity_data", + json=user_ids, + params={"days": str(days)} + ) + return {int(user_id): message_count for user_id, message_count in result.items()} From c391c79aafb39f21861a173284858e292bd5c80f Mon Sep 17 00:00:00 2001 From: wookie184 Date: Sun, 26 Feb 2023 17:39:06 +0000 Subject: [PATCH 03/12] Only review users with recent activity --- bot/exts/recruitment/talentpool/_review.py | 98 +++++++++++++++------- 1 file changed, 66 insertions(+), 32 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index f41e08fe10..9436f2badf 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -37,6 +37,8 @@ MIN_REVIEW_INTERVAL = timedelta(days=1) # Minimum time between nomination and sending a review MIN_NOMINATION_TIME = timedelta(days=7) +# Number of days ago that the user must have activity since +RECENT_ACTIVITY_DAYS = 7 # A constant for weighting number of nomination entries against nomination age when selecting a user to review. # The higher this is, the lower the effect of review age. At 1, age and number of entries are weighted equally. @@ -107,56 +109,88 @@ async def is_ready_for_review(self) -> bool: return True - async def get_nomination_to_review(self) -> Optional[Nomination]: + async def is_nomination_ready_for_review( + self, + nomination: Nomination, + user_message_count: int, + now: datetime, + ) -> bool: """ - Returns the Nomination of the next user to review, or None if there are no users ready. + Returns a boolean representing whether a nomination should be reviewed. Users will only be selected for review if: - They have not already been reviewed. - They have been nominated for longer than `MIN_NOMINATION_TIME`. + - They have sent at least one message in the server recently. + - They are still a member of the server. + """ + guild = self.bot.get_guild(Guild.id) + time_since_nomination = now - nomination.inserted_at + return ( + # Must be an active nomination + nomination.active and + # ... that has not already been reviewed + not nomination.reviewed and + # ... and has been nominated for long enough + time_since_nomination > MIN_NOMINATION_TIME and + # ... and is for a user that has been active recently + user_message_count > 0 and + # ... and is currently a member of the server + await get_or_fetch_member(guild, nomination.id) is not None + ) - The priority of the review is determined by how many nominations the user has - (more nominations = higher priority). - For users with equal priority the oldest nomination will be reviewed first. + async def sort_nominations_to_review(self, nominations: list[Nomination], now: datetime) -> list[Nomination]: """ - now = datetime.now(timezone.utc) + Sorts a list of nominations by priority for review. - possible_nominations: list[Nomination] = [] - nominations = await self.api.get_nominations(active=True) - for nomination in nominations: - time_since_nomination = now - nomination.inserted_at - if ( - not nomination.reviewed - and time_since_nomination > MIN_NOMINATION_TIME - ): - possible_nominations.append(nomination) + The priority of the review is determined based on how many nominations the user has + (more nominations = higher priority), and the age of the nomination. + """ + oldest_date = min(nomination.inserted_at for nomination in nominations) + max_entries = max(len(nomination.entries) for nomination in nominations) - if not possible_nominations: - log.debug("No users ready to review.") - return None + def score_nomination(nomination: Nomination) -> float: + """ + Scores a nomination based on age and number of nomination entries. - oldest_date = min(nomination.inserted_at for nomination in possible_nominations) - max_entries = max(len(nomination.entries) for nomination in possible_nominations) + The higher the score, the higher the priority for being put up for review should be. + """ + num_entries = len(nomination.entries) + entries_score = num_entries / max_entries - def sort_key(nomination: Nomination) -> float: - return self.score_nomination(nomination, oldest_date, now, max_entries) + nomination_date = nomination.inserted_at + age_score = (nomination_date - now) / (oldest_date - now) - return max(possible_nominations, key=sort_key) + return entries_score * REVIEW_SCORE_WEIGHT + age_score - @staticmethod - def score_nomination(nomination: Nomination, oldest_date: datetime, now: datetime, max_entries: int) -> float: + return sorted(nominations, key=score_nomination, reverse=True) + + async def get_nomination_to_review(self) -> Nomination | None: """ - Scores a nomination based on age and number of nomination entries. + Returns the Nomination of the next user to review, or None if there are no users ready. - The higher the score, the higher the priority for being put up for review should be. + See `is_ready_for_review` for the criteria for a user to be ready for review. + See `sort_nominations_to_review` for the criteria for a user to be prioritised for review. """ - num_entries = len(nomination.entries) - entries_score = num_entries / max_entries + now = datetime.now(timezone.utc) + nominations = await self.api.get_nominations(active=True) + if not nominations: + return None - nomination_date = nomination.inserted_at - age_score = (nomination_date - now) / (oldest_date - now) + messages_per_user = await self.api.get_activity( + [nomination.user_id for nomination in nominations], + days=RECENT_ACTIVITY_DAYS, + ) + possible_nominations = [ + nomination for nomination in nominations + if await self.is_nomination_ready_for_review(nomination, messages_per_user[nomination.user_id], now) + ] + if not possible_nominations: + log.info("No nominations are ready to review") + return None - return entries_score * REVIEW_SCORE_WEIGHT + age_score + sorted_nominations = await self.sort_nominations_to_review(possible_nominations, now) + return sorted_nominations[0] async def post_review(self, nomination: Nomination) -> None: """Format the review of a user and post it to the nomination voting channel.""" From 2e3fb8fcdf6e4507e8ba9c8b1e776f9dfe17efb5 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Sun, 26 Feb 2023 17:39:38 +0000 Subject: [PATCH 04/12] Add tests for new behaviour --- .../recruitment/talentpool/test_review.py | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/tests/bot/exts/recruitment/talentpool/test_review.py b/tests/bot/exts/recruitment/talentpool/test_review.py index 03c78ab08f..9ad429acec 100644 --- a/tests/bot/exts/recruitment/talentpool/test_review.py +++ b/tests/bot/exts/recruitment/talentpool/test_review.py @@ -30,13 +30,17 @@ def nomination( inserted_at: datetime, num_entries: int, reviewed: bool = False, - id: int | None = None + id: int | None = None, + msg_count: int = 1000, ) -> Mock: + id = id or MockMember().id return Mock( id=id or MockMember().id, + user_id=id, inserted_at=inserted_at, entries=[Mock() for _ in range(num_entries)], - reviewed=reviewed + reviewed=reviewed, + _msg_count=msg_count, ) @@ -117,10 +121,13 @@ async def test_get_nomination_to_review(self): # Each case contains a list of nominations, followed by the index in that list # of the one that should be selected, or None if None should be returned cases = [ - # One nomination, too recent so don't send. + # Don't send if too recent, already reviewed, or no recent messages. ( [ nomination(now - timedelta(days=1), 5), + nomination(now - timedelta(days=10), 5, reviewed=True), + nomination(now - timedelta(days=10), 5, msg_count=0), + ], None, ), @@ -151,6 +158,11 @@ async def test_get_nomination_to_review(self): with self.subTest(case_num=case_num): get_nominations_mock = AsyncMock(return_value=nominations) self.nomination_api.get_nominations = get_nominations_mock + + activity = {nomination.id: nomination._msg_count for nomination in nominations} + get_activity_mock = AsyncMock(return_value=activity) + self.nomination_api.get_activity = get_activity_mock + res = await self.reviewer.get_nomination_to_review() if expected is None: @@ -158,6 +170,7 @@ async def test_get_nomination_to_review(self): else: self.assertEqual(res, nominations[expected]) get_nominations_mock.assert_called_once_with(active=True) + get_activity_mock.assert_called_once() @patch("bot.exts.recruitment.talentpool._review.MIN_NOMINATION_TIME", timedelta(days=0)) async def test_get_nomination_to_review_order(self): @@ -201,6 +214,10 @@ async def test_get_nomination_to_review_order(self): get_nominations_mock = AsyncMock(return_value=case[i:]) self.nomination_api.get_nominations = get_nominations_mock + activity = {nomination.id: nomination._msg_count for nomination in case} + get_activity_mock = AsyncMock(return_value=activity) + self.nomination_api.get_activity = get_activity_mock + res = await self.reviewer.get_nomination_to_review() self.assertEqual(res, case[i]) get_nominations_mock.assert_called_once_with(active=True) From 70425e45af5454f2e49be51bebfb1a3d14145569 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Sun, 26 Feb 2023 17:54:24 +0000 Subject: [PATCH 05/12] Prune inactive users from the talentpool --- bot/exts/recruitment/talentpool/_cog.py | 47 +++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index a41d9e8c5b..e5dc447ec9 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -13,6 +13,7 @@ from bot.bot import Bot from bot.constants import Bot as BotConfig, Channels, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES from bot.converters import MemberOrUser, UnambiguousMemberOrUser +from bot.exts.recruitment.talentpool._api import Nomination, NominationAPI from bot.exts.recruitment.talentpool._review import Reviewer from bot.log import get_logger from bot.pagination import LinePaginator @@ -20,11 +21,13 @@ from bot.utils.channel import get_or_fetch_channel from bot.utils.members import get_or_fetch_member -from ._api import Nomination, NominationAPI - AUTOREVIEW_ENABLED_KEY = "autoreview_enabled" REASON_MAX_CHARS = 1000 +# The number of days that a user can have no activity (no messages sent) +# until they should be removed from the talentpool. +DAYS_UNTIL_INACTIVE = 30 + log = get_logger(__name__) @@ -47,6 +50,8 @@ async def cog_load(self) -> None: if await self.autoreview_enabled(): self.autoreview_loop.start() + self.prune_talentpool.start() + async def autoreview_enabled(self) -> bool: """Return whether automatic posting of nomination reviews is enabled.""" return await self.talentpool_settings.get(AUTOREVIEW_ENABLED_KEY, True) @@ -122,6 +127,42 @@ async def autoreview_loop(self) -> None: log.info("Running check for users to nominate.") await self.reviewer.maybe_review_user() + @tasks.loop(hours=24) + async def prune_talentpool(self) -> None: + """ + Prune any inactive users from the talentpool. + + A user is considered inactive if they have sent no messages on the server + in the past `DAYS_UNTIL_INACTIVE` days. + """ + log.info("Running task to prune users from talent pool") + nominations = await self.api.get_nominations(active=True) + + if not nominations: + return + + messages_per_user = await self.api.get_activity( + [nomination.user_id for nomination in nominations], + days=DAYS_UNTIL_INACTIVE + ) + + nomination_discussion = self.bot.get_channel(Channels.nomination_discussion) + for nomination in nominations: + if messages_per_user[nomination.user_id] > 0: + continue + + log.info("Removing %s from the talent pool due to inactivity", nomination.user_id) + + await nomination_discussion.send( + f":warning: <@{nomination.user_id}> ({nomination.user_id})" + " was removed from the talentpool due to inactivity." + ) + await self.api.edit_nomination( + nomination.id, + active=False, + end_reason=f"Automatic removal: User was inactive for more than {DAYS_UNTIL_INACTIVE}" + ) + @nomination_group.command( name="nominees", aliases=("nominated", "all", "list", "watched"), @@ -539,3 +580,5 @@ async def cog_unload(self) -> None: # Only cancel the loop task when the autoreview code is not running async with self.autoreview_lock: self.autoreview_loop.cancel() + + self.prune_talentpool.cancel() From 6900fc7dc8e58912d8572590eaff5e8910e047d8 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Sun, 26 Feb 2023 17:57:34 +0000 Subject: [PATCH 06/12] Update tp list command, making it sort by review order by default --- bot/exts/recruitment/talentpool/_cog.py | 89 ++++++++++++++++--------- 1 file changed, 56 insertions(+), 33 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index e5dc447ec9..83a9a81918 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -1,5 +1,6 @@ import asyncio import textwrap +from datetime import datetime, timezone from io import StringIO from typing import Optional, Union @@ -14,7 +15,7 @@ from bot.constants import Bot as BotConfig, Channels, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES from bot.converters import MemberOrUser, UnambiguousMemberOrUser from bot.exts.recruitment.talentpool._api import Nomination, NominationAPI -from bot.exts.recruitment.talentpool._review import Reviewer +from bot.exts.recruitment.talentpool._review import RECENT_ACTIVITY_DAYS, Reviewer from bot.log import get_logger from bot.pagination import LinePaginator from bot.utils import time @@ -163,70 +164,92 @@ async def prune_talentpool(self) -> None: end_reason=f"Automatic removal: User was inactive for more than {DAYS_UNTIL_INACTIVE}" ) - @nomination_group.command( - name="nominees", - aliases=("nominated", "all", "list", "watched"), - root_aliases=("nominees",) + @nomination_group.group( + name="list", + aliases=("nominated", "nominees"), + invoke_without_command=True ) @has_any_role(*MODERATION_ROLES) - async def list_command( + async def list_group( self, ctx: Context, - oldest_first: bool = False, ) -> None: """ - Shows the users that are currently in the talent pool. + Shows the users that are currently in the talent pool, ordered by next to be reviewed. - The optional kwarg `oldest_first` can be used to order the list by oldest nomination. + Note that this order will change over time, so should not be relied upon. """ - await self.list_nominated_users(ctx, oldest_first=oldest_first) + await self.list_nominated_users(ctx, order_by_priority=True) + + @list_group.command(name="oldest") + async def list_oldest(self, ctx: Context) -> None: + """Shows the users that are currently in the talent pool, ordered by oldest nomination.""" + await self.list_nominated_users(ctx, oldest_first=True) + + @list_group.command(name='newest') + async def list_newest(self, ctx: Context) -> None: + """Shows the users that are currently in the talent pool, ordered by newest nomination.""" + await self.list_nominated_users(ctx, oldest_first=False) async def list_nominated_users( self, ctx: Context, oldest_first: bool = False, + order_by_priority: bool = False ) -> None: """ - Gives an overview of the nominated users list. - - It specifies the user's mention, name, how long ago they were nominated, and whether their - review was posted. + Lists the currently nominated users. The optional kwarg `oldest_first` orders the list by oldest entry. """ + now = datetime.now(tz=timezone.utc) + embed = Embed( + title="Talent Pool active nominations", + color=Color.blue() + ) nominations = await self.api.get_nominations(active=True) - if oldest_first: - nominations = reversed(nominations) - lines = [] + if not nominations: + embed.description = "There are no active nominations." + await ctx.send(embed=embed) + return + + if order_by_priority: + nominations = await self.reviewer.sort_nominations_to_review(nominations, now) + elif oldest_first: + nominations.reverse() + + messages_per_user = await self.api.get_activity( + [nomination.user_id for nomination in nominations], + days=RECENT_ACTIVITY_DAYS + ) + lines: list[str] = [] for nomination in nominations: - member = await get_or_fetch_member(ctx.guild, nomination.user_id) line = f"• `{nomination.user_id}`" + + member = await get_or_fetch_member(ctx.guild, nomination.user_id) if member: line += f" ({member.name}#{member.discriminator})" + else: + line += " (not in server)" + line += f", added {time.format_relative(nomination.inserted_at)}" - if not member: # Cross off users who left the server. - line = f"~~{line}~~" + + is_ready_for_review = await self.reviewer.is_nomination_ready_for_review( + nomination, + messages_per_user[nomination.user_id], + now + ) if nomination.reviewed: line += " *(reviewed)*" - lines.append(line) + elif is_ready_for_review: + line += " *(ready for review)*" - if not lines: - lines = ("There's nothing here yet.",) + lines.append(line) - embed = Embed( - title="Talent Pool active nominations", - color=Color.blue() - ) await LinePaginator.paginate(lines, ctx, embed, empty=False) - @nomination_group.command(name='oldest') - @has_any_role(*MODERATION_ROLES) - async def oldest_command(self, ctx: Context) -> None: - """Shows the users that are currently in the talent pool, ordered by oldest nomination.""" - await self.list_nominated_users(ctx, oldest_first=True) - @nomination_group.command( name="forcenominate", aliases=("fw", "forceadd", "fa", "fn", "forcewatch"), From 910c567173be950a33cc472a3ad611497e39bd3e Mon Sep 17 00:00:00 2001 From: wookie184 Date: Sun, 26 Feb 2023 17:58:09 +0000 Subject: [PATCH 07/12] Send a message when a user is unnominated due to being banned --- bot/exts/recruitment/talentpool/_cog.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 83a9a81918..c5a928f2b6 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -499,7 +499,12 @@ async def post_review(self, ctx: Context, user_id: int) -> None: @Cog.listener() async def on_member_ban(self, guild: Guild, user: MemberOrUser) -> None: """Remove `user` from the talent pool after they are banned.""" - await self.end_nomination(user.id, "User was banned.") + if await self.end_nomination(user.id, "Automatic removal: User was banned"): + nomination_discussion = self.bot.get_channel(Channels.nomination_discussion) + await nomination_discussion.send( + f":warning: <@{user.id}> ({user.id})" + " was removed from the talentpool due to being banned." + ) @Cog.listener() async def on_raw_reaction_add(self, payload: RawReactionActionEvent) -> None: From 076f0714a43006bff7c5d39aa80f898ab3254c26 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Thu, 2 Mar 2023 18:03:36 +0000 Subject: [PATCH 08/12] Use get_or_fetch_channel instead of get_channel --- bot/exts/recruitment/talentpool/_cog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index c5a928f2b6..bf4f2416ee 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -147,7 +147,7 @@ async def prune_talentpool(self) -> None: days=DAYS_UNTIL_INACTIVE ) - nomination_discussion = self.bot.get_channel(Channels.nomination_discussion) + nomination_discussion = await get_or_fetch_channel(Channels.nomination_discussion) for nomination in nominations: if messages_per_user[nomination.user_id] > 0: continue @@ -500,7 +500,7 @@ async def post_review(self, ctx: Context, user_id: int) -> None: async def on_member_ban(self, guild: Guild, user: MemberOrUser) -> None: """Remove `user` from the talent pool after they are banned.""" if await self.end_nomination(user.id, "Automatic removal: User was banned"): - nomination_discussion = self.bot.get_channel(Channels.nomination_discussion) + nomination_discussion = await get_or_fetch_channel(Channels.nomination_discussion) await nomination_discussion.send( f":warning: <@{user.id}> ({user.id})" " was removed from the talentpool due to being banned." From ca40036b17135f2abdcb27e56b950a80b584537e Mon Sep 17 00:00:00 2001 From: wookie184 Date: Sat, 4 Mar 2023 12:17:32 +0000 Subject: [PATCH 09/12] Bump inactivity removal threshold and display in message --- bot/exts/recruitment/talentpool/_cog.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index bf4f2416ee..7a9db7886b 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -27,7 +27,7 @@ # The number of days that a user can have no activity (no messages sent) # until they should be removed from the talentpool. -DAYS_UNTIL_INACTIVE = 30 +DAYS_UNTIL_INACTIVE = 45 log = get_logger(__name__) @@ -156,7 +156,8 @@ async def prune_talentpool(self) -> None: await nomination_discussion.send( f":warning: <@{nomination.user_id}> ({nomination.user_id})" - " was removed from the talentpool due to inactivity." + " was removed from the talentpool as they have sent no messages" + f" in the past {DAYS_UNTIL_INACTIVE} days." ) await self.api.edit_nomination( nomination.id, From e9aa8f1621f9fcff0ace8e6cbe37c98449641773 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Sat, 4 Mar 2023 15:07:49 +0000 Subject: [PATCH 10/12] Split default nomination listing into groups --- bot/exts/recruitment/talentpool/_api.py | 3 + bot/exts/recruitment/talentpool/_cog.py | 116 +++++++++++++++------ bot/exts/recruitment/talentpool/_review.py | 19 +++- 3 files changed, 101 insertions(+), 37 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_api.py b/bot/exts/recruitment/talentpool/_api.py index 7b6c67fc3e..e12111de5f 100644 --- a/bot/exts/recruitment/talentpool/_api.py +++ b/bot/exts/recruitment/talentpool/_api.py @@ -124,6 +124,9 @@ async def get_activity( Returns a dictionary mapping user ID to message count. """ + if not user_ids: + return {} + result = await self.site_api.post( "bot/users/metricity_activity_data", json=user_ids, diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 7a9db7886b..b92afdbcca 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -15,7 +15,7 @@ from bot.constants import Bot as BotConfig, Channels, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES from bot.converters import MemberOrUser, UnambiguousMemberOrUser from bot.exts.recruitment.talentpool._api import Nomination, NominationAPI -from bot.exts.recruitment.talentpool._review import RECENT_ACTIVITY_DAYS, Reviewer +from bot.exts.recruitment.talentpool._review import Reviewer from bot.log import get_logger from bot.pagination import LinePaginator from bot.utils import time @@ -152,6 +152,9 @@ async def prune_talentpool(self) -> None: if messages_per_user[nomination.user_id] > 0: continue + if nomination.reviewed: + continue + log.info("Removing %s from the talent pool due to inactivity", nomination.user_id) await nomination_discussion.send( @@ -176,56 +179,105 @@ async def list_group( ctx: Context, ) -> None: """ - Shows the users that are currently in the talent pool, ordered by next to be reviewed. + Shows the users that are currently in the talent pool. - Note that this order will change over time, so should not be relied upon. + The "Recent Nominations" sections shows users nominated in the past 7 days, + so will not be considered for autoreview. + + In the "Autoreview Priority" section a :zzz: emoji will be shown next to + users that have not been active recently enough to be considered for autoreview. + Note that the order in this section will change over time so should not be relied upon. """ - await self.list_nominated_users(ctx, order_by_priority=True) + await self.show_nominations_list(ctx, grouped_view=True) @list_group.command(name="oldest") async def list_oldest(self, ctx: Context) -> None: """Shows the users that are currently in the talent pool, ordered by oldest nomination.""" - await self.list_nominated_users(ctx, oldest_first=True) + await self.show_nominations_list(ctx, oldest_first=True) @list_group.command(name='newest') async def list_newest(self, ctx: Context) -> None: """Shows the users that are currently in the talent pool, ordered by newest nomination.""" - await self.list_nominated_users(ctx, oldest_first=False) + await self.show_nominations_list(ctx, oldest_first=False) - async def list_nominated_users( + async def show_nominations_list( self, ctx: Context, + *, oldest_first: bool = False, - order_by_priority: bool = False + grouped_view: bool = False, ) -> None: """ Lists the currently nominated users. - The optional kwarg `oldest_first` orders the list by oldest entry. + If `grouped_view` is passed, nominations will be displayed in the groups + being reviewed, recent nominations, and others by autoreview priority. + + Otherwise, nominations will be sorted by age + (ordered based on the value of `oldest_first`). """ now = datetime.now(tz=timezone.utc) + nominations = await self.api.get_nominations(active=True) + messages_per_user = await self.api.get_activity( + [nomination.user_id for nomination in nominations], + days=DAYS_UNTIL_INACTIVE + ) + + if grouped_view: + reviewed_nominations = [] + recent_nominations = [] + other_nominations = [] + for nomination in nominations: + if nomination.reviewed: + reviewed_nominations.append(nomination) + elif not self.reviewer.is_nomination_old_enough(nomination, now): + recent_nominations.append(nomination) + else: + other_nominations.append(nomination) + + other_nominations = await self.reviewer.sort_nominations_to_review(other_nominations, now) + + lines = [ + "**Being Reviewed:**", + *await self.list_nominations(ctx, reviewed_nominations, messages_per_user), + "**Recent Nominations:**", + *await self.list_nominations(ctx, recent_nominations, messages_per_user), + "**Other Nominations by Autoreview Priority:**", + *await self.list_nominations(ctx, other_nominations, messages_per_user, show_inactive=True) + ] + else: + if oldest_first: + nominations.reverse() + lines = await self.list_nominations(ctx, nominations, messages_per_user, show_reviewed=True) + + if not lines: + lines = ["There are no active nominations"] + embed = Embed( title="Talent Pool active nominations", color=Color.blue() ) - nominations = await self.api.get_nominations(active=True) + await LinePaginator.paginate(lines, ctx, embed, empty=False) - if not nominations: - embed.description = "There are no active nominations." - await ctx.send(embed=embed) - return + async def list_nominations( + self, + ctx: Context, + nominations: list[Nomination], + messages_per_user: dict[int, int], + show_reviewed: bool = False, + show_inactive: bool = False, + ) -> list[str]: + """ + Formats the given nominations into a list. - if order_by_priority: - nominations = await self.reviewer.sort_nominations_to_review(nominations, now) - elif oldest_first: - nominations.reverse() + Pass `show_reviewed` to indicate reviewed nominations, and `show_inactive` to + indicate if the user doesn't have recent enough activity to be autoreviewed. + """ + lines: list[str] = [] - messages_per_user = await self.api.get_activity( - [nomination.user_id for nomination in nominations], - days=RECENT_ACTIVITY_DAYS - ) + if not nominations: + return ["*None*"] - lines: list[str] = [] for nomination in nominations: line = f"• `{nomination.user_id}`" @@ -233,23 +285,19 @@ async def list_nominated_users( if member: line += f" ({member.name}#{member.discriminator})" else: - line += " (not in server)" + line += " (not on server)" line += f", added {time.format_relative(nomination.inserted_at)}" - is_ready_for_review = await self.reviewer.is_nomination_ready_for_review( - nomination, - messages_per_user[nomination.user_id], - now - ) - if nomination.reviewed: + if show_reviewed and nomination.reviewed: line += " *(reviewed)*" - elif is_ready_for_review: - line += " *(ready for review)*" - lines.append(line) + is_active = self.reviewer.is_user_active_enough(messages_per_user[nomination.user_id]) + if show_inactive and not is_active: + line += " :zzz:" - await LinePaginator.paginate(lines, ctx, embed, empty=False) + lines.append(line) + return lines @nomination_group.command( name="forcenominate", diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index 9436f2badf..9abf3affa5 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -109,6 +109,17 @@ async def is_ready_for_review(self) -> bool: return True + @staticmethod + def is_nomination_old_enough(nomination: Nomination, now: datetime) -> bool: + """Check if a nomination is old enough to autoreview.""" + time_since_nomination = now - nomination.inserted_at + return time_since_nomination > MIN_NOMINATION_TIME + + @staticmethod + def is_user_active_enough(user_message_count: int) -> bool: + """Check if a user's message count is enough for them to be autoreviewed.""" + return user_message_count > 0 + async def is_nomination_ready_for_review( self, nomination: Nomination, @@ -125,16 +136,15 @@ async def is_nomination_ready_for_review( - They are still a member of the server. """ guild = self.bot.get_guild(Guild.id) - time_since_nomination = now - nomination.inserted_at return ( # Must be an active nomination nomination.active and # ... that has not already been reviewed not nomination.reviewed and # ... and has been nominated for long enough - time_since_nomination > MIN_NOMINATION_TIME and + self.is_nomination_old_enough(nomination, now) and # ... and is for a user that has been active recently - user_message_count > 0 and + self.is_user_active_enough(user_message_count) and # ... and is currently a member of the server await get_or_fetch_member(guild, nomination.id) is not None ) @@ -146,6 +156,9 @@ async def sort_nominations_to_review(self, nominations: list[Nomination], now: d The priority of the review is determined based on how many nominations the user has (more nominations = higher priority), and the age of the nomination. """ + if not nominations: + return [] + oldest_date = min(nomination.inserted_at for nomination in nominations) max_entries = max(len(nomination.entries) for nomination in nominations) From cb8706007ed3c8f8d2b8412541ec9f8e2f813df5 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Sat, 4 Mar 2023 15:25:46 +0000 Subject: [PATCH 11/12] Make show_* arguments to list_nominations keyword only --- bot/exts/recruitment/talentpool/_cog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index b92afdbcca..f3716140a9 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -264,6 +264,7 @@ async def list_nominations( ctx: Context, nominations: list[Nomination], messages_per_user: dict[int, int], + *, show_reviewed: bool = False, show_inactive: bool = False, ) -> list[str]: From 986e2079c23312a5e7d6bedff401c0677d4f4f7a Mon Sep 17 00:00:00 2001 From: wookie184 Date: Sat, 25 Mar 2023 17:47:54 +0000 Subject: [PATCH 12/12] Fix: use nomination.user_id instead of id in get_or_fetch_member --- bot/exts/recruitment/talentpool/_review.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index b99d91c5fd..f46e565ed6 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -154,7 +154,7 @@ async def is_nomination_ready_for_review( # ... and is for a user that has been active recently self.is_user_active_enough(user_message_count) and # ... and is currently a member of the server - await get_or_fetch_member(guild, nomination.id) is not None + await get_or_fetch_member(guild, nomination.user_id) is not None ) async def sort_nominations_to_review(self, nominations: list[Nomination], now: datetime) -> list[Nomination]: