diff options
author | 2022-11-05 16:13:47 +0000 | |
---|---|---|
committer | 2022-11-05 16:13:47 +0000 | |
commit | 8faf181012860bd8c60bd386d843af09f472d3fb (patch) | |
tree | 191a37c8ad9d0295049bfee96f1f008bbd2759e0 | |
parent | Merge pull request #2230 from meatballs/create-nomodule-tag (diff) | |
parent | Merge branch 'main' into 2306-nominations-cleanup (diff) |
Merge pull request #2307 from python-discord/2306-nominations-cleanup
Talentpool Cleanup
-rw-r--r-- | bot/exts/recruitment/talentpool/_api.py | 110 | ||||
-rw-r--r-- | bot/exts/recruitment/talentpool/_cog.py | 269 | ||||
-rw-r--r-- | bot/exts/recruitment/talentpool/_review.py | 114 | ||||
-rw-r--r-- | poetry.lock | 67 | ||||
-rw-r--r-- | pyproject.toml | 1 | ||||
-rw-r--r-- | tests/bot/exts/recruitment/talentpool/test_review.py | 41 |
6 files changed, 323 insertions, 279 deletions
diff --git a/bot/exts/recruitment/talentpool/_api.py b/bot/exts/recruitment/talentpool/_api.py new file mode 100644 index 000000000..2cb15a14d --- /dev/null +++ b/bot/exts/recruitment/talentpool/_api.py @@ -0,0 +1,110 @@ +from datetime import datetime + +from botcore.site_api import APIClient +from pydantic import BaseModel, Field, parse_obj_as + + +class NominationEntry(BaseModel): + """Pydantic model representing a nomination entry.""" + + actor_id: int = Field(alias="actor") + reason: str + inserted_at: datetime + + +class Nomination(BaseModel): + """Pydantic model representing a nomination.""" + + id: int + active: bool + user_id: int = Field(alias="user") + inserted_at: datetime + end_reason: str + ended_at: datetime | None + entries: list[NominationEntry] + reviewed: bool + + +class NominationAPI: + """Abstraction of site API interaction for talentpool.""" + + def __init__(self, site_api: APIClient): + self.site_api = site_api + + async def get_nominations( + self, + user_id: int | None = None, + active: bool | None = None, + ordering: str = "-inserted_at" + ) -> list[Nomination]: + """ + Fetch a list of nominations. + + Passing a value of `None` indicates it shouldn't filtered by. + """ + params = {"ordering": ordering} + if active is not None: + params["active"] = str(active) + if user_id is not None: + params["user__id"] = str(user_id) + + data = await self.site_api.get("bot/nominations", params=params) + nominations = parse_obj_as(list[Nomination], data) + return nominations + + async def get_nomination(self, nomination_id: int) -> Nomination: + """Fetch a nomination by ID.""" + data = await self.site_api.get(f"bot/nominations/{nomination_id}") + nomination = Nomination.parse_obj(data) + return nomination + + async def edit_nomination( + self, + nomination_id: int, + *, + end_reason: str | None = None, + active: bool | None = None, + reviewed: bool | None = None, + ) -> Nomination: + """ + Edit a nomination. + + Passing a value of `None` indicates it shouldn't be updated. + """ + data = {} + if end_reason is not None: + data["end_reason"] = end_reason + if active is not None: + data["active"] = active + if reviewed is not None: + data["reviewed"] = reviewed + + result = await self.site_api.patch(f"bot/nominations/{nomination_id}", json=data) + return Nomination.parse_obj(result) + + async def edit_nomination_entry( + self, + nomination_id: int, + *, + actor_id: int, + reason: str, + ) -> Nomination: + """Edit a nomination entry.""" + data = {"actor": actor_id, "reason": reason} + result = await self.site_api.patch(f"bot/nominations/{nomination_id}", json=data) + return Nomination.parse_obj(result) + + async def post_nomination( + self, + user_id: int, + actor_id: int, + reason: str, + ) -> Nomination: + """Post a nomination to site.""" + data = { + "actor": actor_id, + "reason": reason, + "user": user_id, + } + result = await self.site_api.post("bot/nominations", json=data) + return Nomination.parse_obj(result) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index f69e5a647..94737fc6c 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -1,6 +1,5 @@ import asyncio import textwrap -from collections import ChainMap, defaultdict from io import StringIO from typing import Optional, Union @@ -20,6 +19,8 @@ from bot.pagination import LinePaginator from bot.utils import time from bot.utils.members import get_or_fetch_member +from ._api import Nomination, NominationAPI + AUTOREVIEW_ENABLED_KEY = "autoreview_enabled" REASON_MAX_CHARS = 1000 @@ -35,17 +36,13 @@ class TalentPool(Cog, name="Talentpool"): def __init__(self, bot: Bot) -> None: self.bot = bot - self.reviewer = Reviewer(bot, self) - self.cache: Optional[defaultdict[dict]] = None - self.api_default_params = {'active': 'true', 'ordering': '-inserted_at'} - + self.api = NominationAPI(bot.api_client) + self.reviewer = Reviewer(bot, self.api) # This lock lets us avoid cancelling the reviewer loop while the review code is running. self.autoreview_lock = asyncio.Lock() async def cog_load(self) -> None: - """Load user cache and maybe start autoreview loop.""" - await self.refresh_cache() - + """Start autoreview loop if enabled.""" if await self.autoreview_enabled(): self.autoreview_loop.start() @@ -53,27 +50,6 @@ class TalentPool(Cog, name="Talentpool"): """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.""" - # Wait until logged in to ensure bot api client exists - await self.bot.wait_until_guild_available() - 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(*STAFF_ROLES) async def nomination_group(self, ctx: Context) -> None: @@ -155,23 +131,18 @@ class TalentPool(Cog, name="Talentpool"): self, ctx: Context, oldest_first: bool = False, - update_cache: bool = True ) -> None: """ 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_nominated_users(ctx, oldest_first=oldest_first, update_cache=update_cache) + await self.list_nominated_users(ctx, oldest_first=oldest_first) async def list_nominated_users( self, ctx: Context, oldest_first: bool = False, - update_cache: bool = True ) -> None: """ Gives an overview of the nominated users list. @@ -180,31 +151,22 @@ class TalentPool(Cog, name="Talentpool"): review was posted. The optional kwarg `oldest_first` orders the list by oldest entry. - - The optional kwarg `update_cache` specifies whether the cache should - be refreshed by polling the API. """ - 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.") - - nominations = self.cache.items() + nominations = await self.api.get_nominations(active=True) if oldest_first: nominations = reversed(nominations) lines = [] - for user_id, user_data in nominations: - member = await get_or_fetch_member(ctx.guild, user_id) - line = f"• `{user_id}`" + for nomination in nominations: + member = await get_or_fetch_member(ctx.guild, nomination.user_id) + line = f"• `{nomination.user_id}`" if member: line += f" ({member.name}#{member.discriminator})" - inserted_at = user_data['inserted_at'] - line += f", added {time.format_relative(inserted_at)}" + line += f", added {time.format_relative(nomination.inserted_at)}" if not member: # Cross off users who left the server. line = f"~~{line}~~" - if user_data['reviewed']: + if nomination.reviewed: line += " *(reviewed)*" lines.append(line) @@ -212,21 +174,16 @@ class TalentPool(Cog, name="Talentpool"): lines = ("There's nothing here yet.",) embed = Embed( - title=f"Talent Pool active nominations ({'updated' if update_cache and successful_update else 'cached'})", + 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, update_cache: bool = True) -> None: - """ - 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.list_command, oldest_first=True, update_cache=update_cache) + 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", @@ -273,39 +230,21 @@ class TalentPool(Cog, name="Talentpool"): await ctx.send(":x: Nominating staff members, eh? Here's a cookie :cookie:") return - if not await self.refresh_cache(): - await ctx.send(f":x: Failed to update the cache; can't add {user.mention}.") - return - if len(reason) > REASON_MAX_CHARS: await ctx.send(f":x: The reason's length must not exceed {REASON_MAX_CHARS} characters.") return - # 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('bot/nominations') - kwargs = { - 'json': { - 'actor': ctx.author.id, - 'reason': reason, - 'user': user.id - }, - 'raise_for_status': False, - } - async with session.post(url, **kwargs) as resp: - response_data = await resp.json() - - if resp.status == 400: - if response_data.get('user', False): + try: + await self.api.post_nomination(user.id, ctx.author.id, reason) + except ResponseCodeError as e: + match (e.status, e.response_json): + case (400, {"user": _}): await ctx.send(f":x: {user.mention} can't be found in the database tables.") - elif response_data.get('actor', False): + return + case (400, {"actor": _}): await ctx.send(f":x: You have already nominated {user.mention}.") - - return - else: - resp.raise_for_status() - - self.cache[user.id] = response_data + return + raise await ctx.send(f"✅ The nomination for {user.mention} has been added to the talent pool.") @@ -313,13 +252,8 @@ class TalentPool(Cog, name="Talentpool"): @has_any_role(*MODERATION_ROLES) async def history_command(self, ctx: Context, user: MemberOrUser) -> None: """Shows the specified user's nomination history.""" - result = await self.bot.api_client.get( - 'bot/nominations', - params={ - 'user__id': str(user.id), - 'ordering': "-active,-inserted_at" - } - ) + result = await self.api.get_nominations(user.id, ordering="-active,-inserted_at") + if not result: await ctx.send(f":warning: {user.mention} has never been nominated.") return @@ -417,41 +351,32 @@ class TalentPool(Cog, name="Talentpool"): if len(reason) > REASON_MAX_CHARS: await ctx.send(f":x: The reason's length must not exceed {REASON_MAX_CHARS} characters.") return + if isinstance(target, int): nomination_id = target else: - if nomination := self.cache.get(target.id): - nomination_id = nomination["id"] + active_nominations = await self.api.get_nominations(user_id=target.id, active=True) + if active_nominations: + nomination_id = active_nominations[0].id else: await ctx.send(f":x: {target.mention} doesn't have an active nomination.") return + log.trace(f"Changing reason for nomination with id {nomination_id} of actor {actor} to {repr(reason)}") + try: - nomination = await self.bot.api_client.get(f"bot/nominations/{nomination_id}") + nomination = await self.api.edit_nomination_entry(nomination_id, actor_id=actor.id, reason=reason) except ResponseCodeError as e: - if e.response.status == 404: - 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: - raise - - if not nomination["active"]: - await ctx.send(f":x: <@{nomination['user']}> doesn't have an active nomination.") - return - - if not any(entry["actor"] == actor.id for entry in nomination["entries"]): - await ctx.send(f":x: {actor.mention} doesn't have an entry in this nomination.") - return - - log.trace(f"Changing reason for nomination with id {nomination_id} of actor {actor} to {repr(reason)}") + match (e.status, e.response_json): + case (400, {"actor": _}): + await ctx.send(f":x: {actor.mention} doesn't have an entry in this nomination.") + return + case (404, _): + await ctx.send(f":x: Can't find a nomination with id `{target}`.") + return + raise - await self.bot.api_client.patch( - f"bot/nominations/{nomination_id}", - json={"actor": actor.id, "reason": reason} - ) - await self.refresh_cache() # Update cache - await ctx.send(f":white_check_mark: Updated the nomination reason for <@{nomination['user']}>.") + await ctx.send(f":white_check_mark: Updated the nomination reason for <@{nomination.user_id}>.") @nomination_edit_group.command(name='end_reason') @has_any_role(*MODERATION_ROLES) @@ -461,44 +386,31 @@ class TalentPool(Cog, name="Talentpool"): await ctx.send(f":x: The reason's length must not exceed {REASON_MAX_CHARS} characters.") return + log.trace(f"Changing end reason for nomination with id {nomination_id} to {repr(reason)}") try: - nomination = await self.bot.api_client.get(f"bot/nominations/{nomination_id}") + nomination = await self.api.edit_nomination(nomination_id, end_reason=reason) except ResponseCodeError as e: - if e.response.status == 404: - 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: - raise - - if nomination["active"]: - await ctx.send( - f":x: Can't edit the nomination end reason for <@{nomination['user']}> because it's still active." - ) - return - - log.trace(f"Changing end reason for nomination with id {nomination_id} to {repr(reason)}") - - await self.bot.api_client.patch( - f"bot/nominations/{nomination_id}", - json={"end_reason": reason} - ) - await self.refresh_cache() # Update cache. - await ctx.send(f":white_check_mark: Updated the nomination end reason for <@{nomination['user']}>.") + match (e.status, e.response_json): + case (400, {"end_reason": _}): + await ctx.send(f":x: Can't edit nomination with id `{nomination_id}` because it's still active.") + return + case (404, _): + await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`.") + return + raise - @nomination_group.command(aliases=('mr',)) - @has_any_role(*MODERATION_ROLES) - async def mark_reviewed(self, ctx: Context, user_id: int) -> None: - """Mark a user's nomination as reviewed and cancel the review task.""" - if not await self.reviewer.mark_reviewed(ctx, user_id): - return - await ctx.send(f"{Emojis.check_mark} The user with ID `{user_id}` was marked as reviewed.") + await ctx.send(f":white_check_mark: Updated the nomination end reason for <@{nomination.user_id}>.") @nomination_group.command(aliases=('gr',)) @has_any_role(*MODERATION_ROLES) async def get_review(self, ctx: Context, user_id: int) -> None: """Get the user's review as a markdown file.""" - review, _, _ = await self.reviewer.make_review(user_id) + nominations = await self.api.get_nominations(user_id, active=True) + if not nominations: + await ctx.send(f":x: There doesn't appear to be an active nomination for {user_id}") + return + + review, _, _ = await self.reviewer.make_review(nominations[0]) file = discord.File(StringIO(review), f"{user_id}_review.md") await ctx.send(file=file) @@ -506,10 +418,17 @@ class TalentPool(Cog, name="Talentpool"): @has_any_role(*MODERATION_ROLES) async def post_review(self, ctx: Context, user_id: int) -> None: """Post the automatic review for the user ahead of time.""" - if not await self.reviewer.mark_reviewed(ctx, user_id): + nominations = await self.api.get_nominations(user_id, active=True) + if not nominations: + await ctx.send(f":x: There doesn't appear to be an active nomination for {user_id}") return - await self.reviewer.post_review(user_id, update_database=False) + nomination = nominations[0] + if nomination.reviewed: + await ctx.send(":x: This nomination was already reviewed, but here's a cookie :cookie:") + return + + await self.reviewer.post_review(nomination) await ctx.message.add_reaction(Emojis.check_mark) @Cog.listener() @@ -542,73 +461,59 @@ class TalentPool(Cog, name="Talentpool"): 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( - 'bot/nominations', - params=ChainMap( - {"user__id": str(user_id)}, - self.api_default_params, - ) - ) + active_nominations = await self.api.get_nominations(user_id, active=True) - if not active_nomination: - log.debug(f"No active nominate exists for {user_id=}") + if not active_nominations: + log.debug(f"No active nomination exists for {user_id=}") return False log.info(f"Ending nomination: {user_id=} {reason=}") - nomination = active_nomination[0] - await self.bot.api_client.patch( - f"bot/nominations/{nomination['id']}", - json={'end_reason': reason, 'active': False} - ) - - self.cache.pop(user_id) + nomination = active_nominations[0] + await self.api.edit_nomination(nomination.id, end_reason=reason, active=False) return True - async def _nomination_to_string(self, nomination_object: dict) -> str: + async def _nomination_to_string(self, nomination: Nomination) -> str: """Creates a string representation of a nomination.""" guild = self.bot.get_guild(Guild.id) entries = [] - for site_entry in nomination_object["entries"]: - actor_id = site_entry["actor"] - actor = await get_or_fetch_member(guild, actor_id) + for entry in nomination.entries: + actor = await get_or_fetch_member(guild, entry.actor_id) - reason = site_entry["reason"] or "*None*" - created = time.discord_timestamp(site_entry["inserted_at"]) + reason = entry.reason or "*None*" + created = time.discord_timestamp(entry.inserted_at) entries.append( - f"Actor: {actor.mention if actor else actor_id}\nCreated: {created}\nReason: {reason}" + f"Actor: {actor.mention if actor else entry.actor_id}\nCreated: {created}\nReason: {reason}" ) entries_string = "\n\n".join(entries) - active = nomination_object["active"] - - start_date = time.discord_timestamp(nomination_object["inserted_at"]) - if active: + start_date = time.discord_timestamp(nomination.inserted_at) + if nomination.active: lines = textwrap.dedent( f""" =============== Status: **Active** Date: {start_date} - Nomination ID: `{nomination_object["id"]}` + Nomination ID: `{nomination.id}` {entries_string} =============== """ ) else: - end_date = time.discord_timestamp(nomination_object["ended_at"]) + end_date = time.discord_timestamp(nomination.ended_at) lines = textwrap.dedent( f""" =============== Status: Inactive Date: {start_date} - Nomination ID: `{nomination_object["id"]}` + Nomination ID: `{nomination.id}` {entries_string} End date: {end_date} - Unnomination reason: {nomination_object["end_reason"]} + Unnomination reason: {nomination.end_reason} =============== """ ) @@ -619,4 +524,4 @@ class TalentPool(Cog, name="Talentpool"): """Cancels the autoreview loop on cog unload.""" # Only cancel the loop task when the autoreview code is not running async with self.autoreview_lock: - self.autoreview_loop_lock.cancel() + self.autoreview_loop.cancel() diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index e3ac1086d..a74e1ce2b 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -9,19 +9,17 @@ from datetime import datetime, timedelta, timezone from typing import List, Optional, Union from botcore.site_api import ResponseCodeError -from dateutil.parser import isoparse from discord import Embed, Emoji, Member, Message, NotFound, PartialMessage, TextChannel -from discord.ext.commands import Context from bot.bot import Bot from bot.constants import Channels, Colours, Emojis, Guild, Roles +from bot.exts.recruitment.talentpool._api import Nomination, NominationAPI from bot.log import get_logger from bot.utils import time from bot.utils.members import get_or_fetch_member from bot.utils.messages import count_unique_users_reaction, pin_no_system_message if typing.TYPE_CHECKING: - from bot.exts.recruitment.talentpool._cog import TalentPool from bot.exts.utils.thread_bumper import ThreadBumper log = get_logger(__name__) @@ -52,9 +50,9 @@ NOMINATION_MESSAGE_REGEX = re.compile( class Reviewer: """Manages, formats, and publishes reviews of helper nominees.""" - def __init__(self, bot: Bot, pool: 'TalentPool'): + def __init__(self, bot: Bot, nomination_api: NominationAPI): self.bot = bot - self._pool = pool + self.api = nomination_api async def maybe_review_user(self) -> bool: """ @@ -65,11 +63,11 @@ class Reviewer: if not await self.is_ready_for_review(): return False - user = await self.get_user_for_review() - if not user: + nomination = await self.get_nomination_to_review() + if not nomination: return False - await self.post_review(user, True) + await self.post_review(nomination) return True async def is_ready_for_review(self) -> bool: @@ -107,9 +105,9 @@ class Reviewer: return True - async def get_user_for_review(self) -> Optional[int]: + async def get_nomination_to_review(self) -> Optional[Nomination]: """ - Returns the user ID of the next user to review, or None if there are no users ready. + Returns the Nomination of the next user to review, or None if there are no users ready. Users will only be selected for review if: - They have not already been reviewed. @@ -121,45 +119,46 @@ class Reviewer: """ now = datetime.now(timezone.utc) - possible = [] - for user_id, user_data in self._pool.cache.items(): - time_since_nomination = now - isoparse(user_data["inserted_at"]) + 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 user_data["reviewed"] + not nomination.reviewed and time_since_nomination > MIN_NOMINATION_TIME ): - possible.append((user_id, user_data)) + possible_nominations.append(nomination) - if not possible: + if not possible_nominations: log.debug("No users ready to review.") return None - oldest_date = min(isoparse(x[1]["inserted_at"]) for x in possible) - max_entries = max(len(x[1]["entries"]) for x in possible) + oldest_date = min(nomination.inserted_at for nomination in possible_nominations) + max_entries = max(len(nomination.entries) for nomination in possible_nominations) - def sort_key(nomination: dict) -> float: - return self.score_nomination(nomination[1], oldest_date, now, max_entries) + def sort_key(nomination: Nomination) -> float: + return self.score_nomination(nomination, oldest_date, now, max_entries) - return max(possible, key=sort_key)[0] + return max(possible_nominations, key=sort_key) @staticmethod - def score_nomination(nomination: dict, oldest_date: datetime, now: datetime, max_entries: int) -> float: + def score_nomination(nomination: Nomination, oldest_date: datetime, now: datetime, max_entries: int) -> float: """ Scores a nomination based on age and number of nomination entries. The higher the score, the higher the priority for being put up for review should be. """ - num_entries = len(nomination["entries"]) + num_entries = len(nomination.entries) entries_score = num_entries / max_entries - nomination_date = isoparse(nomination["inserted_at"]) + nomination_date = nomination.inserted_at age_score = (nomination_date - now) / (oldest_date - now) return entries_score * REVIEW_SCORE_WEIGHT + age_score - async def post_review(self, user_id: int, update_database: bool) -> None: + async def post_review(self, nomination: Nomination) -> None: """Format the review of a user and post it to the nomination voting channel.""" - review, reviewed_emoji, nominee = await self.make_review(user_id) + review, reviewed_emoji, nominee = await self.make_review(nomination) if not nominee: return @@ -181,41 +180,31 @@ class Reviewer: ) message = await thread.send(f"<@&{Roles.mod_team}> <@&{Roles.admins}>") - if update_database: - nomination = self._pool.cache.get(user_id) - await self.bot.api_client.patch(f"bot/nominations/{nomination['id']}", json={"reviewed": True}) + await self.api.edit_nomination(nomination.id, reviewed=True) bump_cog: ThreadBumper = self.bot.get_cog("ThreadBumper") if bump_cog: context = await self.bot.get_context(message) await bump_cog.add_thread_to_bump_list(context, thread) - async def make_review(self, user_id: int) -> typing.Tuple[str, Optional[Emoji], Optional[Member]]: + async def make_review(self, nomination: Nomination) -> typing.Tuple[str, Optional[Emoji], Optional[Member]]: """Format a generic review of a user and return it with the reviewed emoji and the user themselves.""" - log.trace(f"Formatting the review of {user_id}") - - # 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 `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 f"There doesn't appear to be an active nomination for {user_id}", None, None + log.trace(f"Formatting the review of {nomination.user_id}") guild = self.bot.get_guild(Guild.id) - nominee = await get_or_fetch_member(guild, user_id) + nominee = await get_or_fetch_member(guild, nomination.user_id) if not nominee: return ( - f"I tried to review the user with ID `{user_id}`, but they don't appear to be on the server :pensive:" + f"I tried to review the user with ID `{nomination.user_id}`," + " but they don't appear to be on the server :pensive:" ), None, None opening = f"{nominee.mention} ({nominee}) for Helper!" current_nominations = "\n\n".join( - f"**<@{entry['actor']}>:** {entry['reason'] or '*no reason given*'}" - for entry in nomination['entries'][::-1] + f"**<@{entry.actor_id}>:** {entry.reason or '*no reason given*'}" + for entry in nomination.entries[::-1] ) current_nominations = f"**Nominated by:**\n{current_nominations}" @@ -434,29 +423,22 @@ class Reviewer: The number of previous nominations and unnominations are shown, as well as the reason the last one ended. """ log.trace(f"Fetching the nomination history data for {member.id}'s review") - history = await self.bot.api_client.get( - "bot/nominations", - params={ - "user__id": str(member.id), - "active": "false", - "ordering": "-inserted_at" - } - ) + history = await self.api.get_nominations(user_id=member.id, active=False) log.trace(f"{len(history)} previous nominations found for {member.id}, formatting review.") if not history: return - num_entries = sum(len(nomination["entries"]) for nomination in history) + num_entries = sum(len(nomination.entries) for nomination in history) nomination_times = f"{num_entries} times" if num_entries > 1 else "once" rejection_times = f"{len(history)} times" if len(history) > 1 else "once" - end_time = time.format_relative(history[0]['ended_at']) + end_time = time.format_relative(history[0].ended_at) review = ( f"They were nominated **{nomination_times}** before" f", but their nomination was called off **{rejection_times}**." - f"\nThe last one ended {end_time} with the reason: {history[0]['end_reason']}" + f"\nThe last one ended {end_time} with the reason: {history[0].end_reason}" ) return review @@ -485,25 +467,3 @@ class Reviewer: results.append(await channel.send(message)) return results - - async def mark_reviewed(self, ctx: Context, user_id: int) -> bool: - """ - Mark an active nomination as reviewed, updating the database and canceling the review task. - - Returns True if the user was successfully marked as reviewed, False otherwise. - """ - log.trace(f"Updating user {user_id} as reviewed") - 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.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"bot/nominations/{nomination['id']}", json={"reviewed": True}) - - return True diff --git a/poetry.lock b/poetry.lock index 9d9431370..5df497649 100644 --- a/poetry.lock +++ b/poetry.lock @@ -695,6 +695,21 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] +name = "pydantic" +version = "1.10.2" +description = "Data validation and settings management using python type hints" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +typing-extensions = ">=4.1.0" + +[package.extras] +dotenv = ["python-dotenv (>=0.10.4)"] +email = ["email-validator (>=1.0.3)"] + +[[package]] name = "pydocstyle" version = "6.1.1" description = "Python docstring style checker" @@ -1060,6 +1075,14 @@ optional = false python-versions = ">=3.7" [[package]] +name = "typing-extensions" +version = "4.4.0" +description = "Backported and Experimental Type Hints for Python 3.7+" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] name = "urllib3" version = "1.26.12" description = "HTTP library with thread-safe connection pooling, file post, and more." @@ -1112,7 +1135,7 @@ multidict = ">=4.0" [metadata] lock-version = "1.1" python-versions = "3.10.*" -content-hash = "e2377eb90e01d4f055532baa60c6157ed01eba4bce94330c8616419792c5c69e" +content-hash = "f7c3aa7385e92837d5f8401f9903375a20881ad2d21aeeda56c9e374e3c46918" [metadata.files] aiodns = [ @@ -1867,6 +1890,44 @@ pycparser = [ {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, ] +pydantic = [ + {file = "pydantic-1.10.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bb6ad4489af1bac6955d38ebcb95079a836af31e4c4f74aba1ca05bb9f6027bd"}, + {file = "pydantic-1.10.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a1f5a63a6dfe19d719b1b6e6106561869d2efaca6167f84f5ab9347887d78b98"}, + {file = "pydantic-1.10.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:352aedb1d71b8b0736c6d56ad2bd34c6982720644b0624462059ab29bd6e5912"}, + {file = "pydantic-1.10.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19b3b9ccf97af2b7519c42032441a891a5e05c68368f40865a90eb88833c2559"}, + {file = "pydantic-1.10.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e9069e1b01525a96e6ff49e25876d90d5a563bc31c658289a8772ae186552236"}, + {file = "pydantic-1.10.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:355639d9afc76bcb9b0c3000ddcd08472ae75318a6eb67a15866b87e2efa168c"}, + {file = "pydantic-1.10.2-cp310-cp310-win_amd64.whl", hash = "sha256:ae544c47bec47a86bc7d350f965d8b15540e27e5aa4f55170ac6a75e5f73b644"}, + {file = "pydantic-1.10.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4c805731c33a8db4b6ace45ce440c4ef5336e712508b4d9e1aafa617dc9907f"}, + {file = "pydantic-1.10.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d49f3db871575e0426b12e2f32fdb25e579dea16486a26e5a0474af87cb1ab0a"}, + {file = "pydantic-1.10.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37c90345ec7dd2f1bcef82ce49b6235b40f282b94d3eec47e801baf864d15525"}, + {file = "pydantic-1.10.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b5ba54d026c2bd2cb769d3468885f23f43710f651688e91f5fb1edcf0ee9283"}, + {file = "pydantic-1.10.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:05e00dbebbe810b33c7a7362f231893183bcc4251f3f2ff991c31d5c08240c42"}, + {file = "pydantic-1.10.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:2d0567e60eb01bccda3a4df01df677adf6b437958d35c12a3ac3e0f078b0ee52"}, + {file = "pydantic-1.10.2-cp311-cp311-win_amd64.whl", hash = "sha256:c6f981882aea41e021f72779ce2a4e87267458cc4d39ea990729e21ef18f0f8c"}, + {file = "pydantic-1.10.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c4aac8e7103bf598373208f6299fa9a5cfd1fc571f2d40bf1dd1955a63d6eeb5"}, + {file = "pydantic-1.10.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81a7b66c3f499108b448f3f004801fcd7d7165fb4200acb03f1c2402da73ce4c"}, + {file = "pydantic-1.10.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bedf309630209e78582ffacda64a21f96f3ed2e51fbf3962d4d488e503420254"}, + {file = "pydantic-1.10.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:9300fcbebf85f6339a02c6994b2eb3ff1b9c8c14f502058b5bf349d42447dcf5"}, + {file = "pydantic-1.10.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:216f3bcbf19c726b1cc22b099dd409aa371f55c08800bcea4c44c8f74b73478d"}, + {file = "pydantic-1.10.2-cp37-cp37m-win_amd64.whl", hash = "sha256:dd3f9a40c16daf323cf913593083698caee97df2804aa36c4b3175d5ac1b92a2"}, + {file = "pydantic-1.10.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b97890e56a694486f772d36efd2ba31612739bc6f3caeee50e9e7e3ebd2fdd13"}, + {file = "pydantic-1.10.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9cabf4a7f05a776e7793e72793cd92cc865ea0e83a819f9ae4ecccb1b8aa6116"}, + {file = "pydantic-1.10.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06094d18dd5e6f2bbf93efa54991c3240964bb663b87729ac340eb5014310624"}, + {file = "pydantic-1.10.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc78cc83110d2f275ec1970e7a831f4e371ee92405332ebfe9860a715f8336e1"}, + {file = "pydantic-1.10.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ee433e274268a4b0c8fde7ad9d58ecba12b069a033ecc4645bb6303c062d2e9"}, + {file = "pydantic-1.10.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7c2abc4393dea97a4ccbb4ec7d8658d4e22c4765b7b9b9445588f16c71ad9965"}, + {file = "pydantic-1.10.2-cp38-cp38-win_amd64.whl", hash = "sha256:0b959f4d8211fc964772b595ebb25f7652da3f22322c007b6fed26846a40685e"}, + {file = "pydantic-1.10.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c33602f93bfb67779f9c507e4d69451664524389546bacfe1bee13cae6dc7488"}, + {file = "pydantic-1.10.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5760e164b807a48a8f25f8aa1a6d857e6ce62e7ec83ea5d5c5a802eac81bad41"}, + {file = "pydantic-1.10.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6eb843dcc411b6a2237a694f5e1d649fc66c6064d02b204a7e9d194dff81eb4b"}, + {file = "pydantic-1.10.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b8795290deaae348c4eba0cebb196e1c6b98bdbe7f50b2d0d9a4a99716342fe"}, + {file = "pydantic-1.10.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:e0bedafe4bc165ad0a56ac0bd7695df25c50f76961da29c050712596cf092d6d"}, + {file = "pydantic-1.10.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2e05aed07fa02231dbf03d0adb1be1d79cabb09025dd45aa094aa8b4e7b9dcda"}, + {file = "pydantic-1.10.2-cp39-cp39-win_amd64.whl", hash = "sha256:c1ba1afb396148bbc70e9eaa8c06c1716fdddabaf86e7027c5988bae2a829ab6"}, + {file = "pydantic-1.10.2-py3-none-any.whl", hash = "sha256:1b6ee725bd6e83ec78b1aa32c5b1fa67a3a65badddde3976bca5fe4568f27709"}, + {file = "pydantic-1.10.2.tar.gz", hash = "sha256:91b8e218852ef6007c2b98cd861601c6a09f1aa32bbbb74fab5b1c33d4a1e410"}, +] pydocstyle = [ {file = "pydocstyle-6.1.1-py3-none-any.whl", hash = "sha256:6987826d6775056839940041beef5c08cc7e3d71d63149b48e36727f70144dc4"}, {file = "pydocstyle-6.1.1.tar.gz", hash = "sha256:1d41b7c459ba0ee6c345f2eb9ae827cab14a7533a88c5c6f7e94923f72df92dc"}, @@ -2210,6 +2271,10 @@ tomli = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] +typing-extensions = [ + {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, + {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, +] urllib3 = [ {file = "urllib3-1.26.12-py2.py3-none-any.whl", hash = "sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997"}, {file = "urllib3-1.26.12.tar.gz", hash = "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e"}, diff --git a/pyproject.toml b/pyproject.toml index b5050ef63..2019d847a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ regex = "2022.9.13" sentry-sdk = "1.9.10" statsd = "3.3.0" tldextract = "3.4.0" +pydantic = "1.10.2" [tool.poetry.dev-dependencies] coverage = "6.5.0" diff --git a/tests/bot/exts/recruitment/talentpool/test_review.py b/tests/bot/exts/recruitment/talentpool/test_review.py index ed9b66e12..03c78ab08 100644 --- a/tests/bot/exts/recruitment/talentpool/test_review.py +++ b/tests/bot/exts/recruitment/talentpool/test_review.py @@ -1,6 +1,6 @@ import unittest from datetime import datetime, timedelta, timezone -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch from bot.exts.recruitment.talentpool import _review from tests.helpers import MockBot, MockMember, MockMessage, MockTextChannel @@ -31,10 +31,12 @@ def nomination( num_entries: int, reviewed: bool = False, id: int | None = None -) -> tuple[int, dict]: - return ( - id or MockMember().id, - {"inserted_at": inserted_at.isoformat(), "entries": [Mock() for _ in range(num_entries)], "reviewed": reviewed}, +) -> Mock: + return Mock( + id=id or MockMember().id, + inserted_at=inserted_at, + entries=[Mock() for _ in range(num_entries)], + reviewed=reviewed ) @@ -48,8 +50,8 @@ class ReviewerTests(unittest.IsolatedAsyncioTestCase): self.voting_channel = MockTextChannel() self.bot.get_channel = Mock(return_value=self.voting_channel) - self.pool = Mock(name="MockTalentPool") - self.reviewer = _review.Reviewer(self.bot, self.pool) + self.nomination_api = Mock(name="MockNominationAPI") + self.reviewer = _review.Reviewer(self.bot, self.nomination_api) @patch("bot.exts.recruitment.talentpool._review.MAX_ONGOING_REVIEWS", 3) @patch("bot.exts.recruitment.talentpool._review.MIN_REVIEW_INTERVAL", timedelta(days=1)) @@ -108,8 +110,8 @@ class ReviewerTests(unittest.IsolatedAsyncioTestCase): self.assertIs(res, expected) @patch("bot.exts.recruitment.talentpool._review.MIN_NOMINATION_TIME", timedelta(days=7)) - async def test_get_user_for_review(self): - """Test get_user_for_review function.""" + async def test_get_nomination_to_review(self): + """Test get_nomination_to_review function.""" now = datetime.now(timezone.utc) # Each case contains a list of nominations, followed by the index in that list @@ -146,19 +148,19 @@ class ReviewerTests(unittest.IsolatedAsyncioTestCase): ] for (case_num, (nominations, expected)) in enumerate(cases, 1): - nomination_dict = dict(nominations) - with self.subTest(case_num=case_num): - self.pool.cache = nomination_dict - res = await self.reviewer.get_user_for_review() + get_nominations_mock = AsyncMock(return_value=nominations) + self.nomination_api.get_nominations = get_nominations_mock + res = await self.reviewer.get_nomination_to_review() if expected is None: self.assertIsNone(res) else: - self.assertEqual(res, nominations[expected][0]) + self.assertEqual(res, nominations[expected]) + get_nominations_mock.assert_called_once_with(active=True) @patch("bot.exts.recruitment.talentpool._review.MIN_NOMINATION_TIME", timedelta(days=0)) - async def test_get_user_for_review_order(self): + async def test_get_nomination_to_review_order(self): now = datetime.now(timezone.utc) # Each case in cases is a list of nominations in the order they should be chosen from first to last @@ -196,8 +198,9 @@ class ReviewerTests(unittest.IsolatedAsyncioTestCase): with self.subTest(case_num=case_num): for i in range(len(case)): with self.subTest(nomination_num=i+1): - sub_case = dict(case[i:]) - self.pool.cache = sub_case + get_nominations_mock = AsyncMock(return_value=case[i:]) + self.nomination_api.get_nominations = get_nominations_mock - res = await self.reviewer.get_user_for_review() - self.assertEqual(res, case[i][0]) + res = await self.reviewer.get_nomination_to_review() + self.assertEqual(res, case[i]) + get_nominations_mock.assert_called_once_with(active=True) |