diff options
| -rw-r--r-- | bot/exts/backend/security.py (renamed from bot/exts/filters/security.py) | 0 | ||||
| -rw-r--r-- | bot/exts/info/information.py | 33 | ||||
| -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-- | bot/exts/utils/snekbox.py | 15 | ||||
| -rw-r--r-- | bot/resources/tags/nomodule.md | 13 | ||||
| -rw-r--r-- | bot/resources/tags/slicing.md | 24 | ||||
| -rw-r--r-- | docker-compose.yml | 2 | ||||
| -rw-r--r-- | poetry.lock | 67 | ||||
| -rw-r--r-- | pyproject.toml | 1 | ||||
| -rw-r--r-- | tests/bot/exts/backend/test_security.py (renamed from tests/bot/exts/filters/test_security.py) | 2 | ||||
| -rw-r--r-- | tests/bot/exts/info/test_information.py | 24 | ||||
| -rw-r--r-- | tests/bot/exts/recruitment/talentpool/test_review.py | 39 | 
14 files changed, 408 insertions, 305 deletions
| diff --git a/bot/exts/filters/security.py b/bot/exts/backend/security.py index 27e4d9752..27e4d9752 100644 --- a/bot/exts/filters/security.py +++ b/bot/exts/backend/security.py diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 2592e093d..733597dd8 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -523,8 +523,19 @@ class Information(Cog):          """Shows information about the raw API response in a copy-pasteable Python format."""          await self.send_raw_content(ctx, message, json=True) +    async def _set_rules_command_help(self) -> None: +        help_string = f"{self.rules.help}\n\n" +        help_string += "__Available keywords per rule__:\n\n" + +        full_rules = await self.bot.api_client.get("rules", params={"link_format": "md"}) + +        for index, (_, keywords) in enumerate(full_rules, start=1): +            help_string += f"**Rule {index}**: {', '.join(keywords)}\n\r" + +        self.rules.help = help_string +      @command(aliases=("rule",)) -    async def rules(self, ctx: Context, *args: Optional[str]) -> Optional[Set[int]]: +    async def rules(self, ctx: Context, *, args: Optional[str]) -> Optional[Set[int]]:          """          Provides a link to all rules or, if specified, displays specific rule(s). @@ -541,13 +552,15 @@ class Information(Cog):              for rule_keyword in rule_keywords:                  keyword_to_rule_number[rule_keyword] = rule_number -        for word in args: -            try: -                rule_numbers.append(int(word)) -            except ValueError: -                if (kw := word.lower()) not in keyword_to_rule_number: -                    break -                keywords.append(kw) +        if args: +            for word in args.split(maxsplit=100): +                try: +                    rule_numbers.append(int(word)) +                except ValueError: +                    # Stop on first invalid keyword/index to allow for normal messaging after +                    if (kw := word.lower()) not in keyword_to_rule_number: +                        break +                    keywords.append(kw)          if not rule_numbers and not keywords:              # Neither rules nor keywords were submitted. Return the default description. @@ -578,6 +591,10 @@ class Information(Cog):          return final_rule_numbers +    async def cog_load(self) -> None: +        """Carry out cog asynchronous initialisation.""" +        await self._set_rules_command_help() +  async def setup(bot: Bot) -> None:      """Load the Information cog.""" 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 0455f13c4..656e0582c 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -10,19 +10,17 @@ from typing import List, Optional, Union  from async_rediscache import RedisCache  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__) @@ -58,9 +56,9 @@ class Reviewer:      # ]      status_cache = RedisCache() -    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:          """ @@ -71,11 +69,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: @@ -115,9 +113,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. @@ -129,45 +127,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 @@ -192,41 +191,31 @@ class Reviewer:          now = datetime.now(tz=timezone.utc)          await self.status_cache.set("last_vote_date", now.timestamp()) -        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}" @@ -445,29 +434,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 @@ -496,25 +478,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/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index 8e961b67c..5e217a288 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -125,7 +125,8 @@ class PythonVersionSwitcherButton(ui.Button):          version_to_switch_to: Literal["3.10", "3.11"],          snekbox_cog: "Snekbox",          ctx: Context, -        code: str +        code: str, +        args: Optional[list[str]] = None      ) -> None:          self.version_to_switch_to = version_to_switch_to          super().__init__(label=f"Run in {self.version_to_switch_to}", style=enums.ButtonStyle.primary) @@ -134,6 +135,7 @@ class PythonVersionSwitcherButton(ui.Button):          self.ctx = ctx          self.job_name = job_name          self.code = code +        self.args = args      async def callback(self, interaction: Interaction) -> None:          """ @@ -150,7 +152,9 @@ class PythonVersionSwitcherButton(ui.Button):              # The log arg on send_job will stop the actual job from running.              await interaction.message.delete() -        await self.snekbox_cog.run_job(self.job_name, self.ctx, self.version_to_switch_to, self.code) +        await self.snekbox_cog.run_job( +            self.job_name, self.ctx, self.version_to_switch_to, self.code, args=self.args +        )  class Snekbox(Cog): @@ -165,7 +169,8 @@ class Snekbox(Cog):          job_name: str,          current_python_version: Literal["3.10", "3.11"],          ctx: Context, -        code: str +        code: str, +        args: Optional[list[str]] = None      ) -> None:          """Return a view that allows the user to change what version of Python their code is run on."""          if current_python_version == "3.10": @@ -177,7 +182,7 @@ class Snekbox(Cog):              allowed_users=(ctx.author.id,),              allowed_roles=MODERATION_ROLES,          ) -        view.add_item(PythonVersionSwitcherButton(job_name, alt_python_version, self, ctx, code)) +        view.add_item(PythonVersionSwitcherButton(job_name, alt_python_version, self, ctx, code, args))          view.add_item(interactions.DeleteMessageButton())          return view @@ -357,7 +362,7 @@ class Snekbox(Cog):                  response = await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.")              else:                  allowed_mentions = AllowedMentions(everyone=False, roles=False, users=[ctx.author]) -                view = self.build_python_version_switcher_view(job_name, python_version, ctx, code) +                view = self.build_python_version_switcher_view(job_name, python_version, ctx, code, args)                  response = await ctx.send(msg, allowed_mentions=allowed_mentions, view=view)                  view.message = response diff --git a/bot/resources/tags/nomodule.md b/bot/resources/tags/nomodule.md new file mode 100644 index 000000000..adae555be --- /dev/null +++ b/bot/resources/tags/nomodule.md @@ -0,0 +1,13 @@ +**ModuleNotFoundError** + +If you've installed a package but you're getting a ModuleNotFoundError when you try to import it, it's likely that the environment where your code is running is different from the one where you did the installation. + +You can read about Python environments at `!tags environments` and `!tags venv`. + +Common causes of this problem include: + +• You installed your package using `pip install ...`. It could be that the `pip` command is not pointing to the environment where your code runs. For greater control, you could instead run pip as a module within the python environment you specify: +``` +python -m pip install <your_package> +``` +• Your editor/ide is configured to create virtual environments automatically (PyCharm is configured this way by default). diff --git a/bot/resources/tags/slicing.md b/bot/resources/tags/slicing.md new file mode 100644 index 000000000..717fc46b7 --- /dev/null +++ b/bot/resources/tags/slicing.md @@ -0,0 +1,24 @@ +--- +aliases: ["slice", "seqslice", "seqslicing", "sequence-slice", "sequence-slicing"] +embed: +    title: "Sequence slicing" +--- +*Slicing* is a way of accessing a part of a sequence by specifying a start, stop, and step. As with normal indexing, negative numbers can be used to count backwards. + +**Examples** +```py +>>> letters = ['a', 'b', 'c', 'd', 'e', 'f', 'g'] +>>> letters[2:]  # from element 2 to the end +['c', 'd', 'e', 'f', 'g'] +>>> letters[:4]  # up to element 4 +['a', 'b', 'c', 'd'] +>>> letters[3:5]  # elements 3 and 4 -- the right bound is not included +['d', 'e'] +>>> letters[2:-1:2]  # Every other element between 2 and the last +['c', 'e'] +>>> letters[::-1]  # The whole list in reverse +['g', 'f', 'e', 'd', 'c', 'b', 'a'] +>>> words = "Hello world!" +>>> words[2:7]  # Strings are also sequences +"llo w" +``` diff --git a/docker-compose.yml b/docker-compose.yml index be7370d6b..bc53c482b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,7 +18,7 @@ services:    postgres:      << : *logging      << : *restart_policy -    image: postgres:13-alpine +    image: postgres:15-alpine      environment:        POSTGRES_DB: pysite        POSTGRES_PASSWORD: pysite 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/filters/test_security.py b/tests/bot/exts/backend/test_security.py index 007b7b1eb..c3985c609 100644 --- a/tests/bot/exts/filters/test_security.py +++ b/tests/bot/exts/backend/test_security.py @@ -2,7 +2,7 @@ import unittest  from discord.ext.commands import NoPrivateMessage -from bot.exts.filters import security +from bot.exts.backend import security  from tests.helpers import MockBot, MockContext diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index 9f5143c01..65595e959 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -603,9 +603,9 @@ class RuleCommandTests(unittest.IsolatedAsyncioTestCase):      async def test_return_none_if_one_rule_number_is_invalid(self):          test_cases = [ -            (('1', '6', '7', '8'), (6, 7, 8)), -            (('10', "first"), (10, )), -            (("first", 10), (10, )) +            ("1 6 7 8", (6, 7, 8)), +            ("10 first", (10,)), +            ("first 10", (10,))          ]          for raw_user_input, extracted_rule_numbers in test_cases: @@ -614,7 +614,7 @@ class RuleCommandTests(unittest.IsolatedAsyncioTestCase):                      str(rule_number) for rule_number in extracted_rule_numbers                      if rule_number < 1 or rule_number > len(self.full_rules)) -                final_rule_numbers = await self.cog.rules(self.cog, self.ctx, *raw_user_input) +                final_rule_numbers = await self.cog.rules(self.cog, self.ctx, args=raw_user_input)                  self.assertEqual(                      self.ctx.send.call_args, @@ -624,26 +624,26 @@ class RuleCommandTests(unittest.IsolatedAsyncioTestCase):      async def test_return_correct_rule_numbers(self):          test_cases = [ -            (("1", "2", "first"), {1, 2}), -            (("1", "hello", "2", "second"), {1}), -            (("second", "third", "unknown", "999"), {2, 3}) +            ("1 2 first", {1, 2}), +            ("1 hello 2 second", {1}), +            ("second third unknown 999", {2, 3}),          ]          for raw_user_input, expected_matched_rule_numbers in test_cases:              with self.subTest(identifier=raw_user_input): -                final_rule_numbers = await self.cog.rules(self.cog, self.ctx, *raw_user_input) +                final_rule_numbers = await self.cog.rules(self.cog, self.ctx, args=raw_user_input)                  self.assertEqual(expected_matched_rule_numbers, final_rule_numbers)      async def test_return_default_rules_when_no_input_or_no_match_are_found(self):          test_cases = [ -            ((), None), -            (("hello", "2", "second"), None), -            (("hello", "999"), None), +            ("", None), +            ("hello 2 second", None), +            ("hello 999", None),          ]          for raw_user_input, expected_matched_rule_numbers in test_cases:              with self.subTest(identifier=raw_user_input): -                final_rule_numbers = await self.cog.rules(self.cog, self.ctx, *raw_user_input) +                final_rule_numbers = await self.cog.rules(self.cog, self.ctx, args=raw_user_input)                  embed = self.ctx.send.call_args.kwargs['embed']                  self.assertEqual(information.DEFAULT_RULES_DESCRIPTION, embed.description)                  self.assertEqual(expected_matched_rule_numbers, final_rule_numbers) diff --git a/tests/bot/exts/recruitment/talentpool/test_review.py b/tests/bot/exts/recruitment/talentpool/test_review.py index 295b0e221..1ddb73ab0 100644 --- a/tests/bot/exts/recruitment/talentpool/test_review.py +++ b/tests/bot/exts/recruitment/talentpool/test_review.py @@ -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)) @@ -118,8 +120,8 @@ class ReviewerTests(unittest.IsolatedAsyncioTestCase):                  cache_get_mock.assert_called_with("last_vote_date")      @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 @@ -156,19 +158,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 @@ -206,8 +208,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) | 
