diff --git a/cogs/__init__.py b/cogs/__init__.py index 0c74de61e..8a7de3e07 100644 --- a/cogs/__init__.py +++ b/cogs/__init__.py @@ -21,6 +21,7 @@ from .command_error import CommandErrorCog from .committee_actions_tracking import ( CommitteeActionsTrackingContextCommandCog, + CommitteeActionsTrackingRemindersTaskCog, CommitteeActionsTrackingSlashCommandsCog, ) from .delete_all import DeleteAllCommandsCog @@ -60,7 +61,8 @@ "CheckSUPlatformAuthorisationTaskCog", "ClearRemindersBacklogTaskCog", "CommandErrorCog", - "CommitteeActionsTrackingContextCommandsCog", + "CommitteeActionsTrackingContextCommandCog", + "CommitteeActionsTrackingRemindersTaskCog", "CommitteeActionsTrackingSlashCommandsCog", "CommitteeHandoverCommandCog", "DeleteAllCommandsCog", @@ -101,6 +103,7 @@ def setup(bot: "TeXBot") -> None: ClearRemindersBacklogTaskCog, CommandErrorCog, CommitteeActionsTrackingSlashCommandsCog, + CommitteeActionsTrackingRemindersTaskCog, CommitteeActionsTrackingContextCommandCog, CommitteeHandoverCommandCog, DeleteAllCommandsCog, diff --git a/cogs/committee_actions_tracking.py b/cogs/committee_actions_tracking.py index 7fe39071d..b2c241683 100644 --- a/cogs/committee_actions_tracking.py +++ b/cogs/committee_actions_tracking.py @@ -1,16 +1,21 @@ """Contains cog classes for tracking committee-actions.""" import contextlib +import datetime import logging import random import textwrap +import time +from collections import defaultdict from enum import Enum -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, override import discord +from discord.ext import tasks from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist, ValidationError from django.db.models import Q +from config import settings from db.core.models import AssignedCommitteeAction, DiscordMember from exceptions import ( CommitteeElectRoleDoesNotExistError, @@ -19,18 +24,20 @@ InvalidActionTargetError, ) from utils import CommandChecks, TeXBotBaseCog +from utils.error_capture_decorators import capture_guild_does_not_exist_error if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Collection, Iterable, Mapping, Sequence from collections.abc import Set as AbstractSet from logging import Logger from typing import Final - from utils import TeXBotApplicationContext, TeXBotAutocompleteContext + from utils import TeXBot, TeXBotApplicationContext, TeXBotAutocompleteContext __all__: "Sequence[str]" = ( "CommitteeActionsTrackingBaseCog", "CommitteeActionsTrackingContextCommandCog", + "CommitteeActionsTrackingRemindersTaskCog", "CommitteeActionsTrackingSlashCommandsCog", ) @@ -51,6 +58,81 @@ class Status(Enum): class CommitteeActionsTrackingBaseCog(TeXBotBaseCog): """Base cog class that defines methods for committee actions tracking.""" + @classmethod + async def _get_actions_grouped_by_member_id( + cls, filter_query: Q | None = None + ) -> "Mapping[str, Collection[AssignedCommitteeAction]]": + grouped_actions: dict[str, list[AssignedCommitteeAction]] = defaultdict(list) + if filter_query is None: + filter_query = Q() + + action: AssignedCommitteeAction + async for action in AssignedCommitteeAction.objects.select_related( + "discord_member" + ).filter(filter_query): + grouped_actions[action.discord_member.discord_id].append(action) + + return grouped_actions + + async def _update_action_board(self) -> None: + """ + Update the action board message with the current actions. + + This method should be called after any action is created, updated or deleted. + """ + if not settings["DISPLAY_COMMITTEE_ACTIONS_BOARD"]: + return + + action_board_channel: discord.TextChannel | None = discord.utils.get( + self.bot.main_guild.text_channels, + name=settings["COMMITTEE_ACTIONS_BOARD_CHANNEL"], + ) + + if not action_board_channel: + logger.warning( + "Action board channel could not be found so " + "the action board will not be updated." + ) + return + + action_board_message: discord.Message | None = None + with contextlib.suppress(discord.errors.NoMoreItems): + action_board_message = await action_board_channel.history(limit=1).next() + + if not action_board_message or action_board_message.author != self.bot.user: + action_board_message = await action_board_channel.send( + content="## Committee Actions Tracking Board\n" + ) + + all_actions: Mapping[ + str, Collection[AssignedCommitteeAction] + ] = await self._get_actions_grouped_by_member_id( + filter_query=Q(raw_status__in=AssignedCommitteeAction.Status.TODO_FILTER) + ) + + logger.debug(all_actions) + + if not all_actions: + return + + await action_board_message.edit( + content=f"## Committee Actions Tracking Board\n{ + '\n'.join( + [ + f'\n<@{discord_id}>, Actions:' + f'\n{ + ", \n".join( + action.status.emoji + + f" {action.description} ({action.status.label})" + for action in actions + ) + }' + for discord_id, actions in all_actions.items() + ], + ) + }" + ) + async def _create_action( self, ctx: "TeXBotApplicationContext", action_user: discord.Member, description: str ) -> AssignedCommitteeAction | None: @@ -107,8 +189,131 @@ async def _create_action( raise InvalidActionDescriptionError( message=DUPLICATE_ACTION_MESSAGE ) from create_action_error + + await self._update_action_board() + return action + @classmethod + async def get_user_actions( + cls, + action_user: "discord.Member | discord.User | Iterable[discord.Member | discord.User]", + status: "Iterable[AssignedCommitteeAction.Status]", + ) -> "Mapping[discord.Member | discord.User, Collection[AssignedCommitteeAction]]": + """Get the actions for one or more given users, filtered by one or more statuses.""" + if isinstance(action_user, (discord.User, discord.Member)): + user_actions: Collection[AssignedCommitteeAction] = [ + action + async for action in AssignedCommitteeAction.objects.filter( + raw_status__in=(status_item.value for status_item in status), + discord_member__discord_id=int(action_user.id), + ) + ] + + return {action_user: user_actions} if user_actions else {} + + return { + user: actions + for user in action_user + if ( + actions := [ + action + async for action in AssignedCommitteeAction.objects.filter( + discord_member__discord_id=user.id, + raw_status__in=(status_item.value for status_item in status), + ) + ] + ) + } + + +class CommitteeActionsTrackingRemindersTaskCog(CommitteeActionsTrackingBaseCog): + """Cog class that defines sending committee-actions tracking reminders.""" + + @override + def __init__(self, bot: "TeXBot") -> None: + """Start all task managers when this cog is initialised.""" + if settings["SEND_COMMITTEE_ACTIONS_REMINDERS"]: + _ = self.send_committee_actions_reminders_task.start() + + super().__init__(bot) + + @override + def cog_unload(self) -> None: + """ + Unload-hook that ends all running tasks whenever the tasks cog is unloaded. + + This may be run dynamically or when the bot closes. + """ + self.send_committee_actions_reminders_task.cancel() + + @tasks.loop(**settings["COMMITTEE_ACTIONS_REMINDERS_INTERVAL"]) + @capture_guild_does_not_exist_error + async def send_committee_actions_reminders_task(self) -> None: + """ + Definition of the background task that sends reminders of committee actions. + + The task will run every interval specified in the settings and will send reminders + to all committee members who have actions that are either in progress or not started. + """ + committee_role: discord.Role = await self.bot.committee_role + committee_action_reminders_channel: discord.TextChannel | None = discord.utils.get( + self.bot.main_guild.text_channels, + name=settings["COMMITTEE_ACTIONS_REMINDERS_CHANNEL"], + ) + + if not committee_action_reminders_channel: + logger.warning( + "Committee actions reminders channel could not be found! " + "Actions reminders task will not run until next restart." + ) + self.send_committee_actions_reminders_task.cancel() + return + + all_actions: Mapping[ + discord.Member | discord.User, Collection[AssignedCommitteeAction] + ] = await self.get_user_actions( + action_user=committee_role.members, + status=AssignedCommitteeAction.Status.TODO_FILTER, + ) + + interval_seconds: float = datetime.timedelta( + **settings["COMMITTEE_ACTIONS_REMINDERS_INTERVAL"] + ).total_seconds() + next_reminder_unix = int(time.time() + interval_seconds) + + actions_reminder_info_message: str = ( + f"Wakey wakey committee!\n" + "Here are your actions that are either in progress or not started yet.\n" + f"I'll remind you again " + ) + + all_actions_message: str = "\n".join( + [ + f"\n{committee_member}, Actions:" + f"\n{ + ', \n'.join( + action.status.emoji + f' {action.description} ({action.status.label})' + for action in actions + ) + }" + for committee_member, actions in all_actions.items() + ], + ) + + if not all_actions_message: + logger.info("No actions found for any committee members. No reminders sent.") + return + + await committee_action_reminders_channel.send( + content=f"{actions_reminder_info_message}\n{all_actions_message}", + ) + + @send_committee_actions_reminders_task.before_loop + async def before_tasks(self) -> None: + """Pre-execution hook, preventing any tasks from executing before the bot is ready.""" + await self.bot.wait_until_ready() + class CommitteeActionsTrackingSlashCommandsCog(CommitteeActionsTrackingBaseCog): """Cog class that defines the committee-actions tracking slash commands functionality.""" @@ -147,6 +352,25 @@ async def autocomplete_get_committee_members( if not member.bot } + @staticmethod + async def autocomplete_get_users_with_actions( + ctx: "TeXBotAutocompleteContext", + ) -> "AbstractSet[discord.OptionChoice] | AbstractSet[str]": + """Autocomplete callable that provides a set of users who have actions assigned.""" + discord_users_with_actions: set[discord.User] = { + user + async for action in AssignedCommitteeAction.objects.select_related().all() + if (user := await ctx.bot.get_or_fetch_user(int(action.discord_member.discord_id))) + } + + return { + discord.OptionChoice( + name=f"{user.name}", + value=str(user.id), + ) + for user in discord_users_with_actions + } + @staticmethod async def autocomplete_get_user_action_ids( ctx: "TeXBotAutocompleteContext", @@ -179,11 +403,7 @@ async def autocomplete_get_user_action_ids( return { discord.OptionChoice(name=action.description, value=str(action.id)) async for action in AssignedCommitteeAction.objects.filter( - ( - Q(status=Status.IN_PROGRESS.value) - | Q(status=Status.BLOCKED.value) - | Q(status=Status.NOT_STARTED.value) - ), + Q(AssignedCommitteeAction.Status.TODO_FILTER), discord_member__discord_id=interaction_user.id, ) } @@ -194,7 +414,7 @@ async def autocomplete_get_action_status( ) -> "AbstractSet[discord.OptionChoice] | AbstractSet[str]": """Autocomplete callable that provides the set of possible Status' of actions.""" status_options: Sequence[tuple[str, str]] = AssignedCommitteeAction._meta.get_field( - "status" + "raw_status" ).choices # type: ignore[assignment] if not status_options: @@ -332,6 +552,10 @@ async def update_status( # NOTE: Committee role check is not present because no await action.aupdate(status=new_status) + logger.debug("Action: %s, status updated to: %s", action, action.status) + + await self._update_action_board() + await ctx.respond( content=f"Status for action`{action.description}` updated to `{action.status}`", ephemeral=True, @@ -394,6 +618,8 @@ async def update_description( await action.aupdate(description=new_description) + await self._update_action_board() + await ctx.respond( content=f"Action `{old_description}` updated to `{action.description}`!" ) @@ -520,7 +746,7 @@ async def action_all_committee( name="user", description="The user to list actions for.", input_type=str, - autocomplete=discord.utils.basic_autocomplete(autocomplete_get_committee_members), + autocomplete=discord.utils.basic_autocomplete(autocomplete_get_users_with_actions), required=False, default=None, parameter_name="action_member_id", @@ -539,14 +765,14 @@ async def action_all_committee( autocomplete=discord.utils.basic_autocomplete(autocomplete_get_action_status), required=False, default=None, - parameter_name="status", + parameter_name="raw_status", ) async def list_user_actions( # NOTE: Committee role check is not present because non-committee can have actions, and need to be able to list their own actions. self, ctx: "TeXBotApplicationContext", action_member_id: str | None, ping: bool, # noqa: FBT001 - status: str | None, + raw_status: "None | str", ) -> None: """ Definition and callback of the "/list" command. @@ -582,17 +808,11 @@ async def list_user_actions( # NOTE: Committee role check is not present becaus ) return - user_actions: list[AssignedCommitteeAction] - - if not status: + if not raw_status: user_actions = [ action async for action in AssignedCommitteeAction.objects.filter( - ( - Q(status=Status.IN_PROGRESS.value) - | Q(status=Status.BLOCKED.value) - | Q(status=Status.NOT_STARTED.value) - ), + raw_status__in=AssignedCommitteeAction.Status.TODO_FILTER, discord_member__discord_id=action_member.id, ) ] @@ -600,7 +820,7 @@ async def list_user_actions( # NOTE: Committee role check is not present becaus user_actions = [ action async for action in AssignedCommitteeAction.objects.filter( - status=status, + raw_status=raw_status, discord_member__discord_id=action_member.id, ) ] @@ -608,12 +828,11 @@ async def list_user_actions( # NOTE: Committee role check is not present becaus if not user_actions: await ctx.respond( content=( - ( - f"User: {action_member.mention if ping else action_member} has no " - "in progress actions." - ) - if not status - else "actions matching given filter." + f"User: {action_member.mention if ping else action_member} has no { + 'in progress actions' + if not raw_status + else 'actions matching given filter' + }." ) ) return @@ -623,9 +842,7 @@ async def list_user_actions( # NOTE: Committee role check is not present becaus f"{action_member.mention if ping else action_member}:" f"\n{ '\n'.join( - str(action.description) - + f' ({AssignedCommitteeAction.Status(action.status).label})' - for action in user_actions + f'{action.description} ({action.status.label})' for action in user_actions ) }" ) @@ -754,13 +971,6 @@ async def reassign_action( return @committee_actions.command(name="list-all", description="List all current actions.") - @discord.option( - name="ping", - description="Triggers whether the message pings users or not.", - input_type=bool, - default=False, - required=False, - ) @discord.option( name="status-filter", description="The filter to apply to the status of actions.", @@ -768,56 +978,60 @@ async def reassign_action( autocomplete=discord.utils.basic_autocomplete(autocomplete_get_action_status), required=False, default=None, - parameter_name="status", + parameter_name="raw_status", ) @CommandChecks.check_interaction_user_has_committee_role @CommandChecks.check_interaction_user_in_main_guild async def list_all_actions( self, ctx: "TeXBotApplicationContext", - ping: bool, # noqa: FBT001 - status: str | None, + raw_status: str | None, ) -> None: - """List all actions.""" # NOTE: this doesn't actually list *all* actions as it is possible for non-committee to be actioned. - committee_role: discord.Role = await self.bot.committee_role - - actions: list[AssignedCommitteeAction] = [ - action async for action in AssignedCommitteeAction.objects.select_related().all() - ] - - desired_status: list[str] = ( - [status] - if status - else [Status.NOT_STARTED.value, Status.IN_PROGRESS.value, Status.BLOCKED.value] + """List all actions.""" + filtered_actions: Mapping[ + str, Collection[AssignedCommitteeAction] + ] = await self._get_actions_grouped_by_member_id( + filter_query=Q(raw_status=AssignedCommitteeAction.Status(value=raw_status).value) + if raw_status is not None + else Q(raw_status__in=AssignedCommitteeAction.Status.TODO_FILTER) ) - committee_members: list[discord.Member] = committee_role.members - - committee_actions: dict[discord.Member, list[AssignedCommitteeAction]] = { - committee: [ - action - for action in actions - if str(action.discord_member) == str(committee.id) - and action.status in desired_status - ] - for committee in committee_members - } - - filtered_committee_actions = { - committee: actions for committee, actions in committee_actions.items() if actions - } - - if not filtered_committee_actions: + if not filtered_actions: await ctx.respond(content="No one has any actions that match the request!") - logger.debug("No actions found with the status filter: %s", status) + logger.debug("No actions found with the status filter: %s", raw_status) return all_actions_message: str = "\n".join( - [ - f"\n{committee.mention if ping else committee}, Actions:" - f"\n{', \n'.join(str(action.description) + f' ({AssignedCommitteeAction.Status(action.status).label})' for action in actions)}" # noqa: E501 - for committee, actions in filtered_committee_actions.items() - ], + f"\n<@{discord_id}>, Actions:\n" + f"{ + ', \n'.join( + action.status.emoji + f' {action.description} ({action.status.label})' + for action in actions + if action.discord_member.discord_id == discord_id + ) + }" + for discord_id, actions in filtered_actions.items() + ) + + if len(all_actions_message) < 2000: + await ctx.respond(content=all_actions_message) + return + + await ctx.respond( + content=( + "\n".join( + f"\n<@{discord_id}>, Actions:\n" + f"{ + ', \n'.join( + action.status.emoji + + f' {action.description} ({action.status.label})' + for action in actions + if action.discord_member.discord_id == discord_id + ) + }" + for discord_id, actions in filtered_actions.items() + ) + ) ) if len(all_actions_message) >= 2000: @@ -878,6 +1092,8 @@ async def delete_action(self, ctx: "TeXBotApplicationContext", action_id: str) - await action.adelete() + await self._update_action_board() + await ctx.respond(content=f"Action `{action_description}` successfully deleted.") diff --git a/config.py b/config.py index 572fdea99..c35e964ee 100644 --- a/config.py +++ b/config.py @@ -964,6 +964,137 @@ def _setup_auto_add_committee_to_threads(cls) -> None: raw_auto_add_committee_to_threads in TRUE_VALUES ) + @classmethod + def _setup_send_committee_actions_reminders(cls) -> None: + raw_send_committee_actions_reminders: str = ( + str( + os.getenv("SEND_COMMITTEE_ACTIONS_REMINDERS", "True"), + ) + .strip() + .lower() + ) + + if raw_send_committee_actions_reminders not in TRUE_VALUES | FALSE_VALUES: + INVALID_SEND_COMMITTEE_ACTIONS_REMINDERS_MESSAGE: Final[str] = ( + "SEND_COMMITTEE_ACTIONS_REMINDERS must be a boolean value." + ) + raise ImproperlyConfiguredError(INVALID_SEND_COMMITTEE_ACTIONS_REMINDERS_MESSAGE) + + cls._settings["SEND_COMMITTEE_ACTIONS_REMINDERS"] = ( + raw_send_committee_actions_reminders in TRUE_VALUES + ) + + @classmethod + def _setup_committee_actions_reminders_interval(cls) -> None: + if "SEND_COMMITTEE_ACTIONS_REMINDERS" not in cls._settings: + INVALID_SETUP_ORDER_MESSAGE: Final[str] = ( + "Invalid setup order: SEND_COMMITTEE_ACTIONS_REMINDERS must be set up " + "before COMMITTEE_ACTIONS_REMINDERS_INTERVAL can be set up." + ) + raise RuntimeError(INVALID_SETUP_ORDER_MESSAGE) + + raw_committee_actions_reminders_interval: re.Match[str] | None = re.fullmatch( + r"\A(?:(?P(?:\d*\.)?\d+)s)?(?:(?P(?:\d*\.)?\d+)m)?(?:(?P(?:\d*\.)?\d+)h)?\Z", + str(os.getenv("COMMITTEE_ACTIONS_REMINDERS_INTERVAL", "24h")), + ) + + raw_timedelta_committee_actions_reminders_interval: Mapping[str, float] = { + "hours": 24, + } + + if cls._settings["SEND_COMMITTEE_ACTIONS_REMINDERS"]: + if not raw_committee_actions_reminders_interval: + INVALID_COMMITTEE_ACTIONS_REMINDERS_INTERVAL_MESSAGE: Final[str] = ( + "COMMITTEE_ACTIONS_REMINDERS_INTERVAL must contain the interval " + "in any combination of seconds, minutes or hours." + ) + raise ImproperlyConfiguredError( + INVALID_COMMITTEE_ACTIONS_REMINDERS_INTERVAL_MESSAGE, + ) + + raw_timedelta_committee_actions_reminders_interval = { + key: float(value) + for key, value in ( + raw_committee_actions_reminders_interval.groupdict().items() + ) + if value + } + + cls._settings["COMMITTEE_ACTIONS_REMINDERS_INTERVAL"] = ( + raw_timedelta_committee_actions_reminders_interval + ) + + @classmethod + def _setup_committee_actions_reminders_channel(cls) -> None: + if "SEND_COMMITTEE_ACTIONS_REMINDERS" not in cls._settings: + INVALID_SETUP_ORDER_MESSAGE: Final[str] = ( + "Invalid setup order: SEND_COMMITTEE_ACTIONS_REMINDERS must be set up " + "before COMMITTEE_ACTIONS_REMINDERS_CHANNEL can be set up." + ) + raise RuntimeError(INVALID_SETUP_ORDER_MESSAGE) + + raw_committee_actions_reminders_channel: str = ( + os.getenv("COMMITTEE_ACTIONS_REMINDERS_CHANNEL", "committee-general") + .strip() + .lower() + ) + + if not raw_committee_actions_reminders_channel: + INVALID_COMMITTEE_ACTIONS_REMINDERS_CHANNEL_MESSAGE: Final[str] = ( + "COMMITTEE_ACTIONS_REMINDERS_CHANNEL must be a valid name" + " of a channel in your group's Discord guild." + ) + raise ImproperlyConfiguredError( + INVALID_COMMITTEE_ACTIONS_REMINDERS_CHANNEL_MESSAGE + ) + + cls._settings["COMMITTEE_ACTIONS_REMINDERS_CHANNEL"] = ( + raw_committee_actions_reminders_channel + ) + + @classmethod + def _setup_display_committee_actions_board(cls) -> None: + raw_display_committee_actions_board: str = ( + os.getenv("DISPLAY_COMMITTEE_ACTIONS_BOARD", "True").strip().lower() + ) + + if raw_display_committee_actions_board not in TRUE_VALUES | FALSE_VALUES: + INVALID_DISPLAY_COMMITTEE_ACTIONS_BOARD_MESSAGE: Final[str] = ( + "DISPLAY_COMMITTEE_ACTIONS_BOARD must be a boolean value." + ) + raise ImproperlyConfiguredError(INVALID_DISPLAY_COMMITTEE_ACTIONS_BOARD_MESSAGE) + + cls._settings["DISPLAY_COMMITTEE_ACTIONS_BOARD"] = ( + raw_display_committee_actions_board in TRUE_VALUES + ) + + @classmethod + def _setup_committee_actions_board_channel(cls) -> None: + if "DISPLAY_COMMITTEE_ACTIONS_BOARD" not in cls._settings: + INVALID_SETUP_ORDER_MESSAGE: Final[str] = ( + "Invalid setup order: DISPLAY_COMMITTEE_ACTIONS_BOARD must be set up " + "before COMMITTEE_ACTIONS_BOARD_CHANNEL can be set up." + ) + raise RuntimeError(INVALID_SETUP_ORDER_MESSAGE) + + raw_committee_actions_board_channel: str = ( + os.getenv( + "COMMITTEE_ACTIONS_BOARD_CHANNEL", + "committee-actions-board", + ) + .strip() + .lower() + ) + + if not raw_committee_actions_board_channel: + INVALID_COMMITTEE_ACTIONS_BOARD_CHANNEL_MESSAGE: Final[str] = ( + "COMMITTEE_ACTIONS_BOARD_CHANNEL must be a valid name " + "of a channel in your group's Discord guild." + ) + raise ImproperlyConfiguredError(INVALID_COMMITTEE_ACTIONS_BOARD_CHANNEL_MESSAGE) + + cls._settings["COMMITTEE_ACTIONS_BOARD_CHANNEL"] = raw_committee_actions_board_channel + @classmethod def _setup_env_variables(cls) -> None: """ @@ -1009,6 +1140,11 @@ def _setup_env_variables(cls) -> None: cls._setup_moderation_document_url() cls._setup_strike_performed_manually_warning_location() cls._setup_auto_add_committee_to_threads() + cls._setup_send_committee_actions_reminders() + cls._setup_committee_actions_reminders_channel() + cls._setup_display_committee_actions_board() + cls._setup_committee_actions_board_channel() + cls._setup_committee_actions_reminders_interval() except ImproperlyConfiguredError as improper_config_error: webhook_config_logger.error(improper_config_error.message) # noqa: TRY400 raise improper_config_error from improper_config_error diff --git a/db/core/migrations/0012_rename_status_assignedcommitteeaction_raw_status.py b/db/core/migrations/0012_rename_status_assignedcommitteeaction_raw_status.py new file mode 100644 index 000000000..3679bfa2a --- /dev/null +++ b/db/core/migrations/0012_rename_status_assignedcommitteeaction_raw_status.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.5 on 2025-09-05 00:02 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0007_discordmember_alter_discordmemberstrikes_options_and_more_squashed_0011_alter_assignedcommitteeaction_managers_and_more'), + ] + + operations = [ + migrations.RenameField( + model_name='assignedcommitteeaction', + old_name='status', + new_name='raw_status', + ), + ] diff --git a/db/core/models/__init__.py b/db/core/models/__init__.py index d9be7b405..1b622de89 100644 --- a/db/core/models/__init__.py +++ b/db/core/models/__init__.py @@ -2,7 +2,7 @@ import hashlib import re -from typing import TYPE_CHECKING, override +from typing import TYPE_CHECKING, Self, overload, override import discord from django.core.exceptions import ValidationError @@ -10,13 +10,14 @@ from django.db import models from django.utils.translation import gettext_lazy as _ from django_stubs_ext.db.models import TypedModelMeta +from typed_classproperties import classproperty from .utils import AsyncBaseModel, DiscordMember if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Iterable, Sequence from collections.abc import Set as AbstractSet - from typing import ClassVar, Final + from typing import ClassVar, Final, LiteralString from django.db.models.constraints import BaseConstraint from django_stubs_ext import StrOrPromise @@ -39,13 +40,67 @@ class AssignedCommitteeAction(AsyncBaseModel): """Model to represent an action that has been assigned to a Discord committee-member.""" class Status(models.TextChoices): - """The named status and shortcode associated with the progress of each action.""" + """Enum class to define the possible statuses of an action.""" + + BLOCKED = "BLK", "no_entry", _("Blocked") + CANCELLED = "CND", "wastebasket", _("Cancelled") + COMPLETE = "CMP", "white_check_mark", _("Complete") + IN_PROGRESS = "INP", "yellow_circle", _("In Progress") + NOT_STARTED = "NST", "red_circle", _("Not Started") + + emoji: str + + @overload + def __new__(cls, value: "LiteralString") -> "AssignedCommitteeAction.Status": ... + + @overload + def __new__( + cls, value: "LiteralString", emoji: "LiteralString" + ) -> "AssignedCommitteeAction.Status": ... + + def __new__( # noqa: D102 + cls, value: "LiteralString", emoji: "LiteralString | None" = None + ) -> "AssignedCommitteeAction.Status": + if not emoji: + EMOJI_NOT_PROVIDED_MESSAGE: Final[str] = ( + "The 'emoji' argument must be provided and non-empty." + ) + raise ValueError(EMOJI_NOT_PROVIDED_MESSAGE) + + obj: AssignedCommitteeAction.Status = str.__new__(cls, value) + + obj._value_ = value + obj.emoji = f":{emoji.strip('\r\n\t :')}:" + + return obj + + @classproperty + def TODO_FILTER(cls) -> "AssignedCommitteeAction._StatusCollection": # noqa: N802 + """A collection of Stats enum items that are considered to be 'to-do' statuses.""" + return AssignedCommitteeAction._StatusCollection( + [cls.IN_PROGRESS, cls.BLOCKED, cls.NOT_STARTED] + ) + + class _StatusCollection(tuple[Status]): + """A collection of Status enum items.""" + + __slots__ = () + + @override + def __new__(cls, iterable: "Iterable[AssignedCommitteeAction.Status]", /) -> "Self": + iterable = list(iterable) + if not iterable: + NO_STATUSES_GIVEN_MESSAGE: Final[str] = ( + f"Cannot instantiate {type(Self).__name__} with no 'statuses'." + ) + raise ValueError(NO_STATUSES_GIVEN_MESSAGE) + return super().__new__(cls, iterable) + + def values(self) -> "Sequence[str]": + return [status.value for status in self.__iter__()] - BLOCKED = "BLK", _("Blocked") - CANCELLED = "CND", _("Cancelled") - COMPLETE = "CMP", _("Complete") - IN_PROGRESS = "INP", _("In Progress") - NOT_STARTED = "NST", _("Not Started") + def emojis(self) -> "Sequence[str]": + return [status.emoji for status in self.__iter__()] INSTANCES_NAME_PLURAL: str = "Assigned Committee Actions" @@ -59,10 +114,19 @@ class Status(models.TextChoices): unique=False, ) description = models.TextField(_("Description"), max_length=200, null=False, blank=False) - status = models.CharField( + raw_status = models.CharField( max_length=3, choices=Status, default=Status.NOT_STARTED, null=False, blank=False ) + @property + def status(self) -> Status: + """Return the status of this assigned committee action as a Status enum item.""" + return self.Status(self.raw_status) + + @status.setter + def status(self, value: Status, /) -> None: + self.raw_status = value.value + class Meta(TypedModelMeta): # noqa: D106 verbose_name: "ClassVar[StrOrPromise]" = _("Assigned Committee Action") constraints: "ClassVar[list[BaseConstraint] | tuple[BaseConstraint, ...]]" = ( @@ -79,6 +143,11 @@ def __repr__(self) -> str: def __str__(self) -> str: return f"{self.discord_member}: {self.description}" + @classmethod + @override + def _get_proxy_field_names(cls) -> "AbstractSet[str]": + return {*super()._get_proxy_field_names(), "status"} + class IntroductionReminderOptOutMember(AsyncBaseModel): """ diff --git a/uv.lock b/uv.lock index 1b968b48f..3e19292a8 100644 --- a/uv.lock +++ b/uv.lock @@ -653,31 +653,31 @@ wheels = [ [[package]] name = "numpy" -version = "2.4.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/50/8e/b8041bc719f056afd864478029d52214789341ac6583437b0ee5031e9530/numpy-2.4.5.tar.gz", hash = "sha256:ca670567a5683b7c1670ec03e0ddd5862e10934e92a70751d68d7b7b74ca7f9f", size = 20735669, upload-time = "2026-05-15T20:25:19.492Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/a4/fb50657c7cab297bf34edcd60a074cb0647f61771430d6363575274160fe/numpy-2.4.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1ef248460b645c102026b82337cc4e88231909c66dd77b59ec6d6cac7e44f277", size = 16684760, upload-time = "2026-05-15T20:23:19.436Z" }, - { url = "https://files.pythonhosted.org/packages/3e/43/87e731299b9408eda705b3b9cb31c7bceb9347d2af9cbb16b2b1e4b5bc0f/numpy-2.4.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4603622bdcdbf8dccb1d9d5b21d16a7aa4e473ae6c8e14048d846fd4ca2907a0", size = 14694117, upload-time = "2026-05-15T20:23:21.832Z" }, - { url = "https://files.pythonhosted.org/packages/a9/c7/0b2bb8acea222e9dd6e582afc2bc553b89b8833cbdccc68e68f050fb31f8/numpy-2.4.5-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6c18d49c67689c562854b53fdc433b93e47c12952aa6fa6d59f185e1a5992419", size = 5199141, upload-time = "2026-05-15T20:23:24.066Z" }, - { url = "https://files.pythonhosted.org/packages/39/60/b6972b5d47033d90000f0097c81a98b9486589a2d7003bf725bff275cb0d/numpy-2.4.5-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:b1c663ddc641f4192e90511bec61a09bc231e3bbdb996cdc6edbcaa0e528d685", size = 6546954, upload-time = "2026-05-15T20:23:26.099Z" }, - { url = "https://files.pythonhosted.org/packages/c1/e9/ed667cb12c11ca0adde431f685d3a5dd78e6f78b27228c581c8415198e9e/numpy-2.4.5-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93793222b524f692f12b2f8752ce8b1d9d9125b2bfd5dbf0fb69c92c5e1ce86c", size = 15669430, upload-time = "2026-05-15T20:23:28.147Z" }, - { url = "https://files.pythonhosted.org/packages/44/e5/679f6ffeb01294b0008e5ada4a113cb47617bc0e1819a529fd7973c6d7f4/numpy-2.4.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1616bde34b2bcba2fa9bde06217ce00da4f3d1bdfb264d54525a99e8fe170d83", size = 16633390, upload-time = "2026-05-15T20:23:31.622Z" }, - { url = "https://files.pythonhosted.org/packages/36/46/42bfffc9a780ec902ccd7470d3219192ee82b7b442710307dd85b4d121b0/numpy-2.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:09d7d97da1c2c62f4818b3e150a57572ff8dcf1cf5ac501aac832ffd4ebd9566", size = 17020709, upload-time = "2026-05-15T20:23:34.08Z" }, - { url = "https://files.pythonhosted.org/packages/44/00/3e840bfee0cc6cec22209f2c97057f26eeb30de031e4933b4dfc0395416c/numpy-2.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d68d0b355ab2e39fe0de59001d7151dfdbbb880ef67baeed806661e03df5097", size = 18357818, upload-time = "2026-05-15T20:23:36.965Z" }, - { url = "https://files.pythonhosted.org/packages/72/cb/3447b400b9da84134575486f0f656541559b00d4b262477bce9b678bbca8/numpy-2.4.5-cp313-cp313-win32.whl", hash = "sha256:fe28b64777ddfa0eca9b5f51474034ebe3dcb8324f48f27b28f479085673ae33", size = 5961114, upload-time = "2026-05-15T20:23:39.586Z" }, - { url = "https://files.pythonhosted.org/packages/28/f9/a90d2220ffcdc0798f5d55bb5d5463cd6254ec9ef43f384dae80217d7a2f/numpy-2.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:fb4a6c9c537d6ccec9cc4aeae4261bd3cc79b070c67ddc0646f5b1c07fddde42", size = 12318553, upload-time = "2026-05-15T20:23:41.436Z" }, - { url = "https://files.pythonhosted.org/packages/b8/c9/96f531fb3234545315152d34efdf3de7daee81254448447eb619e8d16967/numpy-2.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:6d7df2da2e7ea0624a43aa368104b3a3ce14aae98ad4bb2c9a93fecef76f1c97", size = 10222200, upload-time = "2026-05-15T20:23:43.681Z" }, - { url = "https://files.pythonhosted.org/packages/e1/f4/a291caab5a3c520babf93ff77c54fd5fdb1ebbc3296cee2eb2146ce773b1/numpy-2.4.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:2a235607a18df941760a695927051af4b1cd5d3ee85840d0e2af816785771feb", size = 14821438, upload-time = "2026-05-15T20:23:45.911Z" }, - { url = "https://files.pythonhosted.org/packages/85/26/13dbb1159b864370568e7309063fd72667984df89db74e9caeb175d067c7/numpy-2.4.5-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:58dcf64969d870f36bc7fbd557d2617e997db7dc06261b6e3327148ea460d0a4", size = 5326663, upload-time = "2026-05-15T20:23:48.18Z" }, - { url = "https://files.pythonhosted.org/packages/7c/99/d233408072a0e019e2288e27edd23f7d572ccd4a73d1539baa3270ede85d/numpy-2.4.5-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:235f54b0156274d8fa3155db3ed6d2f401c7e8f3367c90db0a12f02a58fde6ed", size = 6646874, upload-time = "2026-05-15T20:23:49.856Z" }, - { url = "https://files.pythonhosted.org/packages/c5/00/eeb6f193dfe767725e952e0464f3e51f44145c5dd261cd7389aa36ac0713/numpy-2.4.5-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3b5bb65437a3555c648e706475db01c645559ca80dc8b03e4f202ea757e0d6", size = 15728147, upload-time = "2026-05-15T20:23:51.655Z" }, - { url = "https://files.pythonhosted.org/packages/e5/c9/b8ed039f1fde1b13a8807c893e7e2f9432a379f4d6401edecf0028da5b2c/numpy-2.4.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7f09a7e5f017d7098c66522097c96257411c9620c0926212200d66bc8cee3976", size = 16681770, upload-time = "2026-05-15T20:23:53.933Z" }, - { url = "https://files.pythonhosted.org/packages/11/5b/0198ef6cb7016eca6d895d392106012138127fab23f46637e76d5e25c9f5/numpy-2.4.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:993a88d8fdd8554466a8765cd8bacd97ba56b70ca6b0a04bcdca77f5afed4222", size = 17086218, upload-time = "2026-05-15T20:23:56.646Z" }, - { url = "https://files.pythonhosted.org/packages/f0/fe/8821f3cfc660ae84c92ee158505941874b62c56a42e035a41425228cd8cf/numpy-2.4.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:84f58bed609b5669f5ad3d597901a4f1f86ee5b3c3708aaa55f05b4fe6e0f656", size = 18403542, upload-time = "2026-05-15T20:23:59.173Z" }, - { url = "https://files.pythonhosted.org/packages/0e/00/e64ecaf498865e7b091f57658b2c522503e5d1b70e43b807f5f8247e1d88/numpy-2.4.5-cp313-cp313t-win32.whl", hash = "sha256:7200c58f3f933ca61e66346667dcc8510bb111995e9ce15398a731e6a4afa4bb", size = 6084903, upload-time = "2026-05-15T20:24:01.506Z" }, - { url = "https://files.pythonhosted.org/packages/20/c0/354997dedaf74e8311c2cf9a6027b476fd8d424cb92189cc0ae2b25f501c/numpy-2.4.5-cp313-cp313t-win_amd64.whl", hash = "sha256:c26c71080d35db5002102f5d9ff614d45de02aa1f7802943e691e063e5ee93bc", size = 12458420, upload-time = "2026-05-15T20:24:03.735Z" }, - { url = "https://files.pythonhosted.org/packages/66/dc/917ee5ea4a31ca1a6e4c9a85386477efa318dcc60db257c5ef4adda096c1/numpy-2.4.5-cp313-cp313t-win_arm64.whl", hash = "sha256:2caa576d1707b275cba1aeb60a5c50daa6fa2a3f28ecb08123bc05fd439005db", size = 10291826, upload-time = "2026-05-15T20:24:06.535Z" }, +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, ] [[package]]