From b25792cdbb8ba6177ec2af3fea8663d5a07c3c97 Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Sat, 14 Dec 2024 16:36:01 +0100 Subject: [PATCH 01/16] feat: implement new Subscription object and endpoints --- changelog/1113.feature.rst | 4 +- changelog/1186.feature.rst | 4 +- changelog/1249.feature.rst | 4 +- changelog/1257.feature.rst | 5 ++ disnake/__init__.py | 1 + disnake/entitlement.py | 2 +- disnake/enums.py | 19 ++++++ disnake/http.py | 41 ++++++++++++ disnake/sku.py | 29 +++++++- disnake/subscription.py | 122 ++++++++++++++++++++++++++++++++++ disnake/types/subscription.py | 23 +++++++ docs/api/events.rst | 31 ++++++++- docs/api/subscriptions.rst | 44 ++++++++++++ 13 files changed, 318 insertions(+), 11 deletions(-) create mode 100644 changelog/1257.feature.rst create mode 100644 disnake/subscription.py create mode 100644 disnake/types/subscription.py create mode 100644 docs/api/subscriptions.rst diff --git a/changelog/1113.feature.rst b/changelog/1113.feature.rst index 079ad452e1..e9c1c6b123 100644 --- a/changelog/1113.feature.rst +++ b/changelog/1113.feature.rst @@ -1,5 +1,5 @@ Support application subscriptions and one-time purchases (see the :ddocs:`official docs ` for more info). -- New types: :class:`SKU`, :class:`Entitlement`. +- New types: :class:`SKU`, :class:`Entitlement`, :class:`Subscription`. - New :attr:`Interaction.entitlements` attribute, and :meth:`InteractionResponse.require_premium` response type. -- New events: :func:`on_entitlement_create`, :func:`on_entitlement_update`, :func:`on_entitlement_delete`. +- New events: :func:`on_entitlement_create`, :func:`on_entitlement_update`, :func:`on_entitlement_delete`, :func:`on_subscription_create`, :func:`on_subscription_update` and :func:`on_subscription_delete`. - New :class:`Client` methods: :meth:`~Client.skus`, :meth:`~Client.entitlements`, :meth:`~Client.fetch_entitlement`, :meth:`~Client.create_entitlement`. diff --git a/changelog/1186.feature.rst b/changelog/1186.feature.rst index 079ad452e1..e9c1c6b123 100644 --- a/changelog/1186.feature.rst +++ b/changelog/1186.feature.rst @@ -1,5 +1,5 @@ Support application subscriptions and one-time purchases (see the :ddocs:`official docs ` for more info). -- New types: :class:`SKU`, :class:`Entitlement`. +- New types: :class:`SKU`, :class:`Entitlement`, :class:`Subscription`. - New :attr:`Interaction.entitlements` attribute, and :meth:`InteractionResponse.require_premium` response type. -- New events: :func:`on_entitlement_create`, :func:`on_entitlement_update`, :func:`on_entitlement_delete`. +- New events: :func:`on_entitlement_create`, :func:`on_entitlement_update`, :func:`on_entitlement_delete`, :func:`on_subscription_create`, :func:`on_subscription_update` and :func:`on_subscription_delete`. - New :class:`Client` methods: :meth:`~Client.skus`, :meth:`~Client.entitlements`, :meth:`~Client.fetch_entitlement`, :meth:`~Client.create_entitlement`. diff --git a/changelog/1249.feature.rst b/changelog/1249.feature.rst index 079ad452e1..e9c1c6b123 100644 --- a/changelog/1249.feature.rst +++ b/changelog/1249.feature.rst @@ -1,5 +1,5 @@ Support application subscriptions and one-time purchases (see the :ddocs:`official docs ` for more info). -- New types: :class:`SKU`, :class:`Entitlement`. +- New types: :class:`SKU`, :class:`Entitlement`, :class:`Subscription`. - New :attr:`Interaction.entitlements` attribute, and :meth:`InteractionResponse.require_premium` response type. -- New events: :func:`on_entitlement_create`, :func:`on_entitlement_update`, :func:`on_entitlement_delete`. +- New events: :func:`on_entitlement_create`, :func:`on_entitlement_update`, :func:`on_entitlement_delete`, :func:`on_subscription_create`, :func:`on_subscription_update` and :func:`on_subscription_delete`. - New :class:`Client` methods: :meth:`~Client.skus`, :meth:`~Client.entitlements`, :meth:`~Client.fetch_entitlement`, :meth:`~Client.create_entitlement`. diff --git a/changelog/1257.feature.rst b/changelog/1257.feature.rst new file mode 100644 index 0000000000..e9c1c6b123 --- /dev/null +++ b/changelog/1257.feature.rst @@ -0,0 +1,5 @@ +Support application subscriptions and one-time purchases (see the :ddocs:`official docs ` for more info). +- New types: :class:`SKU`, :class:`Entitlement`, :class:`Subscription`. +- New :attr:`Interaction.entitlements` attribute, and :meth:`InteractionResponse.require_premium` response type. +- New events: :func:`on_entitlement_create`, :func:`on_entitlement_update`, :func:`on_entitlement_delete`, :func:`on_subscription_create`, :func:`on_subscription_update` and :func:`on_subscription_delete`. +- New :class:`Client` methods: :meth:`~Client.skus`, :meth:`~Client.entitlements`, :meth:`~Client.fetch_entitlement`, :meth:`~Client.create_entitlement`. diff --git a/disnake/__init__.py b/disnake/__init__.py index e0af7f3354..58ea3739c1 100644 --- a/disnake/__init__.py +++ b/disnake/__init__.py @@ -65,6 +65,7 @@ from .sku import * from .stage_instance import * from .sticker import * +from .subscription import * from .team import * from .template import * from .threads import * diff --git a/disnake/entitlement.py b/disnake/entitlement.py index b27fff44db..7f5c7aa1a3 100644 --- a/disnake/entitlement.py +++ b/disnake/entitlement.py @@ -76,7 +76,7 @@ class Entitlement(Hashable): Set to ``None`` when this is a test entitlement. ends_at: Optional[:class:`datetime.datetime`] The time at which the entitlement stops being active. - Set to ``None`` when this is a test entitlement. + Set to ``None`` when this is a test entitlement or when this is an indefinite entitlement. You can use :meth:`is_active` to check whether this entitlement is still active. """ diff --git a/disnake/enums.py b/disnake/enums.py index ddbc2c42bb..3caf3341b2 100644 --- a/disnake/enums.py +++ b/disnake/enums.py @@ -72,6 +72,7 @@ "OnboardingPromptType", "SKUType", "EntitlementType", + "SubscriptionStatus", "PollLayoutType", "VoiceChannelEffectAnimationType", "MessageReferenceType", @@ -1322,6 +1323,18 @@ class Event(Enum): entitlement_delete = "entitlement_delete" """Called when a user's entitlement is deleted. Represents the :func:`on_entitlement_delete` event.""" + subscription_create = "subscription_create" + """Called when a subscription for a premium app is created. + Represents the :func:`on_subscription_create` event. + """ + subscription_update = "subscription_update" + """Called when a subscription for a premium app is updated. + Represents the :func:`on_subscription_update` event. + """ + subscription_delete = "subscription_delete" + """Called when a subscription for a premium app is deleted. + Represents the :func:`on_subscription_delete` event. + """ # ext.commands events command = "command" """Called when a command is found and is about to be invoked. @@ -1407,6 +1420,12 @@ class EntitlementType(Enum): application_subscription = 8 +class SubscriptionStatus(Enum): + active = 0 + ending = 1 + inactive = 2 + + class PollLayoutType(Enum): default = 1 diff --git a/disnake/http.py b/disnake/http.py index 89dfe30724..a0fce01a15 100644 --- a/disnake/http.py +++ b/disnake/http.py @@ -74,6 +74,7 @@ role, sku, sticker, + subscription, template, threads, user, @@ -2411,6 +2412,46 @@ def get_entitlement( ) ) + def get_subscriptions( + self, + sku_id: Snowflake, + *, + before: Optional[Snowflake] = None, + after: Optional[Snowflake] = None, + limit: int = 50, + user_id: Optional[Snowflake] = None, + ) -> Response[List[subscription.Subscription]]: + params: Dict[str, Any] = { + "limit": limit, + } + if before is not None: + params["before"] = before + if after is not None: + params["after"] = after + if user_id is not None: + params["user_id"] = user_id + + return self.request( + Route( + "GET", + "/skus/{sku_id}/subscriptions", + sku_id=sku_id, + ), + params=params, + ) + + def get_subscription( + self, sku_id: Snowflake, subscription_id: int + ) -> Response[subscription.Subscription]: + return self.request( + Route( + "GET", + "/skus/{sku_id}/subscriptions/{subscription_id}", + sku_id=sku_id, + subscription_id=subscription_id, + ) + ) + def create_test_entitlement( self, application_id: Snowflake, diff --git a/disnake/sku.py b/disnake/sku.py index 204720d6a3..9440ba99d8 100644 --- a/disnake/sku.py +++ b/disnake/sku.py @@ -8,9 +8,11 @@ from .enums import SKUType, try_enum from .flags import SKUFlags from .mixins import Hashable +from .subscription import Subscription from .utils import snowflake_time if TYPE_CHECKING: + from .state import ConnectionState from .types.sku import SKU as SKUPayload @@ -56,9 +58,10 @@ class SKU(Hashable): The SKU's URL slug, system-generated based on :attr:`name`. """ - __slots__ = ("id", "type", "application_id", "name", "slug", "_flags") + __slots__ = ("_state", "id", "type", "application_id", "name", "slug", "_flags") - def __init__(self, *, data: SKUPayload) -> None: + def __init__(self, *, data: SKUPayload, state: ConnectionState) -> None: + self._state: ConnectionState = state self.id: int = int(data["id"]) self.type: SKUType = try_enum(SKUType, data["type"]) self.application_id: int = int(data["application_id"]) @@ -81,3 +84,25 @@ def created_at(self) -> datetime.datetime: def flags(self) -> SKUFlags: """:class:`SKUFlags`: Returns the SKU's flags.""" return SKUFlags._from_value(self._flags) + + async def subscriptions(self): + """|coro| + + Retrieve all the subscriptions for this SKU. + """ + ... + + async def fetch_subscription(self, subscription_id: int, /) -> Subscription: + """|coro| + + Retrieve a subscription for this SKU given its ID. + + Raises + ------ + NotFound + The subscription does not exist. + HTTPException + Retrieving the subscription failed. + """ + data = await self._state.http.get_subscription(self.id, subscription_id) + return Subscription(data=data, state=self._state) diff --git a/disnake/subscription.py b/disnake/subscription.py new file mode 100644 index 0000000000..dc0f242ab2 --- /dev/null +++ b/disnake/subscription.py @@ -0,0 +1,122 @@ +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +import datetime +from typing import TYPE_CHECKING, List, Optional + +from .enums import SubscriptionStatus, try_enum +from .mixins import Hashable +from .utils import parse_time, snowflake_time + +if TYPE_CHECKING: + from .state import ConnectionState + from .types.subscription import Subscription as SubscriptionPayload + from .user import User + +__all__ = ("Subscription",) + + +class Subscription(Hashable): + """Represents a subscription. + + This can only be retrieved using :meth:`SKU.subscriptions` or :meth:`SKU.fetch_subscription`. + + .. warning:: + :class:`Subscription`\\s should not be used to grant perks. Use :class:`Entitlement`\\s as a way of whether a user should have access to a specific :class:`SKU`. + + .. note:: + Some subscriptions may have been canceled already; consider using :meth:`is_canceled` to check whether a given subscription was canceled. + + .. collapse:: operations + + .. describe:: x == y + + Checks if two :class:`Subscription`\\s are equal. + + .. describe:: x != y + + Checks if two :class:`Subscription`\\s are not equal. + + .. describe:: hash(x) + + Returns the subscription's hash. + + .. versionadded:: 2.10 + + Attributes + ---------- + id: :class:`int` + The subscription's ID. + user_id: :class:`int` + The ID of the user who is subscribed to the :attr:`sku_ids`. + + See also :attr:`user`. + sku_ids: List[:class:`int`] + The ID of the SKUs the user is subscribed to. + renewal_sku_ids: List[:class:`int`] + The ID of the SKUs that will be renewed at the start of the new period. + entitlement_ids: List[:class:`int`] + The ID of the entitlements the user has. + current_period_start: :class:`datetime.datetime` + The time at which the current period for the given subscription started. + current_period_end: :class:`datetime.datetime` + The time at which the current period for the given subscription will end. + status: :class:`SubscriptionStatus` + The current status of the given subscription. + canceled_at: Optional[:class:`datetime.datetime`] + The time at which the subscription was canceled. + + See also :attr:`is_canceled`. + """ + + __slots__ = ( + "_state", + "id", + "user_id", + "sku_ids", + "entitlement_ids", + "renewal_sku_ids", + "current_period_start", + "current_period_end", + "status", + "canceled_at", + ) + + def __init__(self, *, data: SubscriptionPayload, state: ConnectionState) -> None: + self._state: ConnectionState = state + + self.id: int = int(data["id"]) + self.user_id: int = int(data["user_id"]) + self.sku_ids: List[int] = list(map(int, data["sku_ids"])) + self.entitlement_ids: List[int] = list(map(int, data["entitlement_ids"])) + self.renewal_sku_ids: Optional[List[int]] = ( + list(map(int, data["renewal_sku_ids"])) if data["renewal_sku_ids"] else None + ) + self.current_period_start: datetime.datetime = parse_time(data["current_period_start"]) + self.current_period_end: datetime.datetime = parse_time(data["current_period_end"]) + self.status: SubscriptionStatus = try_enum(SubscriptionStatus, data["status"]) + self.canceled_at: Optional[datetime.datetime] = parse_time(data["canceled_at"]) + + @property + def created_at(self) -> datetime.datetime: + """:class:`datetime.datetime`: Returns the subscription's creation time in UTC.""" + return snowflake_time(self.id) + + @property + def user(self) -> Optional[User]: + """Optional[:class:`User`]: The user who is subscribed to the :attr:`sku_ids`. + + Requires the user to be cached. + See also :attr:`user_id`. + """ + return self._state.get_user(self.user_id) + + @property + def is_canceled(self) -> bool: + """:class:`bool`: Whether the subscription was canceled, + based on :attr:`canceled_at`. + """ + if self.canceled_at is None: + return False + return True diff --git a/disnake/types/subscription.py b/disnake/types/subscription.py new file mode 100644 index 0000000000..1cfaaf8684 --- /dev/null +++ b/disnake/types/subscription.py @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: MIT + +from typing import List, Literal, Optional, TypedDict + +from typing_extensions import NotRequired + +from .snowflake import Snowflake + +SubscriptionStatus = Literal[0, 1, 2] + + +class Subscription(TypedDict): + id: Snowflake + user_id: Snowflake + sku_ids: List[Snowflake] + entitlement_ids: List[Snowflake] + renewal_sku_ids: Optional[List[Snowflake]] + current_period_start: str + current_period_end: str + status: SubscriptionStatus + canceled_at: Optional[str] + # this is always missing unless queried with a private OAuth scope. + country: NotRequired[str] diff --git a/docs/api/events.rst b/docs/api/events.rst index c5683e456c..c3df1cfb24 100644 --- a/docs/api/events.rst +++ b/docs/api/events.rst @@ -1562,8 +1562,8 @@ This section documents events related to entitlements, which are used for applic Called when an entitlement is updated. - This happens e.g. when a user's subscription gets renewed (in which case the - :attr:`Entitlement.ends_at` attribute reflects the new expiration date). + This happens **only** when a user's subscription ends or is cancelled (in which case the + :attr:`Entitlement.ends_at` attribute reflects the expiration date). .. versionadded:: 2.10 @@ -1583,6 +1583,33 @@ This section documents events related to entitlements, which are used for applic :param entitlement: The entitlement that was deleted. :type entitlement: :class:`Entitlement` +.. function:: on_subscription_create(subscription) + + Called when a subscription is created. + + .. versionadded:: 2.10 + + :param subscription: The subscription that was created. + :type subscription: :class:`Subscription` + +.. function:: on_subscription_update(subscription) + + Called when a subscription is updated. + + .. versionadded:: 2.10 + + :param subscription: The subscription that was updated. + :type subscription: :class:`Subscription` + +.. function:: on_subscription_delete(subscription) + + Called when a subscription is deleted. + + .. versionadded:: 2.10 + + :param subscription: The subscription that was deleted. + :type subscription: :class:`Subscription` + Enumerations ------------ diff --git a/docs/api/subscriptions.rst b/docs/api/subscriptions.rst new file mode 100644 index 0000000000..e9e8eafbeb --- /dev/null +++ b/docs/api/subscriptions.rst @@ -0,0 +1,44 @@ +.. SPDX-License-Identifier: MIT + +.. currentmodule:: disnake + +Subscription(s) +=============== + +This section documents everything related to Subscription(s), which represents a user making recurring payments for at least one SKU. +See the :ddocs:`official docs ` for more info. + +Discord Models +-------------- + +Subscription +~~~~~~~~~~~~ + +.. attributetable:: Subscription + +.. autoclass:: Subscription() + :members: + +Enumerations +------------ + +SubscriptionStatus +~~~~~~~~~~~~~~~~~~ + +.. class:: SubscriptionStatus + + Represents the status of a subscription. + + .. versionadded:: 2.10 + + .. attribute:: active + + Represents an active Subscription which is scheduled to renew. + + .. attribute:: ending + + Represents an active Subscription which will not renew. + + .. attribute:: inactive + + Represents an inactive Subscription which is not being charged. From 979d11648a14c2b6cb6e78f2cd336f2f1b25f500 Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Sat, 14 Dec 2024 23:57:46 +0100 Subject: [PATCH 02/16] fix: pass state when building SKU objects --- disnake/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/disnake/client.py b/disnake/client.py index 990e19c201..9c7a4cf6be 100644 --- a/disnake/client.py +++ b/disnake/client.py @@ -3135,7 +3135,7 @@ async def skus(self) -> List[SKU]: The list of SKUs. """ data = await self.http.get_skus(self.application_id) - return [SKU(data=d) for d in data] + return [SKU(data=d, state=self._connection) for d in data] def entitlements( self, From 66823efcbcf2d368b7c71661e7437a144a07d799 Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Mon, 16 Dec 2024 10:41:04 +0100 Subject: [PATCH 03/16] feat: add SubscriptionIterator --- disnake/iterators.py | 100 +++++++++++++++++++++++++++++++++++++++++++ disnake/sku.py | 54 +++++++++++++++++++++-- 2 files changed, 150 insertions(+), 4 deletions(-) diff --git a/disnake/iterators.py b/disnake/iterators.py index 86c311e39f..27e0dbe4fe 100644 --- a/disnake/iterators.py +++ b/disnake/iterators.py @@ -27,6 +27,7 @@ from .guild_scheduled_event import GuildScheduledEvent from .integrations import PartialIntegration from .object import Object +from .subscription import Subscription from .threads import Thread from .utils import maybe_coroutine, snowflake_time, time_snowflake @@ -39,6 +40,7 @@ "MemberIterator", "GuildScheduledEventUserIterator", "EntitlementIterator", + "SubscriptionIterator", "PollAnswerIterator", ) @@ -60,6 +62,7 @@ GuildScheduledEventUser as GuildScheduledEventUserPayload, ) from .types.message import Message as MessagePayload + from .types.subscription import Subscription as SubscriptionPayload from .types.threads import Thread as ThreadPayload from .types.user import PartialUser as PartialUserPayload from .user import User @@ -1147,6 +1150,103 @@ async def _after_strategy(self, retrieve: int) -> List[EntitlementPayload]: return data +class SubscriptionIterator(_AsyncIterator["Subscription"]): + def __init__( + self, + sku_id: int, + *, + user_id: Optional[int] = None, + state: ConnectionState, + limit: Optional[int] = None, + before: Optional[Union[Snowflake, datetime.datetime]] = None, + after: Optional[Union[Snowflake, datetime.datetime]] = None, + ) -> None: + if isinstance(before, datetime.datetime): + before = Object(id=time_snowflake(before, high=False)) + if isinstance(after, datetime.datetime): + after = Object(id=time_snowflake(after, high=True)) + + self.sku_id: int = sku_id + self.user_id: Optional[int] = user_id + self.limit: Optional[int] = limit + self.before: Optional[Snowflake] = before + self.after: Snowflake = after or OLDEST_OBJECT + + self._state: ConnectionState = state + self.request = self._state.http.get_subscriptions + self.subscriptions: asyncio.Queue[Subscription] = asyncio.Queue() + + self._filter: Optional[Callable[[SubscriptionPayload], bool]] = None + if self.before: + self._strategy = self._before_strategy + if self.after != OLDEST_OBJECT: + self._filter = lambda s: int(s["id"]) > self.after.id + else: + self._strategy = self._after_strategy + + async def next(self) -> Subscription: + if self.subscriptions.empty(): + await self._fill() + + try: + return self.subscriptions.get_nowait() + except asyncio.QueueEmpty: + raise NoMoreItems from None + + def _get_retrieve(self) -> bool: + limit = self.limit + if limit is None or limit > 100: + retrieve = 100 + else: + retrieve = limit + self.retrieve: int = retrieve + return retrieve > 0 + + async def _fill(self) -> None: + if not self._get_retrieve(): + return + + data = await self._strategy(self.retrieve) + if len(data) < 100: + self.limit = 0 # terminate loop + + if self._filter: + data = filter(self._filter, data) + + for subscription in data: + await self.subscriptions.put(Subscription(data=subscription, state=self._state)) + + async def _before_strategy(self, retrieve: int) -> List[SubscriptionPayload]: + before = self.before.id if self.before else None + data = await self.request( + self.sku_id, + before=before, + limit=retrieve, + user_id=self.user_id, + ) + + if len(data): + if self.limit is not None: + self.limit -= retrieve + self.before = Object(id=int(data[-1]["id"])) + return data + + async def _after_strategy(self, retrieve: int) -> List[SubscriptionPayload]: + after = self.after.id + data = await self.request( + self.sku_id, + after=after, + limit=retrieve, + user_id=self.user_id, + ) + + if len(data): + if self.limit is not None: + self.limit -= retrieve + self.after = Object(id=int(data[-1]["id"])) + return data + + class PollAnswerIterator(_AsyncIterator[Union["User", "Member"]]): def __init__( self, diff --git a/disnake/sku.py b/disnake/sku.py index 9440ba99d8..4965417d77 100644 --- a/disnake/sku.py +++ b/disnake/sku.py @@ -3,15 +3,17 @@ from __future__ import annotations import datetime -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from .enums import SKUType, try_enum from .flags import SKUFlags +from .iterators import SubscriptionIterator from .mixins import Hashable from .subscription import Subscription from .utils import snowflake_time if TYPE_CHECKING: + from .abc import Snowflake, SnowflakeTime from .state import ConnectionState from .types.sku import SKU as SKUPayload @@ -85,12 +87,56 @@ def flags(self) -> SKUFlags: """:class:`SKUFlags`: Returns the SKU's flags.""" return SKUFlags._from_value(self._flags) - async def subscriptions(self): + async def subscriptions( + self, + *, + limit: Optional[int] = 50, + before: Optional[SnowflakeTime] = None, + after: Optional[SnowflakeTime] = None, + user: Optional[Snowflake] = None, + ) -> SubscriptionIterator: """|coro| - Retrieve all the subscriptions for this SKU. + Retrieves an :class:`.AsyncIterator` that enabled receiving subscriptions for the SKU. + + All parameters are optional. + + Parameters + ---------- + limit: Optional[:class:`int`] + The number of subscriptions to retrieve. + If ``None``, retrieves every subscription. + Note, however, that this would make it a slow operation. + Defaults to ``50``. + before: Union[:class:`.abc.Snowflake`, :class:`datetime.datetime`] + Retrieves subscriptions created before this date or object. + If a datetime is provided, it is recommended to use a UTC aware datetime. + If the datetime is naive, it is assumed to be local time. + after: Union[:class:`.abc.Snowflake`, :class:`datetime.datetime`] + Retrieve subscriptions created after this date or object. + If a datetime is provided, it is recommended to use a UTC aware datetime. + If the datetime is naive, it is assumed to be local time. + user: Optional[:class:`.abc.Snowflake`] + The user to retrieve subscriptions for. + + Raises + ------ + HTTPException + Retrieving the subscriptions failed. + + Yields + ------ + :class:`.Subscription` + The subscriptions for the given parameters. """ - ... + return SubscriptionIterator( + self.id, + state=self._state, + limit=limit, + before=before, + after=after, + user_id=user.id if user is not None else None, + ) async def fetch_subscription(self, subscription_id: int, /) -> Subscription: """|coro| From e43cd5dd9b098249d88eb1f7093ec5074b52f011 Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Mon, 16 Dec 2024 10:43:28 +0100 Subject: [PATCH 04/16] feat: include subscriptions in the toctree --- docs/api/index.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/api/index.rst b/docs/api/index.rst index 8b1dea42da..c23dc632d0 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -116,6 +116,7 @@ Documents permissions roles skus + subscriptions stage_instances stickers users From e34df7c9fadb7d40d626c4282503a68a54469ef6 Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Mon, 16 Dec 2024 10:46:40 +0100 Subject: [PATCH 05/16] fix toctree name --- docs/api/subscriptions.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/subscriptions.rst b/docs/api/subscriptions.rst index e9e8eafbeb..338cb9e786 100644 --- a/docs/api/subscriptions.rst +++ b/docs/api/subscriptions.rst @@ -2,7 +2,7 @@ .. currentmodule:: disnake -Subscription(s) +Subscriptions =============== This section documents everything related to Subscription(s), which represents a user making recurring payments for at least one SKU. From 2c46b9610f0804c6b1bc7126cb29b6bd159c391e Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Sat, 21 Dec 2024 23:20:09 +0100 Subject: [PATCH 06/16] Update disnake/subscription.py Co-authored-by: vi <8530778+shiftinv@users.noreply.github.com> Signed-off-by: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> --- disnake/subscription.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/disnake/subscription.py b/disnake/subscription.py index dc0f242ab2..5623106e6f 100644 --- a/disnake/subscription.py +++ b/disnake/subscription.py @@ -91,7 +91,9 @@ def __init__(self, *, data: SubscriptionPayload, state: ConnectionState) -> None self.sku_ids: List[int] = list(map(int, data["sku_ids"])) self.entitlement_ids: List[int] = list(map(int, data["entitlement_ids"])) self.renewal_sku_ids: Optional[List[int]] = ( - list(map(int, data["renewal_sku_ids"])) if data["renewal_sku_ids"] else None + list(map(int, renewal_sku_ids)) + if (renewal_sku_ids := data.get("renewal_sku_ids")) is not None + else None ) self.current_period_start: datetime.datetime = parse_time(data["current_period_start"]) self.current_period_end: datetime.datetime = parse_time(data["current_period_end"]) From a84f1ea93da6fc821c8f243a623d87be444de18d Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Sat, 21 Dec 2024 23:21:09 +0100 Subject: [PATCH 07/16] Update disnake/subscription.py Co-authored-by: vi <8530778+shiftinv@users.noreply.github.com> Signed-off-by: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> --- disnake/subscription.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/disnake/subscription.py b/disnake/subscription.py index 5623106e6f..7226418692 100644 --- a/disnake/subscription.py +++ b/disnake/subscription.py @@ -20,7 +20,8 @@ class Subscription(Hashable): """Represents a subscription. - This can only be retrieved using :meth:`SKU.subscriptions` or :meth:`SKU.fetch_subscription`. + This can only be retrieved using :meth:`SKU.subscriptions` or :meth:`SKU.fetch_subscription`, + or provided by events (e.g. :func:`on_subscription_create`). .. warning:: :class:`Subscription`\\s should not be used to grant perks. Use :class:`Entitlement`\\s as a way of whether a user should have access to a specific :class:`SKU`. From 187dd8f3c325de2303195858aa8fd63580da3ada Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Sat, 21 Dec 2024 23:21:35 +0100 Subject: [PATCH 08/16] Update disnake/subscription.py Co-authored-by: vi <8530778+shiftinv@users.noreply.github.com> Signed-off-by: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> --- disnake/subscription.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/disnake/subscription.py b/disnake/subscription.py index 7226418692..7a7e6bd27c 100644 --- a/disnake/subscription.py +++ b/disnake/subscription.py @@ -24,7 +24,7 @@ class Subscription(Hashable): or provided by events (e.g. :func:`on_subscription_create`). .. warning:: - :class:`Subscription`\\s should not be used to grant perks. Use :class:`Entitlement`\\s as a way of whether a user should have access to a specific :class:`SKU`. + :class:`Subscription`\\s should not be used to grant perks. Use :class:`Entitlement`\\s as a way of determining whether a user should have access to a specific :class:`SKU`. .. note:: Some subscriptions may have been canceled already; consider using :meth:`is_canceled` to check whether a given subscription was canceled. From 5699c7190803015110b56dc75421f2786f5192d2 Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Sat, 21 Dec 2024 23:22:00 +0100 Subject: [PATCH 09/16] Update disnake/subscription.py Co-authored-by: vi <8530778+shiftinv@users.noreply.github.com> Signed-off-by: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> --- disnake/subscription.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/disnake/subscription.py b/disnake/subscription.py index 7a7e6bd27c..30a9a35cf6 100644 --- a/disnake/subscription.py +++ b/disnake/subscription.py @@ -58,7 +58,7 @@ class Subscription(Hashable): renewal_sku_ids: List[:class:`int`] The ID of the SKUs that will be renewed at the start of the new period. entitlement_ids: List[:class:`int`] - The ID of the entitlements the user has. + The IDs of the entitlements the user has as part of this subscription. current_period_start: :class:`datetime.datetime` The time at which the current period for the given subscription started. current_period_end: :class:`datetime.datetime` From d4e92a1a7a13b2b91bd55ad143a358b5b044b407 Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Sat, 21 Dec 2024 23:22:30 +0100 Subject: [PATCH 10/16] Update disnake/subscription.py Co-authored-by: vi <8530778+shiftinv@users.noreply.github.com> Signed-off-by: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> --- disnake/subscription.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/disnake/subscription.py b/disnake/subscription.py index 30a9a35cf6..ec471c5299 100644 --- a/disnake/subscription.py +++ b/disnake/subscription.py @@ -56,7 +56,7 @@ class Subscription(Hashable): sku_ids: List[:class:`int`] The ID of the SKUs the user is subscribed to. renewal_sku_ids: List[:class:`int`] - The ID of the SKUs that will be renewed at the start of the new period. + The IDs of the SKUs that will be renewed at the start of the new period. entitlement_ids: List[:class:`int`] The IDs of the entitlements the user has as part of this subscription. current_period_start: :class:`datetime.datetime` From b3c92a6c1522b0e8c90cefa30c3eb7e405053f4e Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Sat, 21 Dec 2024 23:22:51 +0100 Subject: [PATCH 11/16] Update disnake/subscription.py Co-authored-by: vi <8530778+shiftinv@users.noreply.github.com> Signed-off-by: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> --- disnake/subscription.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/disnake/subscription.py b/disnake/subscription.py index ec471c5299..bf86e1a35a 100644 --- a/disnake/subscription.py +++ b/disnake/subscription.py @@ -120,6 +120,4 @@ def is_canceled(self) -> bool: """:class:`bool`: Whether the subscription was canceled, based on :attr:`canceled_at`. """ - if self.canceled_at is None: - return False - return True + return self.canceled_at is not None From d7bea2d64115eaf1c08c913717a0e4c18acce343 Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Wed, 25 Dec 2024 16:31:56 +0100 Subject: [PATCH 12/16] Update disnake/enums.py Co-authored-by: vi <8530778+shiftinv@users.noreply.github.com> Signed-off-by: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> --- disnake/enums.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/disnake/enums.py b/disnake/enums.py index 3caf3341b2..220c4f5def 100644 --- a/disnake/enums.py +++ b/disnake/enums.py @@ -1326,14 +1326,20 @@ class Event(Enum): subscription_create = "subscription_create" """Called when a subscription for a premium app is created. Represents the :func:`on_subscription_create` event. + + .. versionadded:: 2.10 """ subscription_update = "subscription_update" """Called when a subscription for a premium app is updated. Represents the :func:`on_subscription_update` event. + + .. versionadded:: 2.10 """ subscription_delete = "subscription_delete" """Called when a subscription for a premium app is deleted. Represents the :func:`on_subscription_delete` event. + + .. versionadded:: 2.10 """ # ext.commands events command = "command" From 7770e2d094c982fa30ba275dfe9e5b420cc612e8 Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Wed, 25 Dec 2024 16:33:42 +0100 Subject: [PATCH 13/16] Update disnake/entitlement.py Co-authored-by: vi <8530778+shiftinv@users.noreply.github.com> Signed-off-by: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> --- disnake/entitlement.py | 1 - 1 file changed, 1 deletion(-) diff --git a/disnake/entitlement.py b/disnake/entitlement.py index 7f5c7aa1a3..8dfda2c745 100644 --- a/disnake/entitlement.py +++ b/disnake/entitlement.py @@ -76,7 +76,6 @@ class Entitlement(Hashable): Set to ``None`` when this is a test entitlement. ends_at: Optional[:class:`datetime.datetime`] The time at which the entitlement stops being active. - Set to ``None`` when this is a test entitlement or when this is an indefinite entitlement. You can use :meth:`is_active` to check whether this entitlement is still active. """ From 7fc659a3f819d666594bc273583e004c5dc8785d Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Thu, 26 Dec 2024 22:43:25 +0100 Subject: [PATCH 14/16] make user required to fetch subscriptions --- disnake/sku.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/disnake/sku.py b/disnake/sku.py index 4965417d77..6c6176c1b7 100644 --- a/disnake/sku.py +++ b/disnake/sku.py @@ -13,7 +13,7 @@ from .utils import snowflake_time if TYPE_CHECKING: - from .abc import Snowflake, SnowflakeTime + from .abc import SnowflakeTime from .state import ConnectionState from .types.sku import SKU as SKUPayload @@ -89,11 +89,11 @@ def flags(self) -> SKUFlags: async def subscriptions( self, + user_id: int, *, limit: Optional[int] = 50, before: Optional[SnowflakeTime] = None, after: Optional[SnowflakeTime] = None, - user: Optional[Snowflake] = None, ) -> SubscriptionIterator: """|coro| @@ -103,6 +103,8 @@ async def subscriptions( Parameters ---------- + user: :class:`int` + The user to retrieve subscriptions for. limit: Optional[:class:`int`] The number of subscriptions to retrieve. If ``None``, retrieves every subscription. @@ -116,8 +118,6 @@ async def subscriptions( Retrieve subscriptions created after this date or object. If a datetime is provided, it is recommended to use a UTC aware datetime. If the datetime is naive, it is assumed to be local time. - user: Optional[:class:`.abc.Snowflake`] - The user to retrieve subscriptions for. Raises ------ @@ -135,7 +135,7 @@ async def subscriptions( limit=limit, before=before, after=after, - user_id=user.id if user is not None else None, + user_id=user_id, ) async def fetch_subscription(self, subscription_id: int, /) -> Subscription: From 6a7a9053bded3ca590b1cce69b969ac3c96af6d5 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sun, 29 Dec 2024 12:13:13 +0100 Subject: [PATCH 15/16] fix: make `SubscriptionIterator` order-agnostic for now --- disnake/iterators.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/disnake/iterators.py b/disnake/iterators.py index 27e0dbe4fe..5627f08b9e 100644 --- a/disnake/iterators.py +++ b/disnake/iterators.py @@ -1228,7 +1228,8 @@ async def _before_strategy(self, retrieve: int) -> List[SubscriptionPayload]: if len(data): if self.limit is not None: self.limit -= retrieve - self.before = Object(id=int(data[-1]["id"])) + # since pagination order isn't documented, don't rely on results being sorted one way or the other + self.before = Object(id=min(int(data[0]["id"]), int(data[-1]["id"]))) return data async def _after_strategy(self, retrieve: int) -> List[SubscriptionPayload]: @@ -1243,7 +1244,7 @@ async def _after_strategy(self, retrieve: int) -> List[SubscriptionPayload]: if len(data): if self.limit is not None: self.limit -= retrieve - self.after = Object(id=int(data[-1]["id"])) + self.after = Object(id=max(int(data[0]["id"]), int(data[-1]["id"]))) return data From 7f5ea527378ba6b194e6a710981bd4ec379adfa8 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sun, 29 Dec 2024 12:18:20 +0100 Subject: [PATCH 16/16] refactor: `SKU.subscriptions#user` should take a snowflake, not int --- disnake/iterators.py | 2 +- disnake/sku.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/disnake/iterators.py b/disnake/iterators.py index 5627f08b9e..847041b7ac 100644 --- a/disnake/iterators.py +++ b/disnake/iterators.py @@ -1155,8 +1155,8 @@ def __init__( self, sku_id: int, *, - user_id: Optional[int] = None, state: ConnectionState, + user_id: Optional[int] = None, # required, except for oauth queries limit: Optional[int] = None, before: Optional[Union[Snowflake, datetime.datetime]] = None, after: Optional[Union[Snowflake, datetime.datetime]] = None, diff --git a/disnake/sku.py b/disnake/sku.py index 6c6176c1b7..3b9e4fabb3 100644 --- a/disnake/sku.py +++ b/disnake/sku.py @@ -13,7 +13,7 @@ from .utils import snowflake_time if TYPE_CHECKING: - from .abc import SnowflakeTime + from .abc import Snowflake, SnowflakeTime from .state import ConnectionState from .types.sku import SKU as SKUPayload @@ -89,7 +89,7 @@ def flags(self) -> SKUFlags: async def subscriptions( self, - user_id: int, + user: Snowflake, *, limit: Optional[int] = 50, before: Optional[SnowflakeTime] = None, @@ -97,13 +97,13 @@ async def subscriptions( ) -> SubscriptionIterator: """|coro| - Retrieves an :class:`.AsyncIterator` that enabled receiving subscriptions for the SKU. + Retrieves an :class:`.AsyncIterator` that enables receiving subscriptions for the SKU. - All parameters are optional. + All parameters, except ``user``, are optional. Parameters ---------- - user: :class:`int` + user: :class:`abc.Snowflake` The user to retrieve subscriptions for. limit: Optional[:class:`int`] The number of subscriptions to retrieve. @@ -132,10 +132,10 @@ async def subscriptions( return SubscriptionIterator( self.id, state=self._state, + user_id=user.id, limit=limit, before=before, after=after, - user_id=user_id, ) async def fetch_subscription(self, subscription_id: int, /) -> Subscription: