Repository: linode/linode_api4-python

Issue:
LinodeLoginClient.finish_oauth() doesn't handle tokens with more than one scope
# Steps to reproduce

1. Initiate the OAuth flow with a request for multiple scopes
 ```
login_client.generate_login_url(
            scopes=[OAuthScopes.Account.read_write, OAuthScopes.Domains.read_write ]
        )
```
2. In the OAuth redirect handler, use the `LinodeLoginClient.finish_login()` utility to retrieve the token
```
token, scopes, expiry, refresh_token = login_client.finish_oauth(code)
```
4. Observe exception
```
  File "/root/app/main.py", line 80, in auth_callback_test
    token, scopes, expiry, refresh_token = login_client.finish_oauth(code)
  File "/usr/local/lib/python3.6/dist-packages/linode_api4/login_client.py", line 411, in finish_oauth
    scopes = OAuthScopes.parse(r.json()["scopes"])
  File "/usr/local/lib/python3.6/dist-packages/linode_api4/login_client.py", line 274, in parse
    resource, access = scope.split(':')
   
```

# Analysis
The callback receives a json blob like:
```
{
    "access_token": "...",
    "expires_in": 7200,
    "refresh_token": "...",
    "scope": "account:read_write linodes:read_write linodes:read_only domains:read_only",
    "scopes": "account:read_write linodes:read_write linodes:read_only domains:read_only",
    "token_type": "bearer"
}
```

The scopes are space separated in the json payload, but the `finish_oauth` uses a comma separator:
```
scopes = OAuthScopes.parse(r.json()["scopes"])

for scope in scopes.split(','):
            resource = access = None
            if ':' in scope:
                resource, access = scope.split(':')
            else:
                resource = scope
                access = '*'

            parsed_scope = OAuthScopes._get_parsed_scope(resource, access)
```




