We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
Infraction scheduler raises exception when marking infraction as "permanent" during edit
When an existing temporary infraction is turned into a permanent infraction using the `!infraction edit` functionality, we ask the infraction scheduler to schedule a new task with `infraction["expires_at"] = None`. However, the scheduler expects to be called only for infractions that have a datetime string mapped to the `"expires_at"` key. This means that the scheduler will try to parse that `None` value with `dateutil.parse.isoparse`, which results in an exception:

```py
Traceback (most recent call last):
  File "/bot/bot/cogs/moderation/scheduler.py", line 415, in _scheduled_task
    expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None)
  File "/usr/local/lib/python3.7/site-packages/dateutil/parser/isoparser.py", line 37, in func
    return f(self, str_in, *args, **kwargs)
  File "/usr/local/lib/python3.7/site-packages/dateutil/parser/isoparser.py", line 134, in isoparse
    components, pos = self._parse_isodate(dt_str)
  File "/usr/local/lib/python3.7/site-packages/dateutil/parser/isoparser.py", line 208, in _parse_isodate
    return self._parse_isodate_common(dt_str)
  File "/usr/local/lib/python3.7/site-packages/dateutil/parser/isoparser.py", line 213, in _parse_isodate_common
    len_str = len(dt_str)
TypeError: object of type 'NoneType' has no len()
```

I think the wisest course of action is tackle this at the level of the infraction edit method, not in the scheduler itself. We should just not try to schedule a new task when we've marked it as permanent. That means this part need to get an additional check:

https://github.com/python-discord/bot/blob/95ae05c773e753699e6899255783c1d7df936024/bot/cogs/moderation/management.py#L131-L134

**Important:** We should still *cancel* the existing scheduled task (which we currently do, it happens before the exception), so skipping this `if` block entirely will break the infraction edit in a more severe way: Cancelling the task is essential to guarantee that we don't accidentally "pardon" a now permanent infraction.

--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `bot/cogs/moderation/management.py`
Content:
```
1 import asyncio
2 import logging
3 import textwrap
4 import typing as t
5 from datetime import datetime
6 
7 import discord
8 from discord.ext import commands
9 from discord.ext.commands import Context
10 
11 from bot import constants
12 from bot.bot import Bot
13 from bot.converters import Expiry, InfractionSearchQuery, allowed_strings, proxy_user
14 from bot.pagination import LinePaginator
15 from bot.utils import time
16 from bot.utils.checks import in_channel_check, with_role_check
17 from . import utils
18 from .infractions import Infractions
19 from .modlog import ModLog
20 
21 log = logging.getLogger(__name__)
22 
23 
24 class ModManagement(commands.Cog):
25     """Management of infractions."""
26 
27     category = "Moderation"
28 
29     def __init__(self, bot: Bot):
30         self.bot = bot
31 
32     @property
33     def mod_log(self) -> ModLog:
34         """Get currently loaded ModLog cog instance."""
35         return self.bot.get_cog("ModLog")
36 
37     @property
38     def infractions_cog(self) -> Infractions:
39         """Get currently loaded Infractions cog instance."""
40         return self.bot.get_cog("Infractions")
41 
42     # region: Edit infraction commands
43 
44     @commands.group(name='infraction', aliases=('infr', 'infractions', 'inf'), invoke_without_command=True)
45     async def infraction_group(self, ctx: Context) -> None:
46         """Infraction manipulation commands."""
47         await ctx.invoke(self.bot.get_command("help"), "infraction")
48 
49     @infraction_group.command(name='edit')
50     async def infraction_edit(
51         self,
52         ctx: Context,
53         infraction_id: t.Union[int, allowed_strings("l", "last", "recent")],
54         duration: t.Union[Expiry, allowed_strings("p", "permanent"), None],
55         *,
56         reason: str = None
57     ) -> None:
58         """
59         Edit the duration and/or the reason of an infraction.
60 
61         Durations are relative to the time of updating and should be appended with a unit of time.
62         Units (∗case-sensitive):
63         \u2003`y` - years
64         \u2003`m` - months∗
65         \u2003`w` - weeks
66         \u2003`d` - days
67         \u2003`h` - hours
68         \u2003`M` - minutes∗
69         \u2003`s` - seconds
70 
71         Use "l", "last", or "recent" as the infraction ID to specify that the most recent infraction
72         authored by the command invoker should be edited.
73 
74         Use "p" or "permanent" to mark the infraction as permanent. Alternatively, an ISO 8601
75         timestamp can be provided for the duration.
76         """
77         if duration is None and reason is None:
78             # Unlike UserInputError, the error handler will show a specified message for BadArgument
79             raise commands.BadArgument("Neither a new expiry nor a new reason was specified.")
80 
81         # Retrieve the previous infraction for its information.
82         if isinstance(infraction_id, str):
83             params = {
84                 "actor__id": ctx.author.id,
85                 "ordering": "-inserted_at"
86             }
87             infractions = await self.bot.api_client.get(f"bot/infractions", params=params)
88 
89             if infractions:
90                 old_infraction = infractions[0]
91                 infraction_id = old_infraction["id"]
92             else:
93                 await ctx.send(
94                     f":x: Couldn't find most recent infraction; you have never given an infraction."
95                 )
96                 return
97         else:
98             old_infraction = await self.bot.api_client.get(f"bot/infractions/{infraction_id}")
99 
100         request_data = {}
101         confirm_messages = []
102         log_text = ""
103 
104         if isinstance(duration, str):
105             request_data['expires_at'] = None
106             confirm_messages.append("marked as permanent")
107         elif duration is not None:
108             request_data['expires_at'] = duration.isoformat()
109             expiry = time.format_infraction_with_duration(request_data['expires_at'])
110             confirm_messages.append(f"set to expire on {expiry}")
111         else:
112             confirm_messages.append("expiry unchanged")
113 
114         if reason:
115             request_data['reason'] = reason
116             confirm_messages.append("set a new reason")
117             log_text += f"""
118                 Previous reason: {old_infraction['reason']}
119                 New reason: {reason}
120             """.rstrip()
121         else:
122             confirm_messages.append("reason unchanged")
123 
124         # Update the infraction
125         new_infraction = await self.bot.api_client.patch(
126             f'bot/infractions/{infraction_id}',
127             json=request_data,
128         )
129 
130         # Re-schedule infraction if the expiration has been updated
131         if 'expires_at' in request_data:
132             self.infractions_cog.cancel_task(new_infraction['id'])
133             loop = asyncio.get_event_loop()
134             self.infractions_cog.schedule_task(loop, new_infraction['id'], new_infraction)
135 
136             log_text += f"""
137                 Previous expiry: {old_infraction['expires_at'] or "Permanent"}
138                 New expiry: {new_infraction['expires_at'] or "Permanent"}
139             """.rstrip()
140 
141         changes = ' & '.join(confirm_messages)
142         await ctx.send(f":ok_hand: Updated infraction #{infraction_id}: {changes}")
143 
144         # Get information about the infraction's user
145         user_id = new_infraction['user']
146         user = ctx.guild.get_member(user_id)
147 
148         if user:
149             user_text = f"{user.mention} (`{user.id}`)"
150             thumbnail = user.avatar_url_as(static_format="png")
151         else:
152             user_text = f"`{user_id}`"
153             thumbnail = None
154 
155         # The infraction's actor
156         actor_id = new_infraction['actor']
157         actor = ctx.guild.get_member(actor_id) or f"`{actor_id}`"
158 
159         await self.mod_log.send_log_message(
160             icon_url=constants.Icons.pencil,
161             colour=discord.Colour.blurple(),
162             title="Infraction edited",
163             thumbnail=thumbnail,
164             text=textwrap.dedent(f"""
165                 Member: {user_text}
166                 Actor: {actor}
167                 Edited by: {ctx.message.author}{log_text}
168             """)
169         )
170 
171     # endregion
172     # region: Search infractions
173 
174     @infraction_group.group(name="search", invoke_without_command=True)
175     async def infraction_search_group(self, ctx: Context, query: InfractionSearchQuery) -> None:
176         """Searches for infractions in the database."""
177         if isinstance(query, discord.User):
178             await ctx.invoke(self.search_user, query)
179         else:
180             await ctx.invoke(self.search_reason, query)
181 
182     @infraction_search_group.command(name="user", aliases=("member", "id"))
183     async def search_user(self, ctx: Context, user: t.Union[discord.User, proxy_user]) -> None:
184         """Search for infractions by member."""
185         infraction_list = await self.bot.api_client.get(
186             'bot/infractions',
187             params={'user__id': str(user.id)}
188         )
189         embed = discord.Embed(
190             title=f"Infractions for {user} ({len(infraction_list)} total)",
191             colour=discord.Colour.orange()
192         )
193         await self.send_infraction_list(ctx, embed, infraction_list)
194 
195     @infraction_search_group.command(name="reason", aliases=("match", "regex", "re"))
196     async def search_reason(self, ctx: Context, reason: str) -> None:
197         """Search for infractions by their reason. Use Re2 for matching."""
198         infraction_list = await self.bot.api_client.get(
199             'bot/infractions',
200             params={'search': reason}
201         )
202         embed = discord.Embed(
203             title=f"Infractions matching `{reason}` ({len(infraction_list)} total)",
204             colour=discord.Colour.orange()
205         )
206         await self.send_infraction_list(ctx, embed, infraction_list)
207 
208     # endregion
209     # region: Utility functions
210 
211     async def send_infraction_list(
212         self,
213         ctx: Context,
214         embed: discord.Embed,
215         infractions: t.Iterable[utils.Infraction]
216     ) -> None:
217         """Send a paginated embed of infractions for the specified user."""
218         if not infractions:
219             await ctx.send(f":warning: No infractions could be found for that query.")
220             return
221 
222         lines = tuple(
223             self.infraction_to_string(infraction)
224             for infraction in infractions
225         )
226 
227         await LinePaginator.paginate(
228             lines,
229             ctx=ctx,
230             embed=embed,
231             empty=True,
232             max_lines=3,
233             max_size=1000
234         )
235 
236     def infraction_to_string(self, infraction: utils.Infraction) -> str:
237         """Convert the infraction object to a string representation."""
238         actor_id = infraction["actor"]
239         guild = self.bot.get_guild(constants.Guild.id)
240         actor = guild.get_member(actor_id)
241         active = infraction["active"]
242         user_id = infraction["user"]
243         hidden = infraction["hidden"]
244         created = time.format_infraction(infraction["inserted_at"])
245 
246         if active:
247             remaining = time.until_expiration(infraction["expires_at"]) or "Expired"
248         else:
249             remaining = "Inactive"
250 
251         if infraction["expires_at"] is None:
252             expires = "*Permanent*"
253         else:
254             date_from = datetime.strptime(created, time.INFRACTION_FORMAT)
255             expires = time.format_infraction_with_duration(infraction["expires_at"], date_from)
256 
257         lines = textwrap.dedent(f"""
258             {"**===============**" if active else "==============="}
259             Status: {"__**Active**__" if active else "Inactive"}
260             User: {self.bot.get_user(user_id)} (`{user_id}`)
261             Type: **{infraction["type"]}**
262             Shadow: {hidden}
263             Reason: {infraction["reason"] or "*None*"}
264             Created: {created}
265             Expires: {expires}
266             Remaining: {remaining}
267             Actor: {actor.mention if actor else actor_id}
268             ID: `{infraction["id"]}`
269             {"**===============**" if active else "==============="}
270         """)
271 
272         return lines.strip()
273 
274     # endregion
275 
276     # This cannot be static (must have a __func__ attribute).
277     def cog_check(self, ctx: Context) -> bool:
278         """Only allow moderators from moderator channels to invoke the commands in this cog."""
279         checks = [
280             with_role_check(ctx, *constants.MODERATION_ROLES),
281             in_channel_check(ctx, *constants.MODERATION_CHANNELS)
282         ]
283         return all(checks)
284 
285     # This cannot be static (must have a __func__ attribute).
286     async def cog_command_error(self, ctx: Context, error: Exception) -> None:
287         """Send a notification to the invoking context on a Union failure."""
288         if isinstance(error, commands.BadUnionArgument):
289             if discord.User in error.converters:
290                 await ctx.send(str(error.errors[0]))
291                 error.handled = True
292
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py
--- a/bot/cogs/moderation/management.py
+++ b/bot/cogs/moderation/management.py
@@ -130,8 +130,11 @@
         # Re-schedule infraction if the expiration has been updated
         if 'expires_at' in request_data:
             self.infractions_cog.cancel_task(new_infraction['id'])
-            loop = asyncio.get_event_loop()
-            self.infractions_cog.schedule_task(loop, new_infraction['id'], new_infraction)
+
+            # If the infraction was not marked as permanent, schedule a new expiration task
+            if request_data['expires_at']:
+                loop = asyncio.get_event_loop()
+                self.infractions_cog.schedule_task(loop, new_infraction['id'], new_infraction)
 
             log_text += f"""
                 Previous expiry: {old_infraction['expires_at'] or "Permanent"}

{"golden_diff": "diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py\n--- a/bot/cogs/moderation/management.py\n+++ b/bot/cogs/moderation/management.py\n@@ -130,8 +130,11 @@\n         # Re-schedule infraction if the expiration has been updated\n         if 'expires_at' in request_data:\n             self.infractions_cog.cancel_task(new_infraction['id'])\n-            loop = asyncio.get_event_loop()\n-            self.infractions_cog.schedule_task(loop, new_infraction['id'], new_infraction)\n+\n+            # If the infraction was not marked as permanent, schedule a new expiration task\n+            if request_data['expires_at']:\n+                loop = asyncio.get_event_loop()\n+                self.infractions_cog.schedule_task(loop, new_infraction['id'], new_infraction)\n \n             log_text += f\"\"\"\n                 Previous expiry: {old_infraction['expires_at'] or \"Permanent\"}\n", "issue": "Infraction scheduler raises exception when marking infraction as \"permanent\" during edit\nWhen an existing temporary infraction is turned into a permanent infraction using the `!infraction edit` functionality, we ask the infraction scheduler to schedule a new task with `infraction[\"expires_at\"] = None`. However, the scheduler expects to be called only for infractions that have a datetime string mapped to the `\"expires_at\"` key. This means that the scheduler will try to parse that `None` value with `dateutil.parse.isoparse`, which results in an exception:\r\n\r\n```py\r\nTraceback (most recent call last):\r\n  File \"/bot/bot/cogs/moderation/scheduler.py\", line 415, in _scheduled_task\r\n    expiry = dateutil.parser.isoparse(infraction[\"expires_at\"]).replace(tzinfo=None)\r\n  File \"/usr/local/lib/python3.7/site-packages/dateutil/parser/isoparser.py\", line 37, in func\r\n    return f(self, str_in, *args, **kwargs)\r\n  File \"/usr/local/lib/python3.7/site-packages/dateutil/parser/isoparser.py\", line 134, in isoparse\r\n    components, pos = self._parse_isodate(dt_str)\r\n  File \"/usr/local/lib/python3.7/site-packages/dateutil/parser/isoparser.py\", line 208, in _parse_isodate\r\n    return self._parse_isodate_common(dt_str)\r\n  File \"/usr/local/lib/python3.7/site-packages/dateutil/parser/isoparser.py\", line 213, in _parse_isodate_common\r\n    len_str = len(dt_str)\r\nTypeError: object of type 'NoneType' has no len()\r\n```\r\n\r\nI think the wisest course of action is tackle this at the level of the infraction edit method, not in the scheduler itself. We should just not try to schedule a new task when we've marked it as permanent. That means this part need to get an additional check:\r\n\r\nhttps://github.com/python-discord/bot/blob/95ae05c773e753699e6899255783c1d7df936024/bot/cogs/moderation/management.py#L131-L134\r\n\r\n**Important:** We should still *cancel* the existing scheduled task (which we currently do, it happens before the exception), so skipping this `if` block entirely will break the infraction edit in a more severe way: Cancelling the task is essential to guarantee that we don't accidentally \"pardon\" a now permanent infraction.\n", "before_files": [{"content": "import asyncio\nimport logging\nimport textwrap\nimport typing as t\nfrom datetime import datetime\n\nimport discord\nfrom discord.ext import commands\nfrom discord.ext.commands import Context\n\nfrom bot import constants\nfrom bot.bot import Bot\nfrom bot.converters import Expiry, InfractionSearchQuery, allowed_strings, proxy_user\nfrom bot.pagination import LinePaginator\nfrom bot.utils import time\nfrom bot.utils.checks import in_channel_check, with_role_check\nfrom . import utils\nfrom .infractions import Infractions\nfrom .modlog import ModLog\n\nlog = logging.getLogger(__name__)\n\n\nclass ModManagement(commands.Cog):\n    \"\"\"Management of infractions.\"\"\"\n\n    category = \"Moderation\"\n\n    def __init__(self, bot: Bot):\n        self.bot = bot\n\n    @property\n    def mod_log(self) -> ModLog:\n        \"\"\"Get currently loaded ModLog cog instance.\"\"\"\n        return self.bot.get_cog(\"ModLog\")\n\n    @property\n    def infractions_cog(self) -> Infractions:\n        \"\"\"Get currently loaded Infractions cog instance.\"\"\"\n        return self.bot.get_cog(\"Infractions\")\n\n    # region: Edit infraction commands\n\n    @commands.group(name='infraction', aliases=('infr', 'infractions', 'inf'), invoke_without_command=True)\n    async def infraction_group(self, ctx: Context) -> None:\n        \"\"\"Infraction manipulation commands.\"\"\"\n        await ctx.invoke(self.bot.get_command(\"help\"), \"infraction\")\n\n    @infraction_group.command(name='edit')\n    async def infraction_edit(\n        self,\n        ctx: Context,\n        infraction_id: t.Union[int, allowed_strings(\"l\", \"last\", \"recent\")],\n        duration: t.Union[Expiry, allowed_strings(\"p\", \"permanent\"), None],\n        *,\n        reason: str = None\n    ) -> None:\n        \"\"\"\n        Edit the duration and/or the reason of an infraction.\n\n        Durations are relative to the time of updating and should be appended with a unit of time.\n        Units (\u2217case-sensitive):\n        \\u2003`y` - years\n        \\u2003`m` - months\u2217\n        \\u2003`w` - weeks\n        \\u2003`d` - days\n        \\u2003`h` - hours\n        \\u2003`M` - minutes\u2217\n        \\u2003`s` - seconds\n\n        Use \"l\", \"last\", or \"recent\" as the infraction ID to specify that the most recent infraction\n        authored by the command invoker should be edited.\n\n        Use \"p\" or \"permanent\" to mark the infraction as permanent. Alternatively, an ISO 8601\n        timestamp can be provided for the duration.\n        \"\"\"\n        if duration is None and reason is None:\n            # Unlike UserInputError, the error handler will show a specified message for BadArgument\n            raise commands.BadArgument(\"Neither a new expiry nor a new reason was specified.\")\n\n        # Retrieve the previous infraction for its information.\n        if isinstance(infraction_id, str):\n            params = {\n                \"actor__id\": ctx.author.id,\n                \"ordering\": \"-inserted_at\"\n            }\n            infractions = await self.bot.api_client.get(f\"bot/infractions\", params=params)\n\n            if infractions:\n                old_infraction = infractions[0]\n                infraction_id = old_infraction[\"id\"]\n            else:\n                await ctx.send(\n                    f\":x: Couldn't find most recent infraction; you have never given an infraction.\"\n                )\n                return\n        else:\n            old_infraction = await self.bot.api_client.get(f\"bot/infractions/{infraction_id}\")\n\n        request_data = {}\n        confirm_messages = []\n        log_text = \"\"\n\n        if isinstance(duration, str):\n            request_data['expires_at'] = None\n            confirm_messages.append(\"marked as permanent\")\n        elif duration is not None:\n            request_data['expires_at'] = duration.isoformat()\n            expiry = time.format_infraction_with_duration(request_data['expires_at'])\n            confirm_messages.append(f\"set to expire on {expiry}\")\n        else:\n            confirm_messages.append(\"expiry unchanged\")\n\n        if reason:\n            request_data['reason'] = reason\n            confirm_messages.append(\"set a new reason\")\n            log_text += f\"\"\"\n                Previous reason: {old_infraction['reason']}\n                New reason: {reason}\n            \"\"\".rstrip()\n        else:\n            confirm_messages.append(\"reason unchanged\")\n\n        # Update the infraction\n        new_infraction = await self.bot.api_client.patch(\n            f'bot/infractions/{infraction_id}',\n            json=request_data,\n        )\n\n        # Re-schedule infraction if the expiration has been updated\n        if 'expires_at' in request_data:\n            self.infractions_cog.cancel_task(new_infraction['id'])\n            loop = asyncio.get_event_loop()\n            self.infractions_cog.schedule_task(loop, new_infraction['id'], new_infraction)\n\n            log_text += f\"\"\"\n                Previous expiry: {old_infraction['expires_at'] or \"Permanent\"}\n                New expiry: {new_infraction['expires_at'] or \"Permanent\"}\n            \"\"\".rstrip()\n\n        changes = ' & '.join(confirm_messages)\n        await ctx.send(f\":ok_hand: Updated infraction #{infraction_id}: {changes}\")\n\n        # Get information about the infraction's user\n        user_id = new_infraction['user']\n        user = ctx.guild.get_member(user_id)\n\n        if user:\n            user_text = f\"{user.mention} (`{user.id}`)\"\n            thumbnail = user.avatar_url_as(static_format=\"png\")\n        else:\n            user_text = f\"`{user_id}`\"\n            thumbnail = None\n\n        # The infraction's actor\n        actor_id = new_infraction['actor']\n        actor = ctx.guild.get_member(actor_id) or f\"`{actor_id}`\"\n\n        await self.mod_log.send_log_message(\n            icon_url=constants.Icons.pencil,\n            colour=discord.Colour.blurple(),\n            title=\"Infraction edited\",\n            thumbnail=thumbnail,\n            text=textwrap.dedent(f\"\"\"\n                Member: {user_text}\n                Actor: {actor}\n                Edited by: {ctx.message.author}{log_text}\n            \"\"\")\n        )\n\n    # endregion\n    # region: Search infractions\n\n    @infraction_group.group(name=\"search\", invoke_without_command=True)\n    async def infraction_search_group(self, ctx: Context, query: InfractionSearchQuery) -> None:\n        \"\"\"Searches for infractions in the database.\"\"\"\n        if isinstance(query, discord.User):\n            await ctx.invoke(self.search_user, query)\n        else:\n            await ctx.invoke(self.search_reason, query)\n\n    @infraction_search_group.command(name=\"user\", aliases=(\"member\", \"id\"))\n    async def search_user(self, ctx: Context, user: t.Union[discord.User, proxy_user]) -> None:\n        \"\"\"Search for infractions by member.\"\"\"\n        infraction_list = await self.bot.api_client.get(\n            'bot/infractions',\n            params={'user__id': str(user.id)}\n        )\n        embed = discord.Embed(\n            title=f\"Infractions for {user} ({len(infraction_list)} total)\",\n            colour=discord.Colour.orange()\n        )\n        await self.send_infraction_list(ctx, embed, infraction_list)\n\n    @infraction_search_group.command(name=\"reason\", aliases=(\"match\", \"regex\", \"re\"))\n    async def search_reason(self, ctx: Context, reason: str) -> None:\n        \"\"\"Search for infractions by their reason. Use Re2 for matching.\"\"\"\n        infraction_list = await self.bot.api_client.get(\n            'bot/infractions',\n            params={'search': reason}\n        )\n        embed = discord.Embed(\n            title=f\"Infractions matching `{reason}` ({len(infraction_list)} total)\",\n            colour=discord.Colour.orange()\n        )\n        await self.send_infraction_list(ctx, embed, infraction_list)\n\n    # endregion\n    # region: Utility functions\n\n    async def send_infraction_list(\n        self,\n        ctx: Context,\n        embed: discord.Embed,\n        infractions: t.Iterable[utils.Infraction]\n    ) -> None:\n        \"\"\"Send a paginated embed of infractions for the specified user.\"\"\"\n        if not infractions:\n            await ctx.send(f\":warning: No infractions could be found for that query.\")\n            return\n\n        lines = tuple(\n            self.infraction_to_string(infraction)\n            for infraction in infractions\n        )\n\n        await LinePaginator.paginate(\n            lines,\n            ctx=ctx,\n            embed=embed,\n            empty=True,\n            max_lines=3,\n            max_size=1000\n        )\n\n    def infraction_to_string(self, infraction: utils.Infraction) -> str:\n        \"\"\"Convert the infraction object to a string representation.\"\"\"\n        actor_id = infraction[\"actor\"]\n        guild = self.bot.get_guild(constants.Guild.id)\n        actor = guild.get_member(actor_id)\n        active = infraction[\"active\"]\n        user_id = infraction[\"user\"]\n        hidden = infraction[\"hidden\"]\n        created = time.format_infraction(infraction[\"inserted_at\"])\n\n        if active:\n            remaining = time.until_expiration(infraction[\"expires_at\"]) or \"Expired\"\n        else:\n            remaining = \"Inactive\"\n\n        if infraction[\"expires_at\"] is None:\n            expires = \"*Permanent*\"\n        else:\n            date_from = datetime.strptime(created, time.INFRACTION_FORMAT)\n            expires = time.format_infraction_with_duration(infraction[\"expires_at\"], date_from)\n\n        lines = textwrap.dedent(f\"\"\"\n            {\"**===============**\" if active else \"===============\"}\n            Status: {\"__**Active**__\" if active else \"Inactive\"}\n            User: {self.bot.get_user(user_id)} (`{user_id}`)\n            Type: **{infraction[\"type\"]}**\n            Shadow: {hidden}\n            Reason: {infraction[\"reason\"] or \"*None*\"}\n            Created: {created}\n            Expires: {expires}\n            Remaining: {remaining}\n            Actor: {actor.mention if actor else actor_id}\n            ID: `{infraction[\"id\"]}`\n            {\"**===============**\" if active else \"===============\"}\n        \"\"\")\n\n        return lines.strip()\n\n    # endregion\n\n    # This cannot be static (must have a __func__ attribute).\n    def cog_check(self, ctx: Context) -> bool:\n        \"\"\"Only allow moderators from moderator channels to invoke the commands in this cog.\"\"\"\n        checks = [\n            with_role_check(ctx, *constants.MODERATION_ROLES),\n            in_channel_check(ctx, *constants.MODERATION_CHANNELS)\n        ]\n        return all(checks)\n\n    # This cannot be static (must have a __func__ attribute).\n    async def cog_command_error(self, ctx: Context, error: Exception) -> None:\n        \"\"\"Send a notification to the invoking context on a Union failure.\"\"\"\n        if isinstance(error, commands.BadUnionArgument):\n            if discord.User in error.converters:\n                await ctx.send(str(error.errors[0]))\n                error.handled = True\n", "path": "bot/cogs/moderation/management.py"}], "after_files": [{"content": "import asyncio\nimport logging\nimport textwrap\nimport typing as t\nfrom datetime import datetime\n\nimport discord\nfrom discord.ext import commands\nfrom discord.ext.commands import Context\n\nfrom bot import constants\nfrom bot.bot import Bot\nfrom bot.converters import Expiry, InfractionSearchQuery, allowed_strings, proxy_user\nfrom bot.pagination import LinePaginator\nfrom bot.utils import time\nfrom bot.utils.checks import in_channel_check, with_role_check\nfrom . import utils\nfrom .infractions import Infractions\nfrom .modlog import ModLog\n\nlog = logging.getLogger(__name__)\n\n\nclass ModManagement(commands.Cog):\n    \"\"\"Management of infractions.\"\"\"\n\n    category = \"Moderation\"\n\n    def __init__(self, bot: Bot):\n        self.bot = bot\n\n    @property\n    def mod_log(self) -> ModLog:\n        \"\"\"Get currently loaded ModLog cog instance.\"\"\"\n        return self.bot.get_cog(\"ModLog\")\n\n    @property\n    def infractions_cog(self) -> Infractions:\n        \"\"\"Get currently loaded Infractions cog instance.\"\"\"\n        return self.bot.get_cog(\"Infractions\")\n\n    # region: Edit infraction commands\n\n    @commands.group(name='infraction', aliases=('infr', 'infractions', 'inf'), invoke_without_command=True)\n    async def infraction_group(self, ctx: Context) -> None:\n        \"\"\"Infraction manipulation commands.\"\"\"\n        await ctx.invoke(self.bot.get_command(\"help\"), \"infraction\")\n\n    @infraction_group.command(name='edit')\n    async def infraction_edit(\n        self,\n        ctx: Context,\n        infraction_id: t.Union[int, allowed_strings(\"l\", \"last\", \"recent\")],\n        duration: t.Union[Expiry, allowed_strings(\"p\", \"permanent\"), None],\n        *,\n        reason: str = None\n    ) -> None:\n        \"\"\"\n        Edit the duration and/or the reason of an infraction.\n\n        Durations are relative to the time of updating and should be appended with a unit of time.\n        Units (\u2217case-sensitive):\n        \\u2003`y` - years\n        \\u2003`m` - months\u2217\n        \\u2003`w` - weeks\n        \\u2003`d` - days\n        \\u2003`h` - hours\n        \\u2003`M` - minutes\u2217\n        \\u2003`s` - seconds\n\n        Use \"l\", \"last\", or \"recent\" as the infraction ID to specify that the most recent infraction\n        authored by the command invoker should be edited.\n\n        Use \"p\" or \"permanent\" to mark the infraction as permanent. Alternatively, an ISO 8601\n        timestamp can be provided for the duration.\n        \"\"\"\n        if duration is None and reason is None:\n            # Unlike UserInputError, the error handler will show a specified message for BadArgument\n            raise commands.BadArgument(\"Neither a new expiry nor a new reason was specified.\")\n\n        # Retrieve the previous infraction for its information.\n        if isinstance(infraction_id, str):\n            params = {\n                \"actor__id\": ctx.author.id,\n                \"ordering\": \"-inserted_at\"\n            }\n            infractions = await self.bot.api_client.get(f\"bot/infractions\", params=params)\n\n            if infractions:\n                old_infraction = infractions[0]\n                infraction_id = old_infraction[\"id\"]\n            else:\n                await ctx.send(\n                    f\":x: Couldn't find most recent infraction; you have never given an infraction.\"\n                )\n                return\n        else:\n            old_infraction = await self.bot.api_client.get(f\"bot/infractions/{infraction_id}\")\n\n        request_data = {}\n        confirm_messages = []\n        log_text = \"\"\n\n        if isinstance(duration, str):\n            request_data['expires_at'] = None\n            confirm_messages.append(\"marked as permanent\")\n        elif duration is not None:\n            request_data['expires_at'] = duration.isoformat()\n            expiry = time.format_infraction_with_duration(request_data['expires_at'])\n            confirm_messages.append(f\"set to expire on {expiry}\")\n        else:\n            confirm_messages.append(\"expiry unchanged\")\n\n        if reason:\n            request_data['reason'] = reason\n            confirm_messages.append(\"set a new reason\")\n            log_text += f\"\"\"\n                Previous reason: {old_infraction['reason']}\n                New reason: {reason}\n            \"\"\".rstrip()\n        else:\n            confirm_messages.append(\"reason unchanged\")\n\n        # Update the infraction\n        new_infraction = await self.bot.api_client.patch(\n            f'bot/infractions/{infraction_id}',\n            json=request_data,\n        )\n\n        # Re-schedule infraction if the expiration has been updated\n        if 'expires_at' in request_data:\n            self.infractions_cog.cancel_task(new_infraction['id'])\n\n            # If the infraction was not marked as permanent, schedule a new expiration task\n            if request_data['expires_at']:\n                loop = asyncio.get_event_loop()\n                self.infractions_cog.schedule_task(loop, new_infraction['id'], new_infraction)\n\n            log_text += f\"\"\"\n                Previous expiry: {old_infraction['expires_at'] or \"Permanent\"}\n                New expiry: {new_infraction['expires_at'] or \"Permanent\"}\n            \"\"\".rstrip()\n\n        changes = ' & '.join(confirm_messages)\n        await ctx.send(f\":ok_hand: Updated infraction #{infraction_id}: {changes}\")\n\n        # Get information about the infraction's user\n        user_id = new_infraction['user']\n        user = ctx.guild.get_member(user_id)\n\n        if user:\n            user_text = f\"{user.mention} (`{user.id}`)\"\n            thumbnail = user.avatar_url_as(static_format=\"png\")\n        else:\n            user_text = f\"`{user_id}`\"\n            thumbnail = None\n\n        # The infraction's actor\n        actor_id = new_infraction['actor']\n        actor = ctx.guild.get_member(actor_id) or f\"`{actor_id}`\"\n\n        await self.mod_log.send_log_message(\n            icon_url=constants.Icons.pencil,\n            colour=discord.Colour.blurple(),\n            title=\"Infraction edited\",\n            thumbnail=thumbnail,\n            text=textwrap.dedent(f\"\"\"\n                Member: {user_text}\n                Actor: {actor}\n                Edited by: {ctx.message.author}{log_text}\n            \"\"\")\n        )\n\n    # endregion\n    # region: Search infractions\n\n    @infraction_group.group(name=\"search\", invoke_without_command=True)\n    async def infraction_search_group(self, ctx: Context, query: InfractionSearchQuery) -> None:\n        \"\"\"Searches for infractions in the database.\"\"\"\n        if isinstance(query, discord.User):\n            await ctx.invoke(self.search_user, query)\n        else:\n            await ctx.invoke(self.search_reason, query)\n\n    @infraction_search_group.command(name=\"user\", aliases=(\"member\", \"id\"))\n    async def search_user(self, ctx: Context, user: t.Union[discord.User, proxy_user]) -> None:\n        \"\"\"Search for infractions by member.\"\"\"\n        infraction_list = await self.bot.api_client.get(\n            'bot/infractions',\n            params={'user__id': str(user.id)}\n        )\n        embed = discord.Embed(\n            title=f\"Infractions for {user} ({len(infraction_list)} total)\",\n            colour=discord.Colour.orange()\n        )\n        await self.send_infraction_list(ctx, embed, infraction_list)\n\n    @infraction_search_group.command(name=\"reason\", aliases=(\"match\", \"regex\", \"re\"))\n    async def search_reason(self, ctx: Context, reason: str) -> None:\n        \"\"\"Search for infractions by their reason. Use Re2 for matching.\"\"\"\n        infraction_list = await self.bot.api_client.get(\n            'bot/infractions',\n            params={'search': reason}\n        )\n        embed = discord.Embed(\n            title=f\"Infractions matching `{reason}` ({len(infraction_list)} total)\",\n            colour=discord.Colour.orange()\n        )\n        await self.send_infraction_list(ctx, embed, infraction_list)\n\n    # endregion\n    # region: Utility functions\n\n    async def send_infraction_list(\n        self,\n        ctx: Context,\n        embed: discord.Embed,\n        infractions: t.Iterable[utils.Infraction]\n    ) -> None:\n        \"\"\"Send a paginated embed of infractions for the specified user.\"\"\"\n        if not infractions:\n            await ctx.send(f\":warning: No infractions could be found for that query.\")\n            return\n\n        lines = tuple(\n            self.infraction_to_string(infraction)\n            for infraction in infractions\n        )\n\n        await LinePaginator.paginate(\n            lines,\n            ctx=ctx,\n            embed=embed,\n            empty=True,\n            max_lines=3,\n            max_size=1000\n        )\n\n    def infraction_to_string(self, infraction: utils.Infraction) -> str:\n        \"\"\"Convert the infraction object to a string representation.\"\"\"\n        actor_id = infraction[\"actor\"]\n        guild = self.bot.get_guild(constants.Guild.id)\n        actor = guild.get_member(actor_id)\n        active = infraction[\"active\"]\n        user_id = infraction[\"user\"]\n        hidden = infraction[\"hidden\"]\n        created = time.format_infraction(infraction[\"inserted_at\"])\n\n        if active:\n            remaining = time.until_expiration(infraction[\"expires_at\"]) or \"Expired\"\n        else:\n            remaining = \"Inactive\"\n\n        if infraction[\"expires_at\"] is None:\n            expires = \"*Permanent*\"\n        else:\n            date_from = datetime.strptime(created, time.INFRACTION_FORMAT)\n            expires = time.format_infraction_with_duration(infraction[\"expires_at\"], date_from)\n\n        lines = textwrap.dedent(f\"\"\"\n            {\"**===============**\" if active else \"===============\"}\n            Status: {\"__**Active**__\" if active else \"Inactive\"}\n            User: {self.bot.get_user(user_id)} (`{user_id}`)\n            Type: **{infraction[\"type\"]}**\n            Shadow: {hidden}\n            Reason: {infraction[\"reason\"] or \"*None*\"}\n            Created: {created}\n            Expires: {expires}\n            Remaining: {remaining}\n            Actor: {actor.mention if actor else actor_id}\n            ID: `{infraction[\"id\"]}`\n            {\"**===============**\" if active else \"===============\"}\n        \"\"\")\n\n        return lines.strip()\n\n    # endregion\n\n    # This cannot be static (must have a __func__ attribute).\n    def cog_check(self, ctx: Context) -> bool:\n        \"\"\"Only allow moderators from moderator channels to invoke the commands in this cog.\"\"\"\n        checks = [\n            with_role_check(ctx, *constants.MODERATION_ROLES),\n            in_channel_check(ctx, *constants.MODERATION_CHANNELS)\n        ]\n        return all(checks)\n\n    # This cannot be static (must have a __func__ attribute).\n    async def cog_command_error(self, ctx: Context, error: Exception) -> None:\n        \"\"\"Send a notification to the invoking context on a Union failure.\"\"\"\n        if isinstance(error, commands.BadUnionArgument):\n            if discord.User in error.converters:\n                await ctx.send(str(error.errors[0]))\n                error.handled = True\n", "path": "bot/cogs/moderation/management.py"}]}