diff --git a/demo/__init__.py b/demo/__init__.py new file mode 100644 index 0000000..65afeef --- /dev/null +++ b/demo/__init__.py @@ -0,0 +1,5 @@ +''' + +''' +__author__ = 'eb' + diff --git a/demo/auth_demo/README.md b/demo/auth_demo/README.md new file mode 100644 index 0000000..a111934 --- /dev/null +++ b/demo/auth_demo/README.md @@ -0,0 +1,77 @@ +# PyPardot4 Single Sign On Enhancements + +The upcoming Spring '21 release of Salesforce requires that all access to Pardot +be authenticated via Single Sign On with Salesforce via OAuth2. This branch provides +an approach to enhancing PyPardot4 to extend it's functionality to be ready for +the new release. This branch is **NOT** meant to actually be merged, as is, into +the PyPardot4 project. Rather, it was committed to provide an example of an approach +and to discuss with the community if it would be a valuable addition, and if so, what changes +would be needed to prepare the code to be merged with the project. + +## Approach + +1. To show additional functionality through a subclass of PardotAPI, so as to highlight the + methodology for supporting SSO without changing existing code. +1. To separate authentication methodology from the api through the + use of a hierarchy of authenticator classes. + +## Demonstration + +There is demonstration code provided in `oauth_demo.py` that shows: +1. Retroactive support for Pardot-Only authentication. +1. Example of how to perform SSO via OAuth2 authentication using raw `requests` level construction. +1. Support for SSO via OAuth2 authentication via the AuthPardotAPI subclass of PardotAPI + +The demonstration program requires a configuration file, `oauth_demo.ini` which by default +is sought in the users home directory. This can be changed at the top level of the +demo program. The content needed in that file is described in `pardot_demo.ini` next to +the demonstration program. Since the config file will have private credentials in it, +it is purposefully located outside of the project by default. The configuration file has sections +for different pardot and salesforce instances, both production and sand box. If you do not desire to +hit against any of these while running the demo, or you do not have access to any of these, you +can eleminate those sections. The demo program will skip code that requires the missing sections. + +## Issues for Discussion + +### Retroactive Support + +Given that Pardot-Only authentication is fully going away, retroactive support for +the old style of authentication is not really necessary. However, for the short time that both exist, it +is useful for testing. Given this, it is not necessary to truly subclass the Pardot API class as shown here. +That architecture was used in this version to show a clean separation between what was and what can be. + +### Separating Authentication from PardotAPI + +That said, it is suggested strongly that authentication functionality be taken out of the +updated PardotAPI class and be shifted to a proper hierarchy of authentication classes. +A quick review of the +[`simple_salesforce`](https://github.com/simple-salesforce/simple-salesforce) package shows +three or more different ways in which one can authenticate with the Salesforce API. +The code in this branch demonstrates achieving SSO using only one of them. By separating +the authentication from the API class, it allows the PyPardot4 package to be easily extendable +to other methods of Salesforce authentication to be used for SSO if necessary. + +### Context Managers + +It would seem very appropriate to enhance the PardotAPI class to be a context manager, such +that it is authenticated as part of entering the context. This branch does not yet include +those extensions in the AuthPardotAPI sub-class to not confuse the primary objective of +accomplishing SSO. However, if this code is to be re-worked in order to be merged into +the project, it is strongly suggested that this enhancement be included. + +## Caveats - IMPORTANT + +### 3.7 + +This code was developed and tested under Python 3.7.4. +It was not tested under any other version. + +### Relative Imports + +This demonstration code made every effort to not change any of the existing code and demonstrate +the SSO functionality simply by adding code (for now). However, there were changes made +removing all relative imports from existing files to enable the code to execute in our 3.7.4 environment. +This is another good reason not to merge this code as is. If the community decides +that we should move ahead with this branch, the continued use of relative imports should +be discussed and if it is decided that they should continue to be utilized, we will need +assistance in figuring out how to get them to work in our environment. diff --git a/demo/auth_demo/__init__.py b/demo/auth_demo/__init__.py new file mode 100644 index 0000000..120a4d1 --- /dev/null +++ b/demo/auth_demo/__init__.py @@ -0,0 +1,6 @@ +''' + +''' +__author__ = 'eb' + + diff --git a/demo/auth_demo/oauth_demo.py b/demo/auth_demo/oauth_demo.py new file mode 100644 index 0000000..184b0b2 --- /dev/null +++ b/demo/auth_demo/oauth_demo.py @@ -0,0 +1,222 @@ +''' +A demonstration of connecting to Pardot using both +a pardot-only user and the existing api and +using a sf user utilizing the expanded api. + +This demonstration requires a configuration file. +By default it looks for a file called +`oauth_demo.ini` in the users home directory +(see __main__ at the bottom of this file). + +This file is used to supply the authentication data +needed to run this code. In this package is +a file `pardot_demo.ini` that contains the structure +of the config file and a description of what values +need to be provided. +''' +__author__ = 'eb' + +import logging +from logging import Logger +from pathlib import Path +from typing import Tuple, Dict, List +from configparser import ConfigParser + +import requests + +from auth_pardot_api import AuthPardotAPI +from client import PardotAPI +from auth_handler import T as AuthHndlr, TraditionalAuthHandler, OAuthHandler + + +class PardotAuthenticationDemo(object): + + def __init__(self, config_file: Path, logger: Logger = None) -> None: + super().__init__() + self.config_file = config_file + self.logger = logger + self.parser = ConfigParser() + self.parser.read(config_file) + + def run(self): + # Demonstrate accessing pardot the traditional way using + # a Pardot-Only user + self.access_pardot_using_pardot_only_user() + + # Demonstrate the formation of and responses to the low + # level requests needed to access pardot using + # a salesforce user via SSO using OAuth2 + self.access_pardot_via_sso_using_raw_requests() + + # Demonstrate accessing pardot using + # a salesforce user via SSO using OAuth2 + # using the AuthPardotAPI sub-class of the existing PardotAPI class + self.access_pardot_via_sso_in_enhanced_api() + + def access_pardot_using_pardot_only_user(self): + """ + Use the existing PardotAPI to fetch prospect data via a pardot-only user. + """ + + # Using the traditional PyPardot4 PardotAPI class + self.logger and self.logger.info("\tAccess Pardot via PardotAPI using Pardot-Only User") + if self.has_sections(["test_data", "pardot"]): + auth_handler = self.get_auth_handler("pardot") + pd = PardotAPI(auth_handler.username, auth_handler.password, auth_handler.userkey) + pd.authenticate() + self.query_pardot_api(pd, "pardot") + + # using a TraditionalAuthHandler via the enhanced AuthPardotAPI + self.logger and self.logger.info("\tAccess Pardot via AuthPardotAPI using Pardot-Only User") + if self.has_sections(["test_data", "pardot"]): + auth_handler = self.get_auth_handler("pardot") + pd = AuthPardotAPI(auth_handler, logger=self.logger) + pd.authenticate() + self.query_pardot_api(pd, "pardot") + + # To a sandbox using a TraditionalAuthHandler via the enhanced AuthPardotAPI + self.logger and self.logger.info("\tAccess Pardot Sandbox via AuthPardotAPI using Pardot-Only User") + if self.has_sections(["test_data", "pardot_sandbox"]): + auth_handler = self.get_auth_handler("pardot_sandbox") + pd = AuthPardotAPI(auth_handler, logger=self.logger) + pd.authenticate() + self.query_pardot_api(pd, "pardot_sandbox") + self.logger and self.logger.info("\t\t...Success") + + def access_pardot_via_sso_using_raw_requests(self): + """ + Demonstrate the formation of and responses to the low + level requests needed to access pardot using + a salesforce user via SSO using OAuth2 + """ + self.logger and self.logger.info("\tAccess Pardot via SF SSO Using Raw Requests") + if self.has_sections(["test_data", "salesforce"]): + access_token, bus_unit_id = self.retrieve_access_token() + prospects = self.send_pardot_request(access_token, bus_unit_id) + self.logger and self.logger.info("\t\t...Success") + + def access_pardot_via_sso_in_enhanced_api(self): + """ + Use the AuthPardotAPI to fetch prospect data via sso using OAuth2 authentication + """ + + # Accessing a production pardot server using credentials from a production salesforce instance + self.logger and self.logger.info("\tAccess Pardot via SF SSO") + if self.has_sections(["test_data", "salesforce"]): + auth_handler = self.get_auth_handler("salesforce") + pd = AuthPardotAPI(auth_handler, logger=self.logger) + self.query_pardot_api(pd, "salesforce") + self.logger and self.logger.info("\t\t...Success") + + # Accessing a pardot sandbox server using credentials from a salesforce sandbox instance + # Commented out because I can't test this, our pardot sandbox can not be authenticated using SSO. + # self.logger and self.logger.info("\tAccess Pardot Sandbox via SF Sandbox SSO") + # if self.has_sections(["test_data", "salesforce_sandbox"]): + # auth_handler = self.get_auth_handler("salesforce_sandbox") + # pd = AuthPardotAPI(auth_handler, logger=self.logger) + # self.query_pardot_api(pd, "salesforce_sandbox") + # self.logger and self.logger.info("\t\t...Success") + + def query_pardot_api(self, pd: PardotAPI, section: str) -> Tuple[Dict, List]: + """ + Demonstrate and return prospect data fetched from pardot using the pardot api. + This code purposefully includes the use of the api read_by_email() and query() method, + because the former utilizes an http post() while the later utilizes an http get(). + This ensures the demonstration includes both forms of interaction. + """ + prospect_email = self.parser.get("test_data", "prospect_email") + response = pd.prospects.read_by_email(email=prospect_email) + prospect = response["prospect"] + self.logger and self.logger.info( + f"\t\tFound Prospect in {section}: {prospect['first_name']} {prospect['last_name']} for email {prospect['email']}") + + prospect_date_filter = self.parser.get("test_data", "prospect_date_filter", fallback="2021-01-01") + response = pd.prospects.query(created_after=prospect_date_filter) + prospects = response["prospect"] + self.logger and self.logger.info( + f"\t\tFound {len(prospects)} Prospects in {section} created after {prospect_date_filter}:") + for p in prospects: + self.logger and self.logger.debug( + f"\t\t\t{p['created_at']}: {p['first_name']} {p['last_name']} <{p['email']}>") + + return prospect, prospects + + def retrieve_access_token(self): + self.logger and self.logger.debug("\t\tAuthenticate Pardot with with OAuth2 parameters from 'salesforce'") + auth_handler = self.get_auth_handler("salesforce") + params = { + "grant_type": "password", + "client_id": auth_handler.consumer_key, + "client_secret": auth_handler.consumer_secret, + "username": auth_handler.username, + "password": auth_handler.password + auth_handler.token + } + url = "https://login.salesforce.com/services/oauth2/token" + r = requests.post(url, params=params) + access_token = r.json().get("access_token") + instance_url = r.json().get("instance_url") + self.logger and self.logger.debug( + f"\t\tRetrieved oauth access_token for {instance_url}") + + return access_token, auth_handler.business_unit_id + + def send_pardot_request(self, access_token, bus_unit_id) -> List: + prospect_date_filter = self.parser.get("test_data", "prospect_date_filter", fallback="2021-01-01") + params = {"format": "json", + "created_after": prospect_date_filter} + headers = {"Authorization": f"Bearer {access_token}", + "Pardot-Business-Unit-Id": bus_unit_id, + "Content-Type": "application/x-www-form-urlencoded" + } + url = "https://pi.pardot.com/api/prospect/version/4/do/query" + response = requests.post(url, + params=params, + headers=headers).json() + if '@attributes' not in response or 'stat' not in response['@attributes']: + raise ValueError(f"Pardot Request Failure: Corrupted Response") + if response['@attributes']['stat'] != "ok": + raise ValueError(f"Pardot Request Failure: {response['@attributes']['stat']}") + + prospects = response["result"]["prospect"] + self.logger and self.logger.info( + f"\t\tFound {len(prospects)} Prospects in Production created after {prospect_date_filter}:") + for p in prospects: + self.logger and self.logger.debug( + f"\t\t\t{p['created_at']}: {p['first_name']} {p['last_name']} <{p['email']}>") + return prospects + + def get_auth_handler(self, section_name: str) -> AuthHndlr: + if section_name.startswith("pardot"): + return TraditionalAuthHandler(self.parser.get(section_name, "username"), + self.parser.get(section_name, "password"), + self.parser.get(section_name, "userkey"), + logger=self.logger) + elif section_name.startswith("salesforce"): + return OAuthHandler(self.parser.get(section_name, "user"), + self.parser.get(section_name, "password"), + self.parser.get(section_name, "consumer_key"), + self.parser.get(section_name, "consumer_secret"), + self.parser.get(section_name, "business_unit_id"), + token=self.parser.get(section_name, "token"), + logger=self.logger) + + def has_sections(self, sections: List[str]) -> bool: + missing = [s for s in sections if s not in self.parser.sections()] + valid = len(missing) == 0 + if not valid: + self.logger and self.logger.info(f"\t\t...Skip, missing sections {missing} " + f"from config file {self.config_file}") + return valid + + +if __name__ == '__main__': + logger = logging.getLogger("OAUTH_DEMO") + logger.setLevel(logging.INFO) # Change to logging.DEBUG for more detailed output + ch = logging.StreamHandler() + formatter = logging.Formatter('[{levelname:>8s}] {asctime} {name:s}: {message:s}', style='{') + ch.setFormatter(formatter) + ch.setLevel(logging.NOTSET) + logger.addHandler(ch) + config_file = Path("~/oauth_demo.ini").expanduser() + demo = PardotAuthenticationDemo(config_file, logger=logger) + demo.run() diff --git a/demo/auth_demo/pardot_demo.ini b/demo/auth_demo/pardot_demo.ini new file mode 100644 index 0000000..36f32d5 --- /dev/null +++ b/demo/auth_demo/pardot_demo.ini @@ -0,0 +1,33 @@ +############################### +[pardot] +username= +password= +userkeyn= +prospect_email= +prospect_date_filter= + +[pardot_sandbox] +username= +password= +userkeyn= + +[salesforce] +user= +password= +token= +consumer_key= +consumer_secret= +business_unit_id= + +[salesforce_sandbox] +user= +password= +token= +consumer_key= +consumer_secret= +business_unit_id= + +[test_data] +prospect_email= +prospect_date_filter= +############################### diff --git a/pypardot/auth_handler.py b/pypardot/auth_handler.py new file mode 100644 index 0000000..7e0039b --- /dev/null +++ b/pypardot/auth_handler.py @@ -0,0 +1,117 @@ +''' + +''' +__author__ = 'eb' + +from logging import Logger +from typing import Optional, Dict, TypeVar, Generic + +import requests + +T = TypeVar("T", bound="AuthHandler") + + +class AuthHandler(Generic[T]): + """Abstract base class for handling authentication""" + + def __init__(self, logger: Optional[Logger] = None) -> None: + super().__init__() + self.logger = logger + + def handle_authentication(self) -> bool: + raise NotImplementedError(f"handle_authentication() called on abstract base class {self.__class__}") + + def auth_header(self) -> Dict: + raise NotImplementedError(f"auth_header() called on abstract base class {self.__class__}") + + def __repr__(self): + return f"{self.__class__.__name__}" + + +class UserAuthHandler(AuthHandler[T]): + """Abstract base class for handling authentication that requires a username and password""" + + def __init__(self, username: str, password: str, **kwargs) -> None: + super().__init__(**kwargs) + self.username = username + self.password = password + + def __repr__(self): + return f"{super().__repr__()}: UN:{self.username[:10]}..." + + def get_userkey(self) -> Optional[str]: + raise NotImplementedError( + f"get_userkey() called on abstract base class {self.__class__}") + + + def handle_authentication(self) -> bool: + raise NotImplementedError( + f"handle_authentication() called on {self.__class__}. Should be handled by PardotAPI") + + def auth_header(self) -> Dict: + raise NotImplementedError( + f"auth_header() called on {self.__class__}. Should be handled by PardotAPI") + + + +class TraditionalAuthHandler(UserAuthHandler[T]): + """Handles authentication of Pardot-only users""" + + def __init__(self, username: str, password: str, userkey: str, **kwargs) -> None: + super().__init__(username=username, password=password, **kwargs) + self.userkey = userkey + + def __repr__(self): + return f"{super().__repr__()}, UK:{self.userkey[:10]}..." + + def get_userkey(self) -> Optional[str]: + return self.userkey + + + +class OAuthHandler(UserAuthHandler[T]): + """ + Handles authentication of SSO Pardot users via OAuth2 when + dependent on username, password, consumer_key, consumer_secret and busines_unit_id. + """ + + def __init__(self, username: str, password: str, + consumer_key: str, consumer_secret: str, business_unit_id: str, + token: Optional[str] = None, is_sandbox: bool = False, **kwargs) -> None: + super().__init__(username=username, password=password, **kwargs) + self.consumer_key = consumer_key + self.consumer_secret = consumer_secret + self.business_unit_id: Optional[str] = business_unit_id + self.token = token + self.is_sandbox = is_sandbox + self.access_token: Optional[str] = None + + def __repr__(self): + at_label = f"{self.access_token[:10]}..." if self.access_token else "None" + return f"{super().__repr__()}, CK:{self.consumer_key[:10]}..., CS:{self.consumer_secret[:10]}..., " \ + f"BU:{self.business_unit_id} AT:{at_label}" + + + def get_userkey(self) -> Optional[str]: + return None + + def handle_authentication(self) -> bool: + params = { + "grant_type": "password", + "client_id": self.consumer_key, + "client_secret": self.consumer_secret, + "username": self.username, + "password": (self.password + self.token) if self.token is not None else self.password + } + prefix = "test" if self.is_sandbox else "login" + url = f"https://{prefix}.salesforce.com/services/oauth2/token" + r = requests.post(url, params=params) + content = r.json() + self.access_token = content.get("access_token") + self.logger and self.logger.debug(f"OAuthHandler: Retrieved oauth access_token for {content.get('instance_url')}") + return True + + def auth_header(self) -> Dict: + return {"Authorization": f"Bearer {self.access_token}", + "Pardot-Business-Unit-Id": self.business_unit_id, + "Content-Type": "application/x-www-form-urlencoded"} diff --git a/pypardot/auth_pardot_api.py b/pypardot/auth_pardot_api.py new file mode 100644 index 0000000..92ab612 --- /dev/null +++ b/pypardot/auth_pardot_api.py @@ -0,0 +1,92 @@ +''' +A demonstration of how to support OAuth2 +using Salesforce for authentication after 2/15/2021. + +This implementation uses a subclass of the standard PardotAPI class +to demonstrate a solution without any modifications to the +original PyPardot4 code. In this way a solution is suggested +without enforcing an architecture on the maintainers of the code. + +It would be expected that this code would be retrofitted into +the codebase for later releases that support OAuth2. +''' +__author__ = 'eb' + +from logging import Logger +from typing import Optional, cast + +import requests + +from auth_handler import AuthHandler, UserAuthHandler, OAuthHandler, TraditionalAuthHandler +from client import PardotAPI +from errors import PardotAPIError + + +class AuthPardotAPI(PardotAPI): + + def __init__(self, auth_handler: UserAuthHandler, version=4, logger: Optional[Logger] = None): + super().__init__(auth_handler.username, auth_handler.password, auth_handler.get_userkey(), version) + self.auth_handler: Optional[AuthHandler] = auth_handler + self.logger = logger + + def use_username_authorization(self) -> bool: + return self.auth_handler is None or isinstance(self.auth_handler, TraditionalAuthHandler) + + def authenticate(self): + if self.use_username_authorization(): + self.logger and self.logger.debug(f"AuthPardotAPI: Authenticate Pardot with Pardot-Only user {self.email[:10]}...") + return super().authenticate() + else: + oauth_handler: OAuthHandler = cast(OAuthHandler, self.auth_handler) + self.logger and self.logger.debug(f"AuthPardotAPI: Authenticate Pardot with OAuth2 key {oauth_handler.consumer_key[:10]}...") + success = self.auth_handler.handle_authentication() + return success + + def post(self, object_name, path=None, params=None, retries=0): + """ + Uses the default methodology if oauth is not required. + + Makes a POST request to the API. Checks for invalid requests that raise PardotAPIErrors. If the API key is + invalid, one re-authentication request is made, in case the key has simply expired. If no errors are raised, + returns either the JSON response, or if no JSON was returned, returns the HTTP response status code. + """ + if self.use_username_authorization(): + return super().post(object_name, path=path, params=params, retries=retries) + + params = {} if params is None else params + params.update({'format': 'json'}) + try: + self._check_auth(object_name=object_name) + headers = self._build_auth_header() + request = requests.post(self._full_path(object_name, self.version, path), data=params, headers=headers) + response = self._check_response(request) + return response + except PardotAPIError as err: + if err.message == 'access_token is invalid, unknown, or malformed': + # The handle_expired should work fine + response = self._handle_expired_api_key(err, retries, 'post', object_name, path, params) + return response + else: + raise err + + # Did not need to overload get because it uses _build_auth_header() + # already, so proper authorization is included in the request + # def get(self, object_name, path=None, params=None, retries=0): + # pass + + def _check_auth(self, object_name): + if self.use_username_authorization(): + return super()._check_auth(object_name) + + oauth_handler: OAuthHandler = cast(OAuthHandler, self.auth_handler) + if oauth_handler.access_token is None: + self.authenticate() + + def _build_auth_header(self): + if self.use_username_authorization(): + return super()._build_auth_header() + + oauth_handler: OAuthHandler = cast(OAuthHandler, self.auth_handler) + if not oauth_handler.access_token: + raise Exception('Cannot build Authorization header. access_token or bus_unit_id is empty') + return oauth_handler.auth_header() diff --git a/pypardot/client.py b/pypardot/client.py index 0c4cc47..e7773b4 100644 --- a/pypardot/client.py +++ b/pypardot/client.py @@ -1,28 +1,28 @@ import requests -from .objects.accounts import Accounts -from .objects.customfields import CustomFields -from .objects.customredirects import CustomRedirects -from .objects.dynamiccontent import DynamicContent -from .objects.emailclicks import EmailClicks -from .objects.emailtemplates import EmailTemplates -from .objects.forms import Forms -from .objects.lifecyclehistories import LifecycleHistories -from .objects.lifecyclestages import LifecycleStages -from .objects.lists import Lists -from .objects.listmemberships import ListMemberships -from .objects.emails import Emails -from .objects.prospects import Prospects -from .objects.opportunities import Opportunities -from .objects.prospectaccounts import ProspectAccounts -from .objects.tags import Tags -from .objects.tagobjects import TagObjects -from .objects.users import Users -from .objects.visits import Visits -from .objects.visitors import Visitors -from .objects.visitoractivities import VisitorActivities -from .objects.campaigns import Campaigns +from objects.accounts import Accounts +from objects.customfields import CustomFields +from objects.customredirects import CustomRedirects +from objects.dynamiccontent import DynamicContent +from objects.emailclicks import EmailClicks +from objects.emailtemplates import EmailTemplates +from objects.forms import Forms +from objects.lifecyclehistories import LifecycleHistories +from objects.lifecyclestages import LifecycleStages +from objects.lists import Lists +from objects.listmemberships import ListMemberships +from objects.emails import Emails +from objects.prospects import Prospects +from objects.opportunities import Opportunities +from objects.prospectaccounts import ProspectAccounts +from objects.tags import Tags +from objects.tagobjects import TagObjects +from objects.users import Users +from objects.visits import Visits +from objects.visitors import Visitors +from objects.visitoractivities import VisitorActivities +from objects.campaigns import Campaigns -from .errors import PardotAPIError +from errors import PardotAPIError # Issue #1 (http://code.google.com/p/pybing/issues/detail?id=1) # Python 2.6 has json built in, 2.5 needs simplejson @@ -175,4 +175,4 @@ def _build_auth_header(self): if not self.user_key or not self.api_key: raise Exception('Cannot build Authorization header. user or api key is empty') auth_string = 'Pardot api_key=%s, user_key=%s' % (self.api_key, self.user_key) - return {'Authorization': auth_string} \ No newline at end of file + return {'Authorization': auth_string} diff --git a/pypardot/objects/emailtemplates.py b/pypardot/objects/emailtemplates.py index 49fc624..adcb2a3 100644 --- a/pypardot/objects/emailtemplates.py +++ b/pypardot/objects/emailtemplates.py @@ -1,4 +1,4 @@ -from ..errors import PardotAPIArgumentError +from errors import PardotAPIArgumentError class EmailTemplates(object): diff --git a/pypardot/objects/listmemberships.py b/pypardot/objects/listmemberships.py index 0d47411..08c8638 100644 --- a/pypardot/objects/listmemberships.py +++ b/pypardot/objects/listmemberships.py @@ -1,4 +1,4 @@ -from ..errors import PardotAPIArgumentError +from errors import PardotAPIArgumentError class ListMemberships(object): diff --git a/pypardot/objects/prospects.py b/pypardot/objects/prospects.py index 6251e3c..77e6115 100644 --- a/pypardot/objects/prospects.py +++ b/pypardot/objects/prospects.py @@ -1,4 +1,4 @@ -from ..errors import PardotAPIArgumentError +from errors import PardotAPIArgumentError class Prospects(object): @@ -94,7 +94,7 @@ def read_by_id(self, id=None, **kwargs): raise PardotAPIArgumentError('id is required to read a prospect.') response = self._post(path='/do/read/id/{id}'.format(id=id), params=kwargs) return response - + def read_by_fid(self, fid=None, **kwargs): """ Returns data for the prospect specified by . must be a valid CRM FID. @@ -227,4 +227,4 @@ def _post(self, object_name='prospect', path=None, params=None): if params is None: params = {} response = self.client.post(object_name=object_name, path=path, params=params) - return response \ No newline at end of file + return response