Discussion:
pbohman: Would this break someone using `parse()` on a comma delimited string? 
Dorthu: Yes, although I doubt anyone would be if we return them space-delimited.  I could split it on either if that's a concern.
pbohman: [226f6d1](https://github.com/linode/linode_api4-python/pull/220/commits/226f6d187861d6349c59d85ef5567d6246e980d7) looks good. Thanks!
lgarber-akamai: Just pushed up a commit to get the linter passing and to merge in some changes that have since been made on the `dev` branch 👍 

Patch:
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 8c425fe..ae876b7 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -14,7 +14,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: ['3.7','3.8','3.9','3.10', '3.11']
+        python-version: ['3.7','3.8','3.9','3.10','3.11']
     steps:
       - uses: actions/checkout@v2
       - uses: actions/setup-python@v2
diff --git a/README.rst b/README.rst
index 89e5a6e..9b8430b 100644
--- a/README.rst
+++ b/README.rst
@@ -5,8 +5,8 @@ The official python library for the `Linode API v4`_ in python.
 
 .. _Linode API v4: https://developers.linode.com/api/v4/
 
-.. image:: https://travis-ci.com/linode/linode_api4-python.svg?branch=master
-    :target: https://travis-ci.com/linode/linode_api4-python
+.. image:: https://img.shields.io/github/actions/workflow/status/linode/linode_api4-python/main.yml?label=tests
+    :target: https://img.shields.io/github/actions/workflow/status/linode/linode_api4-python/main.yml?label=tests
 
 .. image:: https://badge.fury.io/py/linode-api4.svg
    :target: https://badge.fury.io/py/linode-api4
@@ -84,8 +84,8 @@ Check out the `Getting Started guide`_ for more details on getting started
 with this library, or read `the docs`_ for more extensive documentation.
 
 .. _Linode Personal Access Token: https://www.linode.com/docs/products/tools/api/guides/manage-api-tokens/
-.. _Getting Started guide: http://linode_api4.readthedocs.io/en/latest/guides/getting_started.html
-.. _the docs: http://linode_api4.readthedocs.io/en/latest/index.html
+.. _Getting Started guide: http://linode-api4.readthedocs.io/en/latest/guides/getting_started.html
+.. _the docs: http://linode-api4.readthedocs.io/en/latest/index.html
 
 Examples
 --------
diff --git a/docs/guides/core_concepts.rst b/docs/guides/core_concepts.rst
index 22b0bbc..535d807 100644
--- a/docs/guides/core_concepts.rst
+++ b/docs/guides/core_concepts.rst
@@ -82,6 +82,7 @@ In addition to looking up models from collections, you can simply import the
 model class and create it by ID.::
 
    from linode_api4 import Instance
+
    my_linode = Instance(client, 123)
 
 All models take a `LinodeClient` as their first parameter, and their ID as the
@@ -94,6 +95,14 @@ Be aware that when creating a model this way, it is _not_ loaded from the API
 immediately.  Models in this library are **lazy-loaded**, and will not be looked
 up until one of their attributes that is currently unknown is accessed.
 
+In order to automatically populate a model for an existing Linode resource,
+consider using the :any:`LinodeClient.load` method::
+
+    from linode_api4 import Instance, Disk
+
+    instance = client.load(Instance, 12345)
+    instance_disk = client.load(Disk, 123, instance.id)
+
 Lazy Loading
 ^^^^^^^^^^^^
 
@@ -149,10 +158,15 @@ models can also be deleted in a similar fashion.::
 .. note::
 
    Saving a model *may* fail if the values you are attempting to save are invalid.
-   If the values you are attemting to save are coming from an untrusted source,
+   If the values you are attempting to save are coming from an untrusted source,
    be sure to handle a potential :any:`ApiError` raised by the API returning
    an unsuccessful response code.
 
+    When updating an attribute on a model, ensure that the model has been populated
+    *before* any local changes have been made. Attempting to update an attribute
+    and save a model before the model has been populated will result in no changes
+    being applied.
+
 Relationships
 ^^^^^^^^^^^^^
 
diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py
index 208f1d1..decf9c4 100644
--- a/linode_api4/linode_client.py
+++ b/linode_api4/linode_client.py
@@ -48,6 +48,8 @@ class LinodeGroup(Group):
 
            standard_types = client.linode.types(Type.class == "standard")
 
+        API documentation: https://www.linode.com/docs/api/linode-types/#types-list
+
         :param filters: Any number of filters to apply to the query.
 
         :returns: A list of types that match the query.
@@ -62,6 +64,8 @@ class LinodeGroup(Group):
 
            prod_linodes = client.linode.instances(Instance.group == "prod")
 
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#linodes-list
+
         :param filters: Any number of filters to apply to this query.
 
         :returns: A list of Instances that matched the query.
@@ -78,6 +82,8 @@ class LinodeGroup(Group):
 
            my_stackscripts = client.linode.stackscripts(mine_only=True)
 
+        API Documentation: https://www.linode.com/docs/api/stackscripts/#stackscripts-list
+
         :param filters: Any number of filters to apply to this query.
         :param mine_only: If True, returns only private StackScripts
         :type mine_only: bool
@@ -111,6 +117,8 @@ class LinodeGroup(Group):
         Returns a list of available :any:`Kernels<Kernel>`.  Kernels are used
         when creating or updating :any:`LinodeConfigs,LinodeConfig>`.
 
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#kernels-list
+
         :param filters: Any number of filters to apply to this query.
 
         :returns: A list of available kernels that match the query.
@@ -197,6 +205,8 @@ class LinodeGroup(Group):
         successfully until disks and configs are created, or it is otherwise
         configured.
 
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-create
+
         :param ltype: The Instance Type we are creating
         :type ltype: str or Type
         :param region: The Region in which we are creating the Instance
@@ -298,6 +308,8 @@ class LinodeGroup(Group):
         """
         Creates a new :any:`StackScript` on your account.
 
+        API Documentation: https://www.linode.com/docs/api/stackscripts/#stackscript-create
+
         :param label: The label for this StackScript.
         :type label: str
         :param script: The script to run when an :any:`Instance` is deployed with
@@ -375,6 +387,8 @@ class ProfileGroup(Group):
 
            profile = client.profile()
 
+        API Documentation: https://www.linode.com/docs/api/profile/#profile-view
+
         :returns: The acting user's profile.
         :rtype: Profile
         """
@@ -390,13 +404,32 @@ class ProfileGroup(Group):
 
     def tokens(self, *filters):
         """
-        Returns the Person Access Tokens active for this user
+        Returns the Person Access Tokens active for this user.
+
+        API Documentation: https://www.linode.com/docs/api/profile/#personal-access-tokens-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of tokens that matches the query.
+        :rtype: PaginatedList of PersonalAccessToken
         """
         return self.client._get_and_filter(PersonalAccessToken, *filters)
 
     def token_create(self, label=None, expiry=None, scopes=None, **kwargs):
         """
-        Creates and returns a new Personal Access Token
+        Creates and returns a new Personal Access Token.
+
+        API Documentation: https://www.linode.com/docs/api/profile/#personal-access-token-create
+
+        :param label: The label of the new Personal Access Token.
+        :type label: str
+        :param expiry: When the new Personal Accses Token will expire.
+        :type expiry: datetime or str
+        :param scopes: A space-separated list of OAuth scopes for this token.
+        :type scopes: str
+
+        :returns: The new Personal Access Token.
+        :rtype: PersonalAccessToken
         """
         if label:
             kwargs["label"] = label
@@ -421,12 +454,26 @@ class ProfileGroup(Group):
     def apps(self, *filters):
         """
         Returns the Authorized Applications for this user
+
+        API Documentation: https://www.linode.com/docs/api/profile/#authorized-apps-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of Authorized Applications for this user
+        :rtype: PaginatedList of AuthorizedApp
         """
         return self.client._get_and_filter(AuthorizedApp, *filters)
 
     def ssh_keys(self, *filters):
         """
-        Returns the SSH Public Keys uploaded to your profile
+        Returns the SSH Public Keys uploaded to your profile.
+
+        API Documentation: https://www.linode.com/docs/api/profile/#ssh-keys-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of SSH Keys for this profile.
+        :rtype: PaginatedList of SSHKey
         """
         return self.client._get_and_filter(SSHKey, *filters)
 
@@ -435,6 +482,8 @@ class ProfileGroup(Group):
         Uploads a new SSH Public Key to your profile  This key can be used in
         later Linode deployments.
 
+        API Documentation: https://www.linode.com/docs/api/profile/#ssh-key-add
+
         :param key: The ssh key, or a path to the ssh key.  If a path is provided,
                     the file at the path must exist and be readable or an exception
                     will be thrown.
@@ -489,6 +538,8 @@ class LKEGroup(Group):
         Returns a :any:`PaginatedList` of :any:`KubeVersion` objects that can be
         used when creating an LKE Cluster.
 
+        API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-versions-list
+
         :param filters: Any number of filters to apply to the query.
 
         :returns: A Paginated List of kube versions that match the query.
@@ -501,6 +552,8 @@ class LKEGroup(Group):
         Returns a :any:`PaginagtedList` of :any:`LKECluster` objects that belong
         to this account.
 
+        https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-clusters-list
+
         :param filters: Any number of filters to apply to the query.
 
         :returns: A Paginated List of LKE clusters that match the query.
@@ -529,6 +582,8 @@ class LKEGroup(Group):
                kube_version
             )
 
