Repository: python-discord/bot

Issue:
Remove talent pool watch channel
The nominations watch channel should be removed, it isn't used so is not necessary to have any more. All the other nomination logic should stay, we just don't need to relay nominated users' messages to a channel.


Discussion:

Resolving patch:
diff --git a/bot/constants.py b/bot/constants.py
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -472,7 +472,6 @@ class Channels(metaclass=YAMLGetter):
     voice_chat_1: int
 
     big_brother_logs: int
-    talent_pool: int
 
 
 class Webhooks(metaclass=YAMLGetter):
@@ -483,7 +482,6 @@ class Webhooks(metaclass=YAMLGetter):
     dev_log: int
     duck_pond: int
     incidents_archive: int
-    talent_pool: int
 
 
 class Roles(metaclass=YAMLGetter):
diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py
--- a/bot/exts/recruitment/talentpool/_cog.py
+++ b/bot/exts/recruitment/talentpool/_cog.py
@@ -1,6 +1,6 @@
 import logging
 import textwrap
-from collections import ChainMap
+from collections import ChainMap, defaultdict
 from io import StringIO
 from typing import Union
 
@@ -11,12 +11,12 @@
 
 from bot.api import ResponseCodeError
 from bot.bot import Bot
-from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES, Webhooks
+from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES
 from bot.converters import MemberOrUser
-from bot.exts.moderation.watchchannels._watchchannel import WatchChannel
 from bot.exts.recruitment.talentpool._review import Reviewer
 from bot.pagination import LinePaginator
 from bot.utils import time
+from bot.utils.time import get_time_delta
 
 AUTOREVIEW_ENABLED_KEY = "autoreview_enabled"
 REASON_MAX_CHARS = 1000
@@ -24,25 +24,17 @@
 log = logging.getLogger(__name__)
 
 
-class TalentPool(WatchChannel, Cog, name="Talentpool"):
-    """Relays messages of helper candidates to a watch channel to observe them."""
+class TalentPool(Cog, name="Talentpool"):
+    """Used to nominate potential helper candidates."""
 
     # RedisCache[str, bool]
     # Can contain a single key, "autoreview_enabled", with the value a bool indicating if autoreview is enabled.
     talentpool_settings = RedisCache()
 
     def __init__(self, bot: Bot) -> None:
-        super().__init__(
-            bot,
-            destination=Channels.talent_pool,
-            webhook_id=Webhooks.talent_pool,
-            api_endpoint='bot/nominations',
-            api_default_params={'active': 'true', 'ordering': '-inserted_at'},
-            logger=log,
-            disable_header=True,
-        )
-
+        self.bot = bot
         self.reviewer = Reviewer(self.__class__.__name__, bot, self)
+        self.api_default_params = {'active': 'true', 'ordering': '-inserted_at'}
         self.bot.loop.create_task(self.schedule_autoreviews())
 
     async def schedule_autoreviews(self) -> None:
@@ -50,12 +42,31 @@ async def schedule_autoreviews(self) -> None:
         if await self.autoreview_enabled():
             await self.reviewer.reschedule_reviews()
         else:
-            self.log.trace("Not scheduling reviews as autoreview is disabled.")
+            log.trace("Not scheduling reviews as autoreview is disabled.")
 
     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)
 
+    async def refresh_cache(self) -> bool:
+        """Updates TalentPool users cache."""
+        try:
+            data = await self.bot.api_client.get(
+                'bot/nominations',
+                params=self.api_default_params
+            )
+        except ResponseCodeError as err:
+            log.exception("Failed to fetch the currently nominated users from the API", exc_info=err)
+            return False
+
+        self.cache = defaultdict(dict)
+
+        for entry in data:
+            user_id = entry.pop('user')
+            self.cache[user_id] = entry
+
+        return True
+
     @group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True)
     @has_any_role(*MODERATION_ROLES)
     async def nomination_group(self, ctx: Context) -> None:
@@ -106,25 +117,29 @@ async def autoreview_status(self, ctx: Context) -> None:
         else:
             await ctx.send("Autoreview is currently disabled")
 
-    @nomination_group.command(name='watched', aliases=('all', 'list'), root_aliases=("nominees",))
+    @nomination_group.command(
+        name="nominees",
+        aliases=("nominated", "all", "list", "watched"),
+        root_aliases=("nominees",)
+    )
     @has_any_role(*MODERATION_ROLES)
