diff --git a/CHANGES.rst b/CHANGES.rst index d11268a5c..ed9885c06 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -9,6 +9,7 @@ Unreleased * Ability to submit image galleries with :meth:`.submit_gallery`. * Ability to pass a gallery url to :meth:`.Reddit.submission`. * Ability to specify modmail mute duration. +* Add method :meth:`.invited` to get invited moderators of a subreddit. * Added :meth:`.Reddit.close` to close the requestor session. * Ability to use :class:`.Reddit` as an asynchronous context manager that automatically closes the requestor session on exit. diff --git a/asyncpraw/endpoints.py b/asyncpraw/endpoints.py index 359a49add..9ee90b2c2 100644 --- a/asyncpraw/endpoints.py +++ b/asyncpraw/endpoints.py @@ -72,6 +72,7 @@ "list_banned": "r/{subreddit}/about/banned/", "list_contributor": "r/{subreddit}/about/contributors/", "list_moderator": "r/{subreddit}/about/moderators/", + "list_invited_moderator": "/api/v1/{subreddit}/moderators_invited", "list_muted": "r/{subreddit}/about/muted/", "list_wikibanned": "r/{subreddit}/about/wikibanned/", "list_wikicontributor": "r/{subreddit}/about/wikicontributors/", diff --git a/asyncpraw/models/__init__.py b/asyncpraw/models/__init__.py index 474d1cd2b..4bdbcaeac 100644 --- a/asyncpraw/models/__init__.py +++ b/asyncpraw/models/__init__.py @@ -7,7 +7,7 @@ from .list.trophy import TrophyList from .listing.domain import DomainListing from .listing.generator import ListingGenerator -from .listing.listing import Listing +from .listing.listing import Listing, ModeratorListing from .mod_action import ModAction from .preferences import Preferences from .reddit.collections import Collection diff --git a/asyncpraw/models/listing/listing.py b/asyncpraw/models/listing/listing.py index ca7cfe179..db67955c6 100644 --- a/asyncpraw/models/listing/listing.py +++ b/asyncpraw/models/listing/listing.py @@ -33,3 +33,9 @@ class FlairListing(Listing): def after(self) -> Optional[Any]: """Return the next attribute or None.""" return getattr(self, "next", None) + + +class ModeratorListing(Listing): + """Special Listing for handling moderator lists.""" + + CHILD_ATTRIBUTE = "moderators" diff --git a/asyncpraw/models/reddit/subreddit.py b/asyncpraw/models/reddit/subreddit.py index e064e4205..ce533837f 100644 --- a/asyncpraw/models/reddit/subreddit.py +++ b/asyncpraw/models/reddit/subreddit.py @@ -2887,6 +2887,36 @@ async def invite(self, redditor, permissions=None, **other_settings): url = API_PATH["friend"].format(subreddit=self.subreddit) await self.subreddit._reddit.post(url, data=data) + def invited(self, redditor=None, **generator_kwargs): + """Return a :class:`.ListingGenerator` for Redditors invited to be moderators. + + :param redditor: When provided, return a list containing at most one + :class:`~.Redditor` instance. This is useful to confirm if a relationship + exists, or to fetch the metadata associated with a particular relationship + (default: None). + + Additional keyword arguments are passed in the initialization of + :class:`.ListingGenerator`. + + .. note:: + + Unlike other usages of :class:`.ListingGenerator`, ``limit`` has no effect + in the quantity returned. This endpoint always returns moderators in batches + of 25 at a time regardless of what ``limit`` is set to. + + Usage: + + .. code-block:: python + + subreddit = await reddit.subreddit("NAME") + async for invited_mod in subreddit.moderator.invited(): + print(invited_mod) + + """ + generator_kwargs["params"] = {"username": redditor} if redditor else None + url = API_PATH["list_invited_moderator"].format(subreddit=self.subreddit) + return ListingGenerator(self.subreddit._reddit, url, **generator_kwargs) + async def leave(self): """Abdicate the moderator position (use with care). diff --git a/asyncpraw/objector.py b/asyncpraw/objector.py index 642e015b5..7d962ea29 100644 --- a/asyncpraw/objector.py +++ b/asyncpraw/objector.py @@ -118,6 +118,20 @@ def _objectify_dict(self, data): parser = self.parsers[self._reddit.config.kinds["comment"]] elif "collection_id" in data.keys(): parser = self.parsers["Collection"] + elif {"moderators", "moderatorIds", "allUsersLoaded", "subredditId"}.issubset( + data + ): + data = snake_case_keys(data) + moderators = [] + for mod_id in data["moderator_ids"]: + mod = snake_case_keys(data["moderators"][mod_id]) + mod["mod_permissions"] = list(mod["mod_permissions"].keys()) + moderators.append(mod) + data["moderators"] = moderators + parser = self.parsers["moderator-list"] + elif "username" in data.keys(): + data["name"] = data.pop("username") + parser = self.parsers[self._reddit.config.kinds["redditor"]] else: if "user" in data: parser = self.parsers[self._reddit.config.kinds["redditor"]] diff --git a/asyncpraw/reddit.py b/asyncpraw/reddit.py index a77769c43..17e5ab7e7 100644 --- a/asyncpraw/reddit.py +++ b/asyncpraw/reddit.py @@ -436,6 +436,7 @@ def _prepare_objector(self): "image": models.ImageWidget, "menu": models.Menu, "modaction": models.ModAction, + "moderator-list": models.ModeratorListing, "moderators": models.ModeratorsWidget, "more": models.MoreComments, "post-flair": models.PostFlairWidget, diff --git a/tests/integration/cassettes/TestSubredditRelationships.test_moderator_invited_moderators.json b/tests/integration/cassettes/TestSubredditRelationships.test_moderator_invited_moderators.json new file mode 100644 index 000000000..8ba9106f1 --- /dev/null +++ b/tests/integration/cassettes/TestSubredditRelationships.test_moderator_invited_moderators.json @@ -0,0 +1,111 @@ +{ + "interactions": [ + { + "request": { + "body": [ + [ + "grant_type", + "refresh_token" + ], + [ + "refresh_token", + "" + ] + ], + "headers": { + "AUTHORIZATION": [ + "Basic " + ], + "Accept-Encoding": [ + "identity" + ], + "Connection": [ + "close" + ], + "User-Agent": [ + " Async PRAW/7.1.1.dev0 asyncprawcore/1.5.0" + ] + }, + "method": "POST", + "uri": "https://www.reddit.com/api/v1/access_token" + }, + "response": { + "body": { + "string": "{\"access_token\": \"\", \"token_type\": \"bearer\", \"expires_in\": 3600, \"scope\": \"account creddits edit flair history identity livemanage modconfig modcontributors modflair modlog modmail modothers modposts modself modtraffic modwiki mysubreddits privatemessages read report save structuredstyles submit subscribe vote wikiedit wikiread\"}" + }, + "headers": { + "Accept-Ranges": "bytes", + "Connection": "close", + "Content-Length": "370", + "Content-Type": "application/json; charset=UTF-8", + "Date": "Mon, 18 Jan 2021 04:11:01 GMT", + "Server": "snooserv", + "Set-Cookie": "edgebucket=FH0aPNSSzuVo1ci6Zu; Domain=reddit.com; Max-Age=63071999; Path=/; secure", + "Strict-Transport-Security": "max-age=15552000; includeSubDomains; preload", + "Via": "1.1 varnish", + "X-Moose": "majestic", + "cache-control": "max-age=0, must-revalidate", + "x-content-type-options": "nosniff", + "x-frame-options": "SAMEORIGIN", + "x-xss-protection": "1; mode=block" + }, + "status": { + "code": 200, + "message": "OK" + }, + "url": "https://www.reddit.com/api/v1/access_token" + } + }, + { + "request": { + "body": null, + "headers": { + "Accept-Encoding": [ + "identity" + ], + "Authorization": [ + "bearer " + ], + "User-Agent": [ + " Async PRAW/7.1.1.dev0 asyncprawcore/1.5.0" + ] + }, + "method": "GET", + "uri": "https://oauth.reddit.com/api/v1//moderators_invited?limit=100&raw_json=1" + }, + "response": { + "body": { + "string": "{\"after\": null, \"moderators\": {\"t2_3c96t\": {\"username\": \"DubTeeDub\", \"accountIcon\": \"https://styles.redditmedia.com/t5_21g9si/styles/profileIcon_snoo9d063b51-1bb4-4814-b3d9-b51f27626261-headshot.png?width=256\\u0026height=256\\u0026crop=256:256,smart\\u0026frame=1\\u0026s=342c612498e798627a05d5ea1cecc095c8dda841\", \"authorFlairText\": \"\", \"moddedAtUTC\": 1560998690, \"modPermissions\": {\"wiki\": true, \"all\": true, \"chat_operator\": true, \"chat_config\": true, \"posts\": true, \"access\": true, \"mail\": true, \"config\": true, \"flair\": true}, \"iconSize\": [256, 256], \"postKarma\": 697457, \"id\": \"t2_3c96t\"}}, \"moderatorIds\": [\"t2_3c96t\"], \"allUsersLoaded\": true, \"subredditId\": \"t5_3deqz\", \"before\": null}" + }, + "headers": { + "Accept-Ranges": "bytes", + "Connection": "keep-alive", + "Content-Length": "690", + "Content-Type": "application/json; charset=UTF-8", + "Date": "Mon, 18 Jan 2021 04:11:01 GMT", + "Server": "snooserv", + "Set-Cookie": "csv=1; Max-Age=63072000; Domain=.reddit.com; Path=/; Secure; SameSite=None", + "Strict-Transport-Security": "max-age=15552000; includeSubDomains; preload", + "Via": "1.1 varnish", + "X-Moose": "majestic", + "cache-control": "private, s-maxage=0, max-age=0, must-revalidate, no-store, max-age=0, must-revalidate", + "expires": "-1", + "x-content-type-options": "nosniff", + "x-frame-options": "SAMEORIGIN", + "x-ratelimit-remaining": "544.0", + "x-ratelimit-reset": "539", + "x-ratelimit-used": "56", + "x-reddit-decoy-snail": "3314,900", + "x-xss-protection": "1; mode=block" + }, + "status": { + "code": 200, + "message": "OK" + }, + "url": "https://oauth.reddit.com/api/v1//moderators_invited?limit=100&raw_json=1" + } + } + ], + "recorded_at": "2021-01-17T22:11:01", + "version": 1 +} diff --git a/tests/integration/models/reddit/test_subreddit.py b/tests/integration/models/reddit/test_subreddit.py index 37e1a3791..35dd1177f 100644 --- a/tests/integration/models/reddit/test_subreddit.py +++ b/tests/integration/models/reddit/test_subreddit.py @@ -36,6 +36,7 @@ Subreddit, SubredditMessage, WikiPage, + ListingGenerator, ) from ... import IntegrationTest @@ -1671,6 +1672,16 @@ async def test_moderator_invite__no_perms(self, _): await subreddit.moderator.invite(self.REDDITOR, permissions=[]) assert self.REDDITOR not in await subreddit.moderator() + @mock.patch("asyncio.sleep", return_value=None) + async def test_moderator_invited_moderators(self, _): + self.reddit.read_only = False + subreddit = await self.reddit.subreddit(pytest.placeholders.test_subreddit) + with self.use_cassette(): + invited = subreddit.moderator.invited() + assert isinstance(invited, ListingGenerator) + async for moderator in invited: + assert isinstance(moderator, Redditor) + @mock.patch("asyncio.sleep", return_value=None) async def test_moderator_leave(self, _): self.reddit.read_only = False