+        API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-cluster-create
+
         :param region: The Region to create this LKE Cluster in.
         :type region: Region or str
         :param label: The label for the new LKE Cluster.
@@ -600,10 +655,21 @@ class LKEGroup(Group):
 
 
 class LongviewGroup(Group):
+    """
+    Collections related to Linode Longview.
+    """
+
     def clients(self, *filters):
         """
         Requests and returns a paginated list of LongviewClients on your
         account.
+
+        API Documentation: https://www.linode.com/docs/api/longview/#longview-clients-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of Longview Clients matching the given filters.
+        :rtype: PaginatedList of LongviewClient
         """
         return self.client._get_and_filter(LongviewClient, *filters)
 
@@ -611,6 +677,8 @@ class LongviewGroup(Group):
         """
         Creates a new LongviewClient, optionally with a given label.
 
+        API Documentation: https://www.linode.com/docs/api/longview/#longview-client-create
+
         :param label: The label for the new client.  If None, a default label based
             on the new client's ID will be used.
 
@@ -634,11 +702,22 @@ class LongviewGroup(Group):
     def subscriptions(self, *filters):
         """
         Requests and returns a paginated list of LongviewSubscriptions available
+
+        API Documentation: https://www.linode.com/docs/api/longview/#longview-subscriptions-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of Longview Subscriptions matching the given filters.
+        :rtype: PaginatedList of LongviewSubscription
         """
         return self.client._get_and_filter(LongviewSubscription, *filters)
 
 
 class AccountGroup(Group):
+    """
+    Collections related to your account.
+    """
+
     def __call__(self):
         """
         Retrieves information about the acting user's account, such as billing
@@ -647,6 +726,8 @@ class AccountGroup(Group):
 
            account = client.account()
 
+        API Documentation: https://www.linode.com/docs/api/account/#account-view
+
         :returns: Returns the acting user's account information.
         :rtype: Account
         """
@@ -660,12 +741,28 @@ class AccountGroup(Group):
         return Account(self.client, result["email"], result)
 
     def events(self, *filters):
+        """
+        Lists events on the current account matching the given filters.
+
+        API Documentation: https://www.linode.com/docs/api/account/#events-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of events on the current account matching the given filters.
+        :rtype: PaginatedList of Event
+        """
+
         return self.client._get_and_filter(Event, *filters)
 
     def events_mark_seen(self, event):
         """
         Marks event as the last event we have seen.  If event is an int, it is treated
         as an event_id, otherwise it should be an event object whose id will be used.
+
+        API Documentation: https://www.linode.com/docs/api/account/#event-mark-as-seen
+
+        :param event: The Linode event to mark as seen.
+        :type event: Event or int
         """
         last_seen = event if isinstance(event, int) else event.id
         self.client.post(
@@ -675,8 +772,13 @@ class AccountGroup(Group):
 
     def settings(self):
         """
-        Resturns the account settings data for this acocunt.  This is not  a
+        Returns the account settings data for this acocunt.  This is not  a
         listing endpoint.
+
+        API Documentation: https://www.linode.com/docs/api/account/#account-settings-view
+
+        :returns: The account settings data for this account.
+        :rtype: AccountSettings
         """
         result = self.client.get("/account/settings")
 
@@ -691,25 +793,54 @@ class AccountGroup(Group):
 
     def invoices(self):
         """
-        Returns Invoices issued to this account
+        Returns Invoices issued to this account.
+
+        API Documentation: https://www.linode.com/docs/api/account/#invoices-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: Invoices issued to this account.
+        :rtype: PaginatedList of Invoice
         """
         return self.client._get_and_filter(Invoice)
 
     def payments(self):
         """