-    async def watched_command(
+    async def list_command(
         self,
         ctx: Context,
         oldest_first: bool = False,
         update_cache: bool = True
     ) -> None:
         """
-        Shows the users that are currently being monitored in the talent pool.
+        Shows the users that are currently in the talent pool.
 
         The optional kwarg `oldest_first` can be used to order the list by oldest nomination.
 
         The optional kwarg `update_cache` can be used to update the user
         cache using the API before listing the users.
         """
-        await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache)
+        await self.list_nominated_users(ctx, oldest_first=oldest_first, update_cache=update_cache)
 
-    async def list_watched_users(
+    async def list_nominated_users(
         self,
         ctx: Context,
         oldest_first: bool = False,
@@ -141,16 +156,27 @@ async def list_watched_users(
         The optional kwarg `update_cache` specifies whether the cache should
         be refreshed by polling the API.
         """
-        # TODO Once the watch channel is removed, this can be done in a smarter way, without splitting and overriding
-        # the list_watched_users function.
-        watched_data = await self.prepare_watched_users_data(ctx, oldest_first, update_cache)
+        successful_update = False
+        if update_cache:
+            if not (successful_update := await self.refresh_cache()):
+                await ctx.send(":warning: Unable to update cache. Data may be inaccurate.")
 
-        if update_cache and not watched_data["updated"]:
-            await ctx.send(f":x: Failed to update {self.__class__.__name__} user cache, serving from cache")
+        nominations = self.cache.items()
+        if oldest_first:
+            nominations = reversed(nominations)
 
         lines = []
-        for user_id, line in watched_data["info"].items():
-            if self.watched_users[user_id]['reviewed']:
+
+        for user_id, user_data in nominations:
+            member = ctx.guild.get_member(user_id)
+            line = f"• `{user_id}`"
+            if member:
+                line += f" ({member.name}#{member.discriminator})"
+            inserted_at = user_data['inserted_at']
+            line += f", added {get_time_delta(inserted_at)}"
+            if not member:  # Cross off users who left the server.
+                line = f"~~{line}~~"
+            if user_data['reviewed']:
                 line += " *(reviewed)*"
             elif user_id in self.reviewer:
                 line += " *(scheduled)*"
@@ -160,7 +186,7 @@ async def list_watched_users(
             lines = ("There's nothing here yet.",)
 
         embed = Embed(
-            title=watched_data["title"],
+            title=f"Talent Pool active nominations ({'updated' if update_cache and successful_update else 'cached'})",
             color=Color.blue()
         )
         await LinePaginator.paginate(lines, ctx, embed, empty=False)
@@ -169,26 +195,30 @@ async def list_watched_users(
     @has_any_role(*MODERATION_ROLES)
     async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None:
         """
-        Shows talent pool monitored users ordered by oldest nomination.
+        Shows talent pool users ordered by oldest nomination.
 
         The optional kwarg `update_cache` can be used to update the user
         cache using the API before listing the users.
         """
-        await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache)
+        await ctx.invoke(self.list_command, oldest_first=True, update_cache=update_cache)
 
-    @nomination_group.command(name='forcewatch', aliases=('fw', 'forceadd', 'fa'), root_aliases=("forcenominate",))
+    @nomination_group.command(
+        name="forcenominate",
+        aliases=("fw", "forceadd", "fa", "fn", "forcewatch"),
+        root_aliases=("forcenominate",)
+    )
     @has_any_role(*MODERATION_ROLES)
-    async def force_watch_command(self, ctx: Context, user: MemberOrUser, *, reason: str = '') -> None:
+    async def force_nominate_command(self, ctx: Context, user: MemberOrUser, *, reason: str = '') -> None:
         """
         Adds the given `user` to the talent pool, from any channel.
 
         A `reason` for adding the user to the talent pool is optional.
         """
-        await self._watch_user(ctx, user, reason)
+        await self._nominate_user(ctx, user, reason)
 
-    @nomination_group.command(name='watch', aliases=('w', 'add', 'a'), root_aliases=("nominate",))
+    @nomination_group.command(name='nominate', aliases=("w", "add", "a", "watch"), root_aliases=("nominate",))
     @has_any_role(*STAFF_ROLES)
-    async def watch_command(self, ctx: Context, user: MemberOrUser, *, reason: str = '') -> None:
+    async def nominate_command(self, ctx: Context, user: MemberOrUser, *, reason: str = '') -> None:
         """
         Adds the given `user` to the talent pool.
 
@@ -199,26 +229,26 @@ async def watch_command(self, ctx: Context, user: MemberOrUser, *, reason: str =
             if any(role.id in MODERATION_ROLES for role in ctx.author.roles):
                 await ctx.send(
                     f":x: Nominations should be run in the <#{Channels.nominations}> channel. "
-                    "Use `!tp forcewatch` to override this check."
+                    "Use `!tp forcenominate` to override this check."
                 )
             else:
                 await ctx.send(f":x: Nominations must be run in the <#{Channels.nominations}> channel")
             return
 
-        await self._watch_user(ctx, user, reason)
+        await self._nominate_user(ctx, user, reason)
 
-    async def _watch_user(self, ctx: Context, user: MemberOrUser, reason: str) -> None:
+    async def _nominate_user(self, ctx: Context, user: MemberOrUser, reason: str) -> None:
         """Adds the given user to the talent pool."""
         if user.bot:
-            await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.")
+            await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. Only humans can be nominated.")
             return
 
         if isinstance(user, Member) and any(role.id in STAFF_ROLES for role in user.roles):
             await ctx.send(":x: Nominating staff members, eh? Here's a cookie :cookie:")
             return
 
-        if not await self.fetch_user_cache():
-            await ctx.send(f":x: Failed to update the user cache; can't add {user}")
+        if not await self.refresh_cache():
+            await ctx.send(f":x: Failed to update the cache; can't add {user}")
             return
 
         if len(reason) > REASON_MAX_CHARS:
@@ -227,7 +257,7 @@ async def _watch_user(self, ctx: Context, user: MemberOrUser, reason: str) -> No
 
         # Manual request with `raise_for_status` as False because we want the actual response
         session = self.bot.api_client.session
-        url = self.bot.api_client._url_for(self.api_endpoint)
+        url = self.bot.api_client._url_for('bot/nominations')
         kwargs = {
             'json': {
                 'actor': ctx.author.id,
@@ -249,13 +279,13 @@ async def _watch_user(self, ctx: Context, user: MemberOrUser, reason: str) -> No
             else:
                 resp.raise_for_status()
 
-        self.watched_users[user.id] = response_data
+        self.cache[user.id] = response_data
 
         if await self.autoreview_enabled() and user.id not in self.reviewer:
             self.reviewer.schedule_review(user.id)
 
         history = await self.bot.api_client.get(
-            self.api_endpoint,
+            'bot/nominations',
             params={
                 "user__id": str(user.id),
                 "active": "false",
@@ -274,7 +304,7 @@ async def _watch_user(self, ctx: Context, user: MemberOrUser, reason: str) -> No
     async def history_command(self, ctx: Context, user: MemberOrUser) -> None:
         """Shows the specified user's nomination history."""
         result = await self.bot.api_client.get(
-            self.api_endpoint,
+            'bot/nominations',
             params={
                 'user__id': str(user.id),
                 'ordering': "-active,-inserted_at"
@@ -298,20 +328,20 @@ async def history_command(self, ctx: Context, user: MemberOrUser) -> None:
             max_size=1000
         )
 
-    @nomination_group.command(name='unwatch', aliases=('end', ), root_aliases=("unnominate",))
+    @nomination_group.command(name="end", aliases=("unwatch", "unnominate"), root_aliases=("unnominate",))
     @has_any_role(*MODERATION_ROLES)
-    async def unwatch_command(self, ctx: Context, user: MemberOrUser, *, reason: str) -> None:
+    async def end_nomination_command(self, ctx: Context, user: MemberOrUser, *, reason: str) -> None:
         """
         Ends the active nomination of the specified user with the given reason.
 
         Providing a `reason` is required.
         """
         if len(reason) > REASON_MAX_CHARS:
-            await ctx.send(f":x: Maxiumum allowed characters for the end reason is {REASON_MAX_CHARS}.")
+            await ctx.send(f":x: Maximum allowed characters for the end reason is {REASON_MAX_CHARS}.")
             return
 
-        if await self.unwatch(user.id, reason):
-            await ctx.send(f":white_check_mark: Messages sent by {user.mention} will no longer be relayed")
+        if await self.end_nomination(user.id, reason):
+            await ctx.send(f":white_check_mark: Successfully un-nominated {user}")
         else:
             await ctx.send(":x: The specified user does not have an active nomination")
 
@@ -330,10 +360,10 @@ async def edit_reason_command(self, ctx: Context, nomination_id: int, actor: Mem
             return
 
         try:
-            nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}")
+            nomination = await self.bot.api_client.get(f"bot/nominations/{nomination_id}")
         except ResponseCodeError as e:
             if e.response.status == 404:
-                self.log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}")
+                log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}")
                 await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`")
                 return
             else:
@@ -347,13 +377,13 @@ async def edit_reason_command(self, ctx: Context, nomination_id: int, actor: Mem
             await ctx.send(f":x: {actor.mention} doesn't have an entry in this nomination.")
             return
 
-        self.log.trace(f"Changing reason for nomination with id {nomination_id} of actor {actor} to {repr(reason)}")
+        log.trace(f"Changing reason for nomination with id {nomination_id} of actor {actor} to {repr(reason)}")
 
         await self.bot.api_client.patch(
-            f"{self.api_endpoint}/{nomination_id}",
+            f"bot/nominations/{nomination_id}",
             json={"actor": actor.id, "reason": reason}
         )
-        await self.fetch_user_cache()  # Update cache
+        await self.refresh_cache()  # Update cache
         await ctx.send(":white_check_mark: Successfully updated nomination reason.")
 
     @nomination_edit_group.command(name='end_reason')
@@ -365,10 +395,10 @@ async def edit_end_reason_command(self, ctx: Context, nomination_id: int, *, rea
             return
 
         try:
-            nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}")
+            nomination = await self.bot.api_client.get(f"bot/nominations/{nomination_id}")
         except ResponseCodeError as e:
             if e.response.status == 404:
-                self.log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}")
+                log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}")
                 await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`")
                 return
             else:
@@ -378,13 +408,13 @@ async def edit_end_reason_command(self, ctx: Context, nomination_id: int, *, rea
             await ctx.send(":x: Can't edit the end reason of an active nomination.")
             return
 
-        self.log.trace(f"Changing end reason for nomination with id {nomination_id} to {repr(reason)}")
+        log.trace(f"Changing end reason for nomination with id {nomination_id} to {repr(reason)}")
 
         await self.bot.api_client.patch(
-            f"{self.api_endpoint}/{nomination_id}",
+            f"bot/nominations/{nomination_id}",
             json={"end_reason": reason}
         )
-        await self.fetch_user_cache()  # Update cache.
+        await self.refresh_cache()  # Update cache.
         await ctx.send(":white_check_mark: Updated the end reason of the nomination!")
 
     @nomination_group.command(aliases=('mr',))
@@ -419,7 +449,7 @@ async def post_review(self, ctx: Context, user_id: int) -> None:
     @Cog.listener()
     async def on_member_ban(self, guild: Guild, user: Union[MemberOrUser]) -> None:
         """Remove `user` from the talent pool after they are banned."""
-        await self.unwatch(user.id, "User was banned.")
+        await self.end_nomination(user.id, "User was banned.")
 
     @Cog.listener()
     async def on_raw_reaction_add(self, payload: RawReactionActionEvent) -> None:
@@ -441,10 +471,10 @@ async def on_raw_reaction_add(self, payload: RawReactionActionEvent) -> None:
             log.info(f"Archiving nomination {message.id}")
             await self.reviewer.archive_vote(message, emoji == Emojis.incident_actioned)
 
-    async def unwatch(self, user_id: int, reason: str) -> bool:
+    async def end_nomination(self, user_id: int, reason: str) -> bool:
         """End the active nomination of a user with the given reason and return True on success."""
         active_nomination = await self.bot.api_client.get(
-            self.api_endpoint,
+            'bot/nominations',
             params=ChainMap(
                 {"user__id": str(user_id)},
                 self.api_default_params,
@@ -459,11 +489,11 @@ async def unwatch(self, user_id: int, reason: str) -> bool:
 
         nomination = active_nomination[0]
         await self.bot.api_client.patch(
-            f"{self.api_endpoint}/{nomination['id']}",
+            f"bot/nominations/{nomination['id']}",
             json={'end_reason': reason, 'active': False}
         )
-        self._remove_user(user_id)
 
+        self.cache.pop(user_id)
         if await self.autoreview_enabled():
             self.reviewer.cancel(user_id)
 
@@ -512,7 +542,7 @@ def _nomination_to_string(self, nomination_object: dict) -> str:
                 {entries_string}
 
                 End date: {end_date}
-                Unwatch reason: {nomination_object["end_reason"]}
+                Unnomination reason: {nomination_object["end_reason"]}
                 ===============
                 """
             )
diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py
--- a/bot/exts/recruitment/talentpool/_review.py
+++ b/bot/exts/recruitment/talentpool/_review.py
@@ -58,9 +58,9 @@ async def reschedule_reviews(self) -> None:
         log.trace("Rescheduling reviews")
         await self.bot.wait_until_guild_available()
         # TODO Once the watch channel is removed, this can be done in a smarter way, e.g create a sync function.
-        await self._pool.fetch_user_cache()
+        await self._pool.refresh_cache()
 
-        for user_id, user_data in self._pool.watched_users.items():
+        for user_id, user_data in self._pool.cache.items():
             if not user_data["reviewed"]:
                 self.schedule_review(user_id)
 
@@ -68,7 +68,7 @@ def schedule_review(self, user_id: int) -> None:
         """Schedules a single user for review."""
         log.trace(f"Scheduling review of user with ID {user_id}")
 
-        user_data = self._pool.watched_users.get(user_id)
+        user_data = self._pool.cache.get(user_id)
         inserted_at = isoparse(user_data['inserted_at']).replace(tzinfo=None)
         review_at = inserted_at + timedelta(days=MAX_DAYS_IN_POOL)
 
@@ -96,18 +96,18 @@ async def post_review(self, user_id: int, update_database: bool) -> None:
                 await last_message.add_reaction(reaction)
 
         if update_database:
-            nomination = self._pool.watched_users.get(user_id)
-            await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{nomination['id']}", json={"reviewed": True})
+            nomination = self._pool.cache.get(user_id)
+            await self.bot.api_client.patch(f"bot/nominations/{nomination['id']}", json={"reviewed": True})
 
     async def make_review(self, user_id: int) -> typing.Tuple[str, Optional[Emoji]]:
         """Format a generic review of a user and return it with the reviewed emoji."""
         log.trace(f"Formatting the review of {user_id}")
 
-        # Since `watched_users` is a defaultdict, we should take care
+        # Since `cache` is a defaultdict, we should take care
         # not to accidentally insert the IDs of users that have no
-        # active nominated by using the `watched_users.get(user_id)`
-        # instead of `watched_users[user_id]`.
-        nomination = self._pool.watched_users.get(user_id)
+        # active nominated by using the `cache.get(user_id)`
+        # instead of `cache[user_id]`.
+        nomination = self._pool.cache.get(user_id)
         if not nomination:
             log.trace(f"There doesn't appear to be an active nomination for {user_id}")
             return "", None
@@ -332,7 +332,7 @@ async def _previous_nominations_review(self, member: Member) -> Optional[str]:
         """
         log.trace(f"Fetching the nomination history data for {member.id}'s review")
         history = await self.bot.api_client.get(
-            self._pool.api_endpoint,
+            "bot/nominations",
             params={
                 "user__id": str(member.id),
                 "active": "false",
@@ -390,18 +390,18 @@ async def mark_reviewed(self, ctx: Context, user_id: int) -> bool:
         Returns True if the user was successfully marked as reviewed, False otherwise.
         """
         log.trace(f"Updating user {user_id} as reviewed")
-        await self._pool.fetch_user_cache()
-        if user_id not in self._pool.watched_users:
+        await self._pool.refresh_cache()
+        if user_id not in self._pool.cache:
             log.trace(f"Can't find a nominated user with id {user_id}")
             await ctx.send(f":x: Can't find a currently nominated user with id `{user_id}`")
             return False
 
-        nomination = self._pool.watched_users.get(user_id)
+        nomination = self._pool.cache.get(user_id)
         if nomination["reviewed"]:
             await ctx.send(":x: This nomination was already reviewed, but here's a cookie :cookie:")
             return False
 
-        await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{nomination['id']}", json={"reviewed": True})
+        await self.bot.api_client.patch(f"bot/nominations/{nomination['id']}", json={"reviewed": True})
         if user_id in self._review_scheduler:
             self._review_scheduler.cancel(user_id)
 


Test patch: