We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
Disallow editing duration of expired infractions
Currently the command assumes that an infraction is still active if a duration is being edited. It tries to cancel the previous infraction but it will fail with a warning if the infraction already expired. Relevant code can be found here:
https://github.com/python-discord/bot/blob/582ddbb1ca8bab2cb883781911f5f35962330995/bot/cogs/moderation/management.py#L130-L142

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

{"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@@ -100,7 +100,12 @@\n         confirm_messages = []\n         log_text = \"\"\n \n-        if isinstance(duration, str):\n+        if duration is not None and not old_infraction['active']:\n+            if reason is None:\n+                await ctx.send(\":x: Cannot edit the expiration of an expired infraction.\")\n+                return\n+            confirm_messages.append(\"expiry unchanged (infraction already expired)\")\n+        elif isinstance(duration, str):\n             request_data['expires_at'] = None\n             confirm_messages.append(\"marked as permanent\")\n         elif duration is not None:\n", "issue": "Disallow editing duration of expired infractions\nCurrently the command assumes that an infraction is still active if a duration is being edited. It tries to cancel the previous infraction but it will fail with a warning if the infraction already expired. Relevant code can be found here:\r\nhttps://github.com/python-discord/bot/blob/582ddbb1ca8bab2cb883781911f5f35962330995/bot/cogs/moderation/management.py#L130-L142\n", "before_files": [{"content": "import 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            # A scheduled task should only exist if the old infraction wasn't permanent\n            if old_infraction['expires_at']:\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                self.infractions_cog.schedule_task(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 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 duration is not None and not old_infraction['active']:\n            if reason is None:\n                await ctx.send(\":x: Cannot edit the expiration of an expired infraction.\")\n                return\n            confirm_messages.append(\"expiry unchanged (infraction already expired)\")\n        elif 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            # A scheduled task should only exist if the old infraction wasn't permanent\n            if old_infraction['expires_at']:\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                self.infractions_cog.schedule_task(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"}]}