-        Returns a list of Payments made to this account
+        Returns a list of Payments made on this account.
+
+        API Documentation: https://www.linode.com/docs/api/account/#payments-list
+
+        :returns: A list of payments made on this account.
+        :rtype: PaginatedList of Payment
         """
         return self.client._get_and_filter(Payment)
 
     def oauth_clients(self, *filters):
         """
-        Returns the OAuth Clients associated to this account
+        Returns the OAuth Clients associated with this account.
+
+        API Documentation: https://www.linode.com/docs/api/account/#oauth-clients-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of OAuth Clients associated with this account.
+        :rtype: PaginatedList of OAuthClient
         """
         return self.client._get_and_filter(OAuthClient, *filters)
 
     def oauth_client_create(self, name, redirect_uri, **kwargs):
         """
-        Make a new OAuth Client and return it
+        Creates a new OAuth client.
+
+        API Documentation: https://www.linode.com/docs/api/account/#oauth-client-create
+
+        :param name: The name of this application.
+        :type name: str
+        :param redirect_uri: The location a successful log in from https://login.linode.com should be redirected to for this client.
+        :type redirect_uri: str
+
+        :returns: The created OAuth Client.
+        :rtype: OAuthClient
         """
         params = {
             "label": name,
@@ -729,13 +860,25 @@ class AccountGroup(Group):
 
     def users(self, *filters):
         """
-        Returns a list of users on this account
+        Returns a list of users on this account.
+
+        API Documentation: https://www.linode.com/docs/api/account/#users-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of users on this account.
+        :rtype: PaginatedList of User
         """
         return self.client._get_and_filter(User, *filters)
 
     def transfer(self):
         """
-        Returns a MappedObject containing the account's transfer pool data
+        Returns a MappedObject containing the account's transfer pool data.
+
+        API Documentation: https://www.linode.com/docs/api/account/#network-utilization-view
+
+        :returns: Information about this account's transfer pool data.
+        :rtype: MappedObject
         """
         result = self.client.get("/account/transfer")
 
@@ -757,6 +900,8 @@ class AccountGroup(Group):
         The new user will receive an email inviting them to set up their password.
         This must be completed before they can log in.
 
+        API Documentation: https://www.linode.com/docs/api/account/#user-create
+
         :param email: The new user's email address.  This is used to finish setting
                       up their user account.
         :type email: str
@@ -791,12 +936,16 @@ class AccountGroup(Group):
 
 
 class NetworkingGroup(Group):
+    """
+    Collections related to Linode Networking.
+    """
+
     def firewalls(self, *filters):
         """
-        .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`.
-
         Retrieves the Firewalls your user has access to.
 
+        API Documentation: https://www.linode.com/docs/api/networking/#firewalls-list
+
         :param filters: Any number of filters to apply to this query.
 
         :returns: A list of Firewalls the acting user can access.
@@ -806,11 +955,11 @@ class NetworkingGroup(Group):
 
     def firewall_create(self, label, rules, **kwargs):
         """
-        .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`.
-
         Creates a new Firewall, either in the given Region or
         attached to the given Instance.
 
+        API Documentation: https://www.linode.com/docs/api/networking/#firewall-create
+
         :param label: The label for the new Firewall.
         :type label: str
         :param rules: The rules to apply to the new Firewall. For more information on Firewall rules, see our `Firewalls Documentation`_.
@@ -866,20 +1015,56 @@ class NetworkingGroup(Group):
         return f
 
     def ips(self, *filters):
+        """
+        Returns a list of IP addresses on this account, excluding private addresses.
+
+        API Documentation: https://www.linode.com/docs/api/networking/#ip-addresses-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of IP addresses on this account.
+        :rtype: PaginatedList of IPAddress
+        """
         return self.client._get_and_filter(IPAddress, *filters)
 
     def ipv6_ranges(self, *filters):
+        """
+        Returns a list of IPv6 ranges on this account.
+
+        API Documentation: https://www.linode.com/docs/api/networking/#ipv6-ranges-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of IPv6 ranges on this account.
+        :rtype: PaginatedList of IPv6Range
+        """
         return self.client._get_and_filter(IPv6Range, *filters)
 
     def ipv6_pools(self, *filters):
+        """
+        Returns a list of IPv6 pools on this account.
+
+        API Documentation: https://www.linode.com/docs/api/networking/#ipv6-pools-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of IPv6 pools on this account.
+        :rtype: PaginatedList of IPv6Pool
+        """
+
         return self.client._get_and_filter(IPv6Pool, *filters)
 
     def vlans(self, *filters):
         """
         .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`.
+
         Returns a list of VLANs on your account.
 
-        :returns: A Paginated List of VLANs on your account.
+        API Documentation: https://www.linode.com/docs/api/networking/#vlans-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A List of VLANs on your account.
         :rtype: PaginatedList of VLAN
         """
         return self.client._get_and_filter(VLAN, *filters)
@@ -912,6 +1097,7 @@ class NetworkingGroup(Group):
            linode1.invalidate()
            linode2.invalidate()
 
+        API Documentation: https://www.linode.com/docs/api/networking/#linodes-assign-ipv4s
 
         :param region: The Region in which the assignments should take place.
                        All Instances and IPAddresses involved in the assignment
@@ -941,12 +1127,14 @@ class NetworkingGroup(Group):
         Allocates an IP to a Instance you own.  Additional IPs must be requested
         by opening a support ticket first.
 
+        API Documentation: https://www.linode.com/docs/api/networking/#ip-address-allocate
+
         :param linode: The Instance to allocate the new IP for.
         :type linode: Instance or int
         :param public: If True, allocate a public IP address.  Defaults to True.
         :type public: bool
 
-        :returns: The new IPAddress
+        :returns: The new IPAddress.
         :rtype: IPAddress
         """
         result = self.client.post(
@@ -972,6 +1160,8 @@ class NetworkingGroup(Group):
         :any:`Instance`.  This will enable the provided Instance to bring up the
         shared IP Addresses even though it does not own them.
 
+        API Documentation: https://www.linode.com/docs/api/networking/#ipv4-sharing-configure
+
         :param linode: The Instance to share the IPAddresses with.  This Instance
                        will be able to bring up the given addresses.
         :type: linode: int or Instance
@@ -1003,25 +1193,76 @@ class NetworkingGroup(Group):
 
 
 class SupportGroup(Group):
+    """
+    Collections related to support tickets.
+    """
+
     def tickets(self, *filters):
+        """
+        Returns a list of support tickets on this account.
+
+        API Documentation: https://www.linode.com/docs/api/support/#support-tickets-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of support tickets on this account.
+        :rtype: PaginatedList of SupportTicket
+        """
+
         return self.client._get_and_filter(SupportTicket, *filters)
 
-    def ticket_open(self, summary, description, regarding=None):
-        """ """
+    def ticket_open(
+        self,
+        summary,
+        description,
+        managed_issue=False,
+        regarding=None,
+        **kwargs,
+    ):
+        """
+        Opens a support ticket on this account.
+
+        API Documentation: https://www.linode.com/docs/api/support/#support-ticket-open
+
+        :param summary: The summary or title for this support ticket.
+        :type summary: str
+        :param description: The full details of the issue or question.
+        :type description: str
+        :param regarding: The resource being referred to in this ticket.
+        :type regarding:
+        :param managed_issue: Designates if this ticket relates to a managed service.
+        :type managed_issue: bool
+
+        :returns: The new support ticket.
+        :rtype: SupportTicket
+        """
         params = {
             "summary": summary,
             "description": description,
+            "managed_issue": managed_issue,
+        }
+
+        type_to_id = {
+            Instance: "linode_id",
+            Domain: "domain_id",
+            NodeBalancer: "nodebalancer_id",
+            Volume: "volume_id",
+            Firewall: "firewall_id",
+            LKECluster: "lkecluster_id",
+            Database: "database_id",
+            LongviewClient: "longviewclient_id",
         }
 
+        params.update(kwargs)
+
         if regarding:
-            if isinstance(regarding, Instance):
-                params["linode_id"] = regarding.id
-            elif isinstance(regarding, Domain):
-                params["domain_id"] = regarding.id
-            elif isinstance(regarding, NodeBalancer):
-                params["nodebalancer_id"] = regarding.id
-            elif isinstance(regarding, Volume):
-                params["volume_id"] = regarding.id
+            id_attr = type_to_id.get(type(regarding))
+
+            if id_attr is not None:
+                params[id_attr] = regarding.id
+            elif isinstance(regarding, VLAN):
+                params["vlan"] = regarding.label
+                params["region"] = regarding.region
             else:
                 raise ValueError(
                     "Cannot open ticket regarding type {}!".format(
@@ -1053,6 +1294,8 @@ class ObjectStorageGroup(Group):
 
            us_east_clusters = client.object_storage.clusters(ObjectStorageCluster.region == "us-east")
 
+        API Documentation: https://www.linode.com/docs/api/object-storage/#clusters-list
+
         :param filters: Any number of filters to apply to this query.
 
         :returns: A list of Object Storage Clusters that matched the query.
@@ -1065,6 +1308,8 @@ class ObjectStorageGroup(Group):
         Returns a list of Object Storage Keys active on this account.  These keys
         allow third-party applications to interact directly with Linode Object Storage.
 
+        API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-keys-list
+
         :param filters: Any number of filters to apply to this query.
 
         :returns: A list of Object Storage Keys that matched the query.
@@ -1107,6 +1352,8 @@ class ObjectStorageGroup(Group):
                bucket_access=client.object_storage.bucket_access("us-east-1", "example2", "read_only"),
            )
 
+        API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-key-create
+
         :param label: The label for this keypair, for identification only.
         :type label: str
         :param bucket_access: One or a list of dicts with keys "cluster,"
@@ -1180,6 +1427,8 @@ class ObjectStorageGroup(Group):
         Cancels Object Storage service.  This may be a destructive operation.  Once
         cancelled, you will no longer receive the transfer for or be billed for
         Object Storage, and all keys will be invalidated.
+
+        API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-cancel
         """
         self.client.post("/object-storage/cancel", data={})
         return True
@@ -1206,6 +1455,8 @@ class DatabaseGroup(Group):
 
            database_types = client.database.types(DatabaseType.deprecated == False)
 
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-database-types-list
+
         :param filters: Any number of filters to apply to the query.
 
         :returns: A list of types that match the query.
@@ -1222,6 +1473,8 @@ class DatabaseGroup(Group):
 
            mysql_engines = client.database.engines(DatabaseEngine.engine == 'mysql')
 
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-database-engines-list
+
         :param filters: Any number of filters to apply to the query.
 
         :returns: A list of types that match the query.
@@ -1233,6 +1486,8 @@ class DatabaseGroup(Group):
         """
         Returns a list of Managed Databases active on this account.
 
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-databases-list-all
+
         :param filters: Any number of filters to apply to this query.
 
         :returns: A list of databases that matched the query.
@@ -1244,6 +1499,8 @@ class DatabaseGroup(Group):
         """
         Returns a list of Managed MySQL Databases active on this account.
 
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-databases-list
+
         :param filters: Any number of filters to apply to this query.
 
         :returns: A list of MySQL databases that matched the query.
@@ -1271,6 +1528,8 @@ class DatabaseGroup(Group):
                type.id
             )
 
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-database-create
+
         :param label: The name for this cluster
         :type label: str
         :param region: The region to deploy this cluster in
@@ -1303,6 +1562,8 @@ class DatabaseGroup(Group):
         """
         Returns a list of Managed PostgreSQL Databases active on this account.
 
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-databases-list
+
         :param filters: Any number of filters to apply to this query.
 
         :returns: A list of PostgreSQL databases that matched the query.
@@ -1330,6 +1591,8 @@ class DatabaseGroup(Group):
                type.id
             )
 
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-database-create
+
         :param label: The name for this cluster
         :type label: str
         :param region: The region to deploy this cluster in
@@ -1365,6 +1628,8 @@ class DatabaseGroup(Group):
         """
         Returns a list of Managed MongoDB Databases active on this account.
 
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-databases-list
+
         :param filters: Any number of filters to apply to this query.
 
         :returns: A list of MongoDB databases that matched the query.
@@ -1392,6 +1657,8 @@ class DatabaseGroup(Group):
                type.id
             )
 
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-database-create
+
         :param label: The name for this cluster
         :type label: str
         :param region: The region to deploy this cluster in
@@ -1673,6 +1940,8 @@ class LinodeClient:
         """
         Returns the available Regions for Linode products.
 
+        API Documentation: https://www.linode.com/docs/api/regions/#regions-list
+
         :param filters: Any number of filters to apply to the query.
 
         :returns: A list of available Regions.
@@ -1689,6 +1958,8 @@ class LinodeClient:
            debian_images = client.images(
                Image.vendor == "debain")
 
+        API Documentation: https://www.linode.com/docs/api/images/#images-list
+
         :param filters: Any number of filters to apply to the query.
 
         :returns: A list of available Images.
@@ -1700,6 +1971,8 @@ class LinodeClient:
         """
         Creates a new Image from a disk you own.
 
+        API Documentation: https://www.linode.com/docs/api/images/#image-create
+
         :param disk: The Disk to imagize.
         :type disk: Disk or int
         :param label: The label for the resulting Image (defaults to the disk's
@@ -1737,7 +2010,8 @@ class LinodeClient:
     ) -> Tuple[Image, str]:
         """
         Creates a new Image and returns the corresponding upload URL.
-        https://www.linode.com/docs/api/images/#image-upload
+
+        API Documentation: https://www.linode.com/docs/api/images/#image-upload
 
         :param label: The label of the Image to create.
         :type label: str
@@ -1768,7 +2042,8 @@ class LinodeClient:
     ) -> Image:
         """
         Creates and uploads a new image.
-        https://www.linode.com/docs/api/images/#image-upload
+
+        API Documentation: https://www.linode.com/docs/api/images/#image-upload
 
         :param label: The label of the Image to create.
         :type label: str
@@ -1800,6 +2075,8 @@ class LinodeClient:
         """
         Retrieves all of the Domains the acting user has access to.
 
+        API Documentation: https://www.linode.com/docs/api/domains/#domains-list
+
         :param filters: Any number of filters to apply to this query.
 
         :returns: A list of Domains the acting user can access.
@@ -1811,6 +2088,8 @@ class LinodeClient:
         """
         Retrieves all of the NodeBalancers the acting user has access to.
 
+        API Documentation: https://www.linode.com/docs/api/nodebalancers/#nodebalancers-list
+
         :param filters: Any number of filters to apply to this query.
 
         :returns: A list of NodeBalancers the acting user can access.
@@ -1822,6 +2101,8 @@ class LinodeClient:
         """
         Creates a new NodeBalancer in the given Region.
 
+        API Documentation: https://www.linode.com/docs/api/nodebalancers/#nodebalancer-create
+
         :param region: The Region in which to create the NodeBalancer.
         :type region: Region or str
 
@@ -1849,6 +2130,8 @@ class LinodeClient:
         your registrar to Linode's nameservers so that Linode's DNS manager will
         correctly serve your domain.
 
+        API Documentation: https://www.linode.com/docs/api/domains/#domain-create
+
         :param domain: The domain to register to Linode's DNS manager.
         :type domain: str
         :param master: Whether this is a master (defaults to true)
@@ -1882,6 +2165,8 @@ class LinodeClient:
         Retrieves the Tags on your account.  This may only be attempted by
         unrestricted users.
 
+        API Documentation: https://www.linode.com/docs/api/domains/#domain-create
+
         :param filters: Any number of filters to apply to this query.
 
         :returns: A list of Tags on the account.
@@ -1901,6 +2186,8 @@ class LinodeClient:
         """
         Creates a new Tag and optionally applies it to the given entities.
 
+        API Documentation: https://www.linode.com/docs/api/tags/#tags-list
+
         :param label: The label for the new Tag
         :type label: str
         :param entities: A list of objects to apply this Tag to upon creation.
@@ -1981,6 +2268,8 @@ class LinodeClient:
         """
         Retrieves the Block Storage Volumes your user has access to.
 
+        API Documentation: https://www.linode.com/docs/api/volumes/#volumes-list
+
         :param filters: Any number of filters to apply to this query.
 
         :returns: A list of Volumes the acting user can access.
@@ -1993,6 +2282,8 @@ class LinodeClient:
         Creates a new Block Storage Volume, either in the given Region or
         attached to the given Instance.
 
+        API Documentation: https://www.linode.com/docs/api/volumes/#volumes-list
+
         :param label: The label for the new Volume.
         :type label: str
         :param region: The Region to create this Volume in.  Not required if
diff --git a/linode_api4/login_client.py b/linode_api4/login_client.py
index 68de22f..7531a77 100644
--- a/linode_api4/login_client.py
+++ b/linode_api4/login_client.py
@@ -1,3 +1,4 @@
+import re
 from datetime import datetime, timedelta
 from enum import Enum
 
@@ -283,7 +284,7 @@ class OAuthScopes:
                 for scope in OAuthScopes._scope_families.values()
             ]
 
-        for scope in scopes.split(","):
+        for scope in re.split("[, ]", scopes):
             resource = access = None
             if ":" in scope:
                 resource, access = scope.split(":")
@@ -303,7 +304,7 @@ class OAuthScopes:
         access = access.lower()
         if resource in OAuthScopes._scope_families:
             if access == "*":
-                access = "delete"
+                access = "all"
             if hasattr(OAuthScopes._scope_families[resource], access):
                 return getattr(OAuthScopes._scope_families[resource], access)
 
diff --git a/linode_api4/objects/longview.py b/linode_api4/objects/longview.py
index 35b1593..cfbb398 100644
--- a/linode_api4/objects/longview.py
+++ b/linode_api4/objects/longview.py
@@ -16,7 +16,8 @@ class LongviewClient(Base):
 
 
 class LongviewSubscription(Base):
-    api_endpoint = "longview/subscriptions/{id}"
+    api_endpoint = "/longview/subscriptions/{id}"
+
     properties = {
         "id": Property(identifier=True),
         "label": Property(),
diff --git a/linode_api4/objects/support.py b/linode_api4/objects/support.py
index 965346b..c12def1 100644
--- a/linode_api4/objects/support.py
+++ b/linode_api4/objects/support.py
@@ -40,6 +40,10 @@ class SupportTicket(Base):
         "updated": Property(is_datetime=True),
         "updated_by": Property(),
         "replies": Property(derived_class=TicketReply),
+        "attachments": Property(),
+        "closable": Property(),
+        "gravatar_id": Property(),
+        "opened_by": Property(),
     }
 
     @property
@@ -114,3 +118,6 @@ class SupportTicket(Base):
             raise ApiError("{}: {}".format(result.status_code, errors), json=j)
 
         return True
+
+    def support_ticket_close(self):
+        self._client.post("{}/close".format(self.api_endpoint), model=self)


Test patch:
diff --git a/test/fixtures/support_tickets_123.json b/test/fixtures/support_tickets_123.json
new file mode 100644
index 0000000..4a568f1
--- /dev/null
+++ b/test/fixtures/support_tickets_123.json
@@ -0,0 +1,22 @@
+{
+    "attachments": [
+      null
+    ],
+    "closable": false,
+    "closed": "2015-06-04T16:07:03",
+    "description": "I'm having trouble setting the root password on my Linode. I tried following the instructions but something is not working and I'm not sure what I'm doing wrong. Can you please help me figure out how I can reset it?\n",
+    "entity": {
+      "id": 10400,
+      "label": "linode123456",
+      "type": "linode",
+      "url": "/v4/linode/instances/123456"
+    },
+    "gravatar_id": "474a1b7373ae0be4132649e69c36ce30",
+    "id": 123,
+    "opened": "2015-06-04T14:16:44",
+    "opened_by": "some_user",
+    "status": "open",
+    "summary": "Having trouble resetting root password on my Linode\n",
+    "updated": "2015-06-04T16:07:03",
+    "updated_by": "some_other_user"
+  }
diff --git a/test/linode_client_test.py b/test/linode_client_test.py
index d1e444c..6e2bcae 100644
--- a/test/linode_client_test.py
+++ b/test/linode_client_test.py
@@ -396,7 +396,10 @@ class LongviewGroupTest(ClientBaseCase):
         """
         Tests that Longview subscriptions can be retrieved
         """
-        r = self.client.longview.subscriptions()
+
+        with self.mock_get("longview/subscriptions") as m:
+            r = self.client.longview.subscriptions()
+            self.assertEqual(m.call_url, "/longview/subscriptions")
 
         self.assertEqual(len(r), 4)
 
diff --git a/test/login_client_test.py b/test/login_client_test.py
new file mode 100644
index 0000000..5a17d77
--- /dev/null
+++ b/test/login_client_test.py
@@ -0,0 +1,55 @@
+from unittest import TestCase
+
+from linode_api4 import OAuthScopes
+
+
+class OAuthScopesTest(TestCase):
+    def test_parse_scopes_none(self):
+        """
+        Tests parsing no scopes
+        """
+        scopes = OAuthScopes.parse("")
+        self.assertEqual(scopes, [])
+
+    def test_parse_scopes_single(self):
+        """
+        Tests parsing a single scope
+        """
+        scopes = OAuthScopes.parse("linodes:read_only")
+        self.assertEqual(scopes, [OAuthScopes.Linodes.read_only])
+
+    def test_parse_scopes_many(self):
+        """
+        Tests parsing many scopes
+        """
+        scopes = OAuthScopes.parse("linodes:read_only domains:read_write")
+        self.assertEqual(
+            scopes,
+            [OAuthScopes.Linodes.read_only, OAuthScopes.Domains.read_write],
+        )
+
+    def test_parse_scopes_many_comma_delimited(self):
+        """
+        Tests parsing many scopes that are comma-delimited (which preserves old behavior)
+        """
+        scopes = OAuthScopes.parse(
+            "nodebalancers:read_write,stackscripts:*,events:read_only"
+        )
+        self.assertEqual(
+            scopes,
+            [
+                OAuthScopes.NodeBalancers.read_write,
+                OAuthScopes.StackScripts.all,
+                OAuthScopes.Events.read_only,
+            ],
+        )
+
+    def test_parse_scopes_all(self):
+        """
+        Tests parsing * scopes
+        """
+        scopes = OAuthScopes.parse("*")
+        self.assertEqual(
+            scopes,
+            [getattr(c, "all") for c in OAuthScopes._scope_families.values()],
+        )
diff --git a/test/objects/domain_test.py b/test/objects/domain_test.py
index 5fc0d5e..ded8321 100644
--- a/test/objects/domain_test.py
+++ b/test/objects/domain_test.py
@@ -1,17 +1,19 @@
-from linode_api4.objects import Domain
 from test.base import ClientBaseCase
 
+from linode_api4.objects import Domain
+
+
 class DomainGeneralTest(ClientBaseCase):
     """
     Tests methods of the Domain class.
     """
 
     def test_save_null_values_excluded(self):
-        with self.mock_put('domains/12345') as m:
+        with self.mock_put("domains/12345") as m:
             domain = self.client.load(Domain, 12345)
 
             domain.type = "slave"
             domain.master_ips = ["127.0.0.1"]
             domain.save()
 
-            self.assertTrue('group' not in m.call_data.keys())
\ No newline at end of file
+            self.assertTrue("group" not in m.call_data.keys())
diff --git a/test/objects/support_test.py b/test/objects/support_test.py
new file mode 100644
index 0000000..50ca0f9
--- /dev/null
+++ b/test/objects/support_test.py
@@ -0,0 +1,28 @@
+from test.base import ClientBaseCase
+
+from linode_api4.objects import SupportTicket
+
+
+class SupportTest(ClientBaseCase):
+    """
+    Tests methods of the SupportTicket class
+    """
+
+    def test_get_support_ticket(self):
+        ticket = SupportTicket(self.client, 123)
+
+        self.assertIsNotNone(ticket.attachments)
+        self.assertFalse(ticket.closable)
+        self.assertIsNotNone(ticket.entity)
+        self.assertEqual(ticket.gravatar_id, "474a1b7373ae0be4132649e69c36ce30")
+        self.assertEqual(ticket.id, 123)
+        self.assertEqual(ticket.opened_by, "some_user")
+        self.assertEqual(ticket.status, "open")
+        self.assertEqual(ticket.updated_by, "some_other_user")
+
+    def test_support_ticket_close(self):
+        ticket = SupportTicket(self.client, 123)
+
+        with self.mock_post({}) as m:
+            ticket.support_ticket_close()
+            self.assertEqual(m.call_url, "/support/tickets/123/close")