From f5134af03ab5d4d092e3560a5996ec5f9bbcb0f5 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Date: Tue, 26 Sep 2017 21:12:23 +0100 Subject: [PATCH 01/17] Add support for custom fields. --- prospyr/fields.py | 13 ++++++++-- prospyr/mixins.py | 32 ++++++++++++++++++++++- prospyr/resources.py | 47 +++++++++++++++++++++------------ prospyr/schema.py | 13 +++++++--- tests/custom_fields.json | 36 ++++++++++++++++++++++++++ tests/person.json | 4 --- tests/test_mixins.py | 2 ++ tests/test_resources.py | 56 ++++++++++++++++++++++++++-------------- 8 files changed, 157 insertions(+), 46 deletions(-) create mode 100644 tests/custom_fields.json diff --git a/prospyr/fields.py b/prospyr/fields.py index 364b512..c4feae5 100644 --- a/prospyr/fields.py +++ b/prospyr/fields.py @@ -19,6 +19,7 @@ class Unix(fields.Field): """ datetime.datetime <-> unix timestamp """ + def _serialize(self, value, attr, obj): try: return arrow.get(value).timestamp @@ -36,6 +37,7 @@ class Email(fields.Email): """ ProsperWorks emails can have leading and trailing spaces. """ + def __init__(self, *args, **kwargs): super(Email, self).__init__(self, *args, **kwargs) @@ -61,6 +63,7 @@ def normalise_many(fn, default=False): wrapped they can assume `value` is a collection. From there, (self, values, attr, data) makes more sense as the signature. """ + @wraps(fn) def wrapper(self, value, attr, data): many = getattr(self, 'many', default) @@ -71,6 +74,7 @@ def wrapper(self, value, attr, data): return res[0] else: return res + return wrapper @@ -84,11 +88,12 @@ class NestedResource(fields.Field): """ def __init__(self, resource_cls, default=missing_, many=False, - id_only=False, **kwargs): + id_only=False, custom_field=False, **kwargs): self.resource_cls = resource_cls self.schema = type(resource_cls.Meta.schema) self.many = many self.id_only = id_only + self.custom_field = custom_field super(NestedResource, self).__init__(default=default, many=many, **kwargs) @@ -98,6 +103,10 @@ def _deserialize(self, values, attr, data): for value in values: if self.id_only: resources.append(self.resource_cls.objects.get(id=value['id'])) + elif self.custom_field: + resource = self.resource_cls.objects.get(id=value['custom_field_definition_id']) + resource.value = value['value'] + resources.append(resource) else: resources.append(self.resource_cls.from_api_data(value)) return resources @@ -147,7 +156,7 @@ def _deserialize(self, values, attr, data): # the resource isn't modelled yet from prospyr.resources import Placeholder name = encode_typename(self.placeholder_types[idtype]) - resource_cls = type(name, (Placeholder, ), {}) + resource_cls = type(name, (Placeholder,), {}) resource = resource_cls(id=value['id']) else: # modelled resource; fetch diff --git a/prospyr/mixins.py b/prospyr/mixins.py index 7058f2d..b65d99b 100644 --- a/prospyr/mixins.py +++ b/prospyr/mixins.py @@ -3,7 +3,7 @@ from __future__ import absolute_import, print_function, unicode_literals from logging import getLogger - +from datetime import datetime from requests import codes from prospyr.exceptions import ApiError @@ -134,3 +134,33 @@ def delete(self, using='default'): class ReadWritable(Creatable, Readable, Updateable, Deletable): pass + + +class CustomFieldMixin(object): + class Meta: + abstract = True + + def get_custom_field_value(cls, field_name): + """ + Return custom field value depending on data type. + """ + value = '' + for field in cls.custom_fields: + if field.name == field_name: + if field.value: + if field.data_type in ['String', 'Text', 'Float', 'URL', 'Percentage', 'Currency']: + value = field.value + elif field.data_type == 'Dropdown': + for option in field.options: + if option['id'] == field.value: + value = option['name'] + elif field.data_type == 'MultiSelect': + values = [] + for val in field.value: + for option in field.options: + if option['id'] == val: + values.append(option['name']) + value = ','.join(values) + elif field.data_type == 'Date': + value = datetime.fromtimestamp(field.value).date() + return value diff --git a/prospyr/resources.py b/prospyr/resources.py index 522561e..4b32aca 100644 --- a/prospyr/resources.py +++ b/prospyr/resources.py @@ -12,6 +12,7 @@ from prospyr import connection, exceptions, mixins, schema from prospyr.exceptions import ApiError, ProspyrException from prospyr.fields import NestedIdentifiedResource, NestedResource, Unix +from prospyr.mixins import CustomFieldMixin from prospyr.search import ActivityTypeListSet, ListSet, ResultSet from prospyr.util import encode_typename, import_dotted_path, to_snake @@ -19,7 +20,6 @@ class Manager(object): - _search_cls = ResultSet def get(self, id): @@ -155,6 +155,7 @@ class ResourceMeta(type): Pulls marshmallow schema fields onto a Schema definition. """ + class Meta(object): abstract = True @@ -177,7 +178,7 @@ def __new__(cls, name, bases, attrs): schema_attrs = value.modify_schema_attrs(attr, schema_attrs) schema_cls = type( encode_typename('%sSchema' % name), - (schema.TrimSchema, ), + (schema.TrimSchema,), schema_attrs ) if 'Meta' not in attrs: @@ -285,6 +286,7 @@ class SecondaryResource(Resource): """ Secondary resources have only a list URL. """ + class Meta: abstract = True @@ -320,12 +322,12 @@ def __set__(self, instance, value): if not isinstance(value, self.related_cls): raise ValueError( '`{value}` must be an instance of `{cls}`' - .format(value=value, cls=self.related_cls) + .format(value=value, cls=self.related_cls) ) if not value.id: raise ValueError( '`{value}` can\'t be assigned without an `id` attribute.' - .format(value=value) + .format(value=value) ) setattr(instance, '%s_id' % attr, value.id) @@ -343,8 +345,23 @@ def modify_schema_attrs(self, self_attr, schema_attrs): return schema_attrs -class User(Resource, mixins.Readable): +class CustomField(Resource, mixins.Readable): + class Meta(object): + list_path = 'custom_field_definitions/' + detail_path = 'custom_field_definitions/{id}' + id = fields.Integer() + name = fields.String() + data_type = fields.String() + currency = fields.String() + options = fields.List(fields.Dict()) + value = fields.String(allow_none=True) + + def __str__(self): + return "{} - {}".format(self.name, self.data_type) + + +class User(Resource, mixins.Readable): class Meta(object): list_path = 'users/' detail_path = 'users/{id}/' @@ -359,8 +376,7 @@ def __str__(self): return '{self.name} ({self.email})'.format(self=self) -class Company(Resource, mixins.ReadWritable): - +class Company(CustomFieldMixin, Resource, mixins.ReadWritable): class Meta(object): create_path = 'companies/' search_path = 'companies/search/' @@ -390,12 +406,11 @@ class Meta(object): tags = fields.List(fields.String) date_created = Unix() date_modified = Unix() - # TODO custom_fields = ... websites = fields.Nested(schema.WebsiteSchema, many=True) + custom_fields = NestedResource(CustomField, many=True, schema=schema.CustomFieldSchema, custom_field=True) -class Person(Resource, mixins.ReadWritable): - +class Person(CustomFieldMixin, Resource, mixins.ReadWritable): class Meta(object): create_path = 'people/' search_path = 'people/search/' @@ -439,8 +454,8 @@ class Meta(object): title = fields.String(allow_none=True) date_created = Unix() date_modified = Unix() - # TODO custom_fields = ... websites = fields.Nested(schema.WebsiteSchema, many=True) + custom_fields = NestedResource(CustomField, many=True, schema=schema.CustomFieldSchema, custom_field=True) class LossReason(SecondaryResource, mixins.Readable): @@ -480,7 +495,7 @@ class Meta(object): name = fields.String(required=True) -class Opportunity(Resource, mixins.ReadWritable): +class Opportunity(CustomFieldMixin, Resource, mixins.ReadWritable): class Meta(object): create_path = 'opportunities/' search_path = 'opportunities/search/' @@ -527,6 +542,7 @@ class Meta(object): win_probability = fields.Integer() date_created = Unix() date_modified = Unix() + custom_fields = NestedResource(CustomField, many=True, schema=schema.CustomFieldSchema, custom_field=True) class ActivityType(SecondaryResource, mixins.Readable): @@ -545,7 +561,6 @@ class Meta(object): class Identifier(SecondaryResource): - class Meta: pass @@ -638,7 +653,7 @@ class Meta: date_modified = Unix() -class Lead(Resource, mixins.ReadWritable): +class Lead(CustomFieldMixin, Resource, mixins.ReadWritable): class Meta: create_path = 'leads/' search_path = 'leads/search' @@ -677,13 +692,12 @@ class Meta: tags = fields.List(fields.String) title = fields.String(allow_none=True) websites = fields.Nested(schema.WebsiteSchema, many=True) - # TODO custom_fields = ... date_created = Unix() date_modified = Unix() + custom_fields = NestedResource(CustomField, many=True, schema=schema.CustomFieldSchema, custom_field=True) class Account(Resource, mixins.Singleton): - objects = SingletonManager() class Meta: @@ -694,7 +708,6 @@ class Meta: class Webhook(Resource, mixins.Readable): - class Meta(object): list_path = 'webhooks/' detail_path = 'webhooks/{id}/' diff --git a/prospyr/schema.py b/prospyr/schema.py index 275504d..7e9d7f3 100644 --- a/prospyr/schema.py +++ b/prospyr/schema.py @@ -39,6 +39,7 @@ class NamedTupleSchema(Schema): """ (De)serialise to namedtuple instead of dict """ + def __init__(self, *args, **kwargs): super(NamedTupleSchema, self).__init__(*args, **kwargs) name = type(self).__name__.replace('Schema', '') @@ -63,7 +64,7 @@ class EmailSchema(NamedTupleSchema): class WebsiteSchema(NamedTupleSchema): url = fields.String() # PW does not validate URLs so neither do we - category = fields.String() + category = fields.String(allow_none=True) class SocialSchema(NamedTupleSchema): @@ -76,9 +77,15 @@ class PhoneNumberSchema(NamedTupleSchema): category = fields.String() -class CustomFieldSchema(Schema): +class CustomFieldSchema(NamedTupleSchema): custom_field_definition_id = fields.Integer() - value = fields.String() # TODO base this on field definition + # value = fields.String(allow_none=True) # TODO base this on field definition + + +class CustomFieldOptionSchema(Schema): + id = fields.Number() + rank = fields.Number() + name = fields.String() class AddressSchema(Schema): diff --git a/tests/custom_fields.json b/tests/custom_fields.json new file mode 100644 index 0000000..7686cf4 --- /dev/null +++ b/tests/custom_fields.json @@ -0,0 +1,36 @@ +{ + "id": 126240, + "name": "Color option", + "data_type": "Dropdown", + "available_on": [ + "opportunity", + "project" + ], + "options": [ + { + "id": 167776, + "name": "Yellow", + "rank": 4 + }, + { + "id": 167775, + "name": "Orange", + "rank": 3 + }, + { + "id": 167774, + "name": "Blue", + "rank": 2 + }, + { + "id": 167773, + "name": "Green", + "rank": 1 + }, + { + "id": 167772, + "name": "Red", + "rank": 0 + } + ] +} \ No newline at end of file diff --git a/tests/person.json b/tests/person.json index 9953377..6f96832 100644 --- a/tests/person.json +++ b/tests/person.json @@ -64,10 +64,6 @@ { "custom_field_definition_id": 123, "value": "string value" - }, - { - "custom_field_definition_id": 456, - "value": "06/20/2015" } ] } diff --git a/tests/test_mixins.py b/tests/test_mixins.py index 73129ed..6ad36ea 100644 --- a/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -21,6 +21,8 @@ def test_create(): # can create content = json.loads(load_fixture_json('person.json')) + # no support for creation with custom fields + del content['custom_fields'] cn = make_cn_with_resp( method='post', status_code=codes.ok, diff --git a/tests/test_resources.py b/tests/test_resources.py index 9f7ee24..a9cc727 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -34,6 +34,7 @@ class MockSession(object): Anything not in `urls` is 404 Not Founded. """ + def __init__(self, urls): self.urls = urls @@ -55,9 +56,11 @@ def post(self, url, *args, **kwargs): @reset_conns def test_read(): cn = connect(email='foo', token='bar') - cn.session = MockSession(urls={ - cn.build_absolute_url('people/1/'): load_fixture_json('person.json') - }) + mocked_urls = { + cn.build_absolute_url('people/1/'): load_fixture_json('person.json'), + cn.build_absolute_url('custom_field_definitions/123'): load_fixture_json('custom_fields.json') + } + cn.session = MockSession(urls=mocked_urls) jon = Person(id=1) jon.read() assert_is_jon(jon) @@ -66,9 +69,11 @@ def test_read(): @reset_conns def test_manager_get(): cn = connect(email='foo', token='bar') - cn.session = MockSession(urls={ - cn.build_absolute_url('people/1/'): load_fixture_json('person.json') - }) + mocked_urls = { + cn.build_absolute_url('people/1/'): load_fixture_json('person.json'), + cn.build_absolute_url('custom_field_definitions/123'): load_fixture_json('custom_fields.json') # noqa + } + cn.session = MockSession(urls=mocked_urls) jon = Person.objects.get(id=1) assert_is_jon(jon) @@ -84,19 +89,23 @@ def test_no_instance_access_to_manager(): @reset_conns -def test_manager_connection_assignment(): - cn_without_jon = connect(email='foo', token='bar', name='without_jon') - cn_with_jon = connect(email='foo', token='bar', name='with_jon') +def test_manager_connection_assignment_with(): + cn = connect(email='foo', token='bar') - cn_without_jon.session = MockSession(urls={}) - cn_with_jon.session = MockSession(urls={ - cn_with_jon.build_absolute_url('people/1/'): load_fixture_json('person.json') # noqa + cn.session = MockSession(urls={ + cn.build_absolute_url('people/1/'): load_fixture_json('person.json'), + cn.build_absolute_url('custom_field_definitions/123'): load_fixture_json('custom_fields.json') }) - - jon = Person.objects.use('with_jon').get(id=1) + jon = Person.objects.get(id=1) assert_is_jon(jon) + + +@reset_conns +def test_manager_connection_assignment_without(): + cn = connect(email='foo', token='bar') + cn.session = MockSession(urls={}) with assert_raises(exceptions.ApiError): - jon = Person.objects.use('without_jon').get(id=1) + jon = Person.objects.get(id=1) def test_resource_validation(): @@ -109,9 +118,15 @@ def test_resource_validation(): with assert_raises(exceptions.ValidationError): albert.validate() - +@reset_conns def test_construct_from_api_data(): + cn = connect(email='foo', token='bar') + cn.session = MockSession(urls={ + cn.build_absolute_url('custom_field_definitions/123'): load_fixture_json('custom_fields.json') + }) + data = json.loads(load_fixture_json('person.json')) + jon = Person.from_api_data(data) assert_is_jon(jon) @@ -143,6 +158,7 @@ def test_str_does_not_raise(): @reset_conns def test_id_or_email_required_for_person(): cn = connect(email='foo', token='bar') + cn.session = MockSession(urls={ cn.build_absolute_url('people/1/'): load_fixture_json('person.json') }) @@ -153,8 +169,10 @@ def test_id_or_email_required_for_person(): @reset_conns def test_get_person_by_email(): cn = connect(email='foo', token='bar') - cn.session = MockSession(urls={ - cn.build_absolute_url('people/fetch_by_email/'): load_fixture_json('person.json') # noqa - }) + mocked_urls = { + cn.build_absolute_url('people/fetch_by_email/'): load_fixture_json('person.json'), # noqa + cn.build_absolute_url('custom_field_definitions/123'): load_fixture_json('custom_fields.json') + } + cn.session = MockSession(urls=mocked_urls) person = Person.objects.get(email='support@prosperworks.com') assert_is_jon(person) From 37819918021da2c30e437e27ef3450a7ac62bc38 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Date: Tue, 31 Jul 2018 16:30:43 +0100 Subject: [PATCH 02/17] Add support to update custom fields. --- prospyr/constants.py | 10 ++++++++ prospyr/mixins.py | 58 ++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 prospyr/constants.py diff --git a/prospyr/constants.py b/prospyr/constants.py new file mode 100644 index 0000000..513e312 --- /dev/null +++ b/prospyr/constants.py @@ -0,0 +1,10 @@ +TYPE_DATE = 'Date' +TYPE_DROPDOWN = 'Dropdown' +TYPE_MULTISELECT = 'MultiSelect' +TYPE_FLOAT = 'Float' +TYPE_DATE = 'Date' +TYPE_STRING = 'String' +TYPE_TEXT = 'Text' +TYPE_URL = 'URL' +TYPE_PERCENTAGE = 'Percentage' +TYPE_CURRENCY = 'Currency' diff --git a/prospyr/mixins.py b/prospyr/mixins.py index b65d99b..fb66c9e 100644 --- a/prospyr/mixins.py +++ b/prospyr/mixins.py @@ -7,6 +7,7 @@ from requests import codes from prospyr.exceptions import ApiError +from prospyr.constants import * logger = getLogger(__name__) @@ -59,7 +60,6 @@ def read(self, using='default'): resp = conn.get(conn.build_absolute_url(path)) if resp.status_code not in self._read_success_codes: raise ApiError(resp.status_code, resp.text) - data = self._load_raw(resp.json()) self._set_fields(data) return True @@ -96,8 +96,28 @@ def update(self, using='default'): # can't update IDs data = self._raw_data + data['custom_fields'] = [] data.pop('id') + # convert to PW style + for cf in self._raw_data['custom_fields']: + if 'value' in cf: + if cf['data_type'] == TYPE_DROPDOWN: + value = int(cf['value']) + elif cf['data_type'] == TYPE_MULTISELECT: + value = [int(v) for v in eval(cf['value'])] + elif cf['data_type'] == TYPE_FLOAT: + value = float(cf['value']) + elif cf['data_type'] == TYPE_DATE: + value = int(cf['value']) + else: + value = cf['value'] + else: + value = '' + data['custom_fields'].append( + {'custom_field_definition_id': cf['id'], 'value': value} + ) + conn = self._get_conn(using) path = self.Meta.detail_path.format(id=self.id) resp = conn.put(conn.build_absolute_url(path), json=data) @@ -148,19 +168,47 @@ def get_custom_field_value(cls, field_name): for field in cls.custom_fields: if field.name == field_name: if field.value: - if field.data_type in ['String', 'Text', 'Float', 'URL', 'Percentage', 'Currency']: + if field.data_type in [TYPE_STRING, TYPE_TEXT, TYPE_FLOAT, TYPE_URL, TYPE_PERCENTAGE, + TYPE_CURRENCY]: value = field.value - elif field.data_type == 'Dropdown': + elif field.data_type == TYPE_DROPDOWN: for option in field.options: if option['id'] == field.value: value = option['name'] - elif field.data_type == 'MultiSelect': + elif field.data_type == TYPE_MULTISELECT: values = [] for val in field.value: for option in field.options: if option['id'] == val: values.append(option['name']) value = ','.join(values) - elif field.data_type == 'Date': + elif field.data_type == TYPE_DATE: value = datetime.fromtimestamp(field.value).date() return value + + def set_custom_field_value(cls, field_name, value): + """ + Set custom field value. + """ + custom_fields = cls.custom_fields + index = 0 + for field in cls.custom_fields: + if field.name == field_name: + if field.data_type in [TYPE_STRING, TYPE_TEXT, TYPE_FLOAT, TYPE_URL, TYPE_PERCENTAGE, + TYPE_CURRENCY]: + custom_fields[index].value = value + elif field.data_type == TYPE_DROPDOWN: + for option in field.options: + if option['name'] == value: + custom_fields[index].value = option['id'] + elif field.data_type == TYPE_MULTISELECT: + values = [] + for val in value: + for option in field.options: + if option['name'] == val: + values.append(option['id']) + custom_fields[index].value = values + elif field.data_type == TYPE_DATE: + custom_fields[index].value = value.timestamp() + index += 1 + cls.custom_fields = custom_fields From 3f3da6e4f7085163badd61b249caff33b967c1cc Mon Sep 17 00:00:00 2001 From: Pedro Gomes Date: Tue, 31 Jul 2018 19:33:51 +0100 Subject: [PATCH 03/17] Add support to update custom fields. --- prospyr/mixins.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/prospyr/mixins.py b/prospyr/mixins.py index fb66c9e..c557727 100644 --- a/prospyr/mixins.py +++ b/prospyr/mixins.py @@ -117,7 +117,6 @@ def update(self, using='default'): data['custom_fields'].append( {'custom_field_definition_id': cf['id'], 'value': value} ) - conn = self._get_conn(using) path = self.Meta.detail_path.format(id=self.id) resp = conn.put(conn.build_absolute_url(path), json=data) @@ -194,12 +193,14 @@ def set_custom_field_value(cls, field_name, value): index = 0 for field in cls.custom_fields: if field.name == field_name: - if field.data_type in [TYPE_STRING, TYPE_TEXT, TYPE_FLOAT, TYPE_URL, TYPE_PERCENTAGE, - TYPE_CURRENCY]: + if value is None: + custom_fields[index].value = None + elif field.data_type in [TYPE_STRING, TYPE_TEXT, TYPE_FLOAT, TYPE_URL, TYPE_PERCENTAGE, + TYPE_CURRENCY]: custom_fields[index].value = value elif field.data_type == TYPE_DROPDOWN: for option in field.options: - if option['name'] == value: + if option['name'].lower().strip() == value.lower().strip(): custom_fields[index].value = option['id'] elif field.data_type == TYPE_MULTISELECT: values = [] From 9216afe6fac1d9d25425fa227a4b5fc5747fea17 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Date: Wed, 1 Aug 2018 15:47:20 +0100 Subject: [PATCH 04/17] Update set custom field for data type Date. --- prospyr/mixins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prospyr/mixins.py b/prospyr/mixins.py index c557727..f1962e2 100644 --- a/prospyr/mixins.py +++ b/prospyr/mixins.py @@ -210,6 +210,6 @@ def set_custom_field_value(cls, field_name, value): values.append(option['id']) custom_fields[index].value = values elif field.data_type == TYPE_DATE: - custom_fields[index].value = value.timestamp() + custom_fields[index].value = value index += 1 cls.custom_fields = custom_fields From 2bcc015783e97514f0716c6570fd75999e65d1de Mon Sep 17 00:00:00 2001 From: Pedro Gomes Date: Thu, 2 Aug 2018 18:36:02 +0100 Subject: [PATCH 05/17] Fix email issue. --- prospyr/mixins.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/prospyr/mixins.py b/prospyr/mixins.py index f1962e2..b1eeb7e 100644 --- a/prospyr/mixins.py +++ b/prospyr/mixins.py @@ -87,7 +87,7 @@ class Updateable(object): _update_success_codes = {codes.ok} - def update(self, using='default'): + def update(self, using='default', email=None): """ Update this Resource. True on success. """ @@ -103,13 +103,13 @@ def update(self, using='default'): for cf in self._raw_data['custom_fields']: if 'value' in cf: if cf['data_type'] == TYPE_DROPDOWN: - value = int(cf['value']) + value = int(cf['value']) if cf['value'] else None elif cf['data_type'] == TYPE_MULTISELECT: value = [int(v) for v in eval(cf['value'])] elif cf['data_type'] == TYPE_FLOAT: - value = float(cf['value']) + value = float(cf['value']) if cf['value'] else None elif cf['data_type'] == TYPE_DATE: - value = int(cf['value']) + value = int(cf['value']) if cf['value'] else None else: value = cf['value'] else: @@ -117,6 +117,8 @@ def update(self, using='default'): data['custom_fields'].append( {'custom_field_definition_id': cf['id'], 'value': value} ) + if email: + data['email']['email'] = email conn = self._get_conn(using) path = self.Meta.detail_path.format(id=self.id) resp = conn.put(conn.build_absolute_url(path), json=data) From ec9f0e1ead42cd1ab6b4f3684955af57690ad3d5 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Date: Wed, 5 Sep 2018 17:21:37 +0100 Subject: [PATCH 06/17] Fix email issue in Creatable mixin. --- prospyr/mixins.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/prospyr/mixins.py b/prospyr/mixins.py index b1eeb7e..26bf30f 100644 --- a/prospyr/mixins.py +++ b/prospyr/mixins.py @@ -20,7 +20,7 @@ class Creatable(object): # pworks uses 200 OK for creates. 201 CREATED is here through optimism. _create_success_codes = {codes.created, codes.ok} - def create(self, using='default'): + def create(self, using='default', email=None): """ Create a new instance of this Resource. True on success. """ @@ -30,7 +30,11 @@ def create(self, using='default'): ) conn = self._get_conn(using) path = self.Meta.create_path - resp = conn.post(conn.build_absolute_url(path), json=self._raw_data) + + data = self._raw_data + if email: + data['email'] = {'category': 'work', 'email': email} + resp = conn.post(conn.build_absolute_url(path), json=data) if resp.status_code in self._create_success_codes: data = self._load_raw(resp.json()) From 15b2cfae5f87abfe9d61ee6b08b598a2a00f6cb6 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Date: Wed, 12 Sep 2018 16:57:36 +0100 Subject: [PATCH 07/17] Add cache to custom fields. --- prospyr/fields.py | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/prospyr/fields.py b/prospyr/fields.py index c4feae5..c0ff346 100644 --- a/prospyr/fields.py +++ b/prospyr/fields.py @@ -3,7 +3,7 @@ from __future__ import absolute_import, print_function, unicode_literals from functools import wraps - +import datetime import arrow from arrow.parser import ParserError from marshmallow import ValidationError, fields @@ -15,6 +15,28 @@ from prospyr.validate import WhitespaceEmail +class FieldsCache: + def __init__(self): + """Constructor""" + self.cache = {} + + def contains(self, key): + """ Returns True or False depending on whether or not the key is in the cache.""" + return key in self.cache + + def get(self, key): + """Return value for the key.""" + return self.cache[key]['value'] + + def set(self, key, value): + """Set key value in cache.""" + self.cache[key] = {'date_created': datetime.datetime.now(), + 'value': value} + + +CACHE = FieldsCache() + + class Unix(fields.Field): """ datetime.datetime <-> unix timestamp @@ -104,7 +126,11 @@ def _deserialize(self, values, attr, data): if self.id_only: resources.append(self.resource_cls.objects.get(id=value['id'])) elif self.custom_field: - resource = self.resource_cls.objects.get(id=value['custom_field_definition_id']) + if CACHE.contains(value['custom_field_definition_id']): + resource = CACHE.get(value['custom_field_definition_id']) + else: + resource = self.resource_cls.objects.get(id=value['custom_field_definition_id']) + CACHE.set(value['custom_field_definition_id'], resource) resource.value = value['value'] resources.append(resource) else: From 8c23c0c22e60af55a0d7fc6edd7a4adf220ef0e0 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Date: Wed, 3 Oct 2018 15:31:01 +0100 Subject: [PATCH 08/17] Fix update mixin to support lead without email. --- prospyr/mixins.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/prospyr/mixins.py b/prospyr/mixins.py index 26bf30f..197f14c 100644 --- a/prospyr/mixins.py +++ b/prospyr/mixins.py @@ -122,7 +122,11 @@ def update(self, using='default', email=None): {'custom_field_definition_id': cf['id'], 'value': value} ) if email: - data['email']['email'] = email + try: + data['email']['email'] = email + except KeyError: + # this may happen if the lead doesn't have an email, by default we add as work email + data['email'] = {'email': email, 'category': 'work'} conn = self._get_conn(using) path = self.Meta.detail_path.format(id=self.id) resp = conn.put(conn.build_absolute_url(path), json=data) From 07aef545565bd32e6754b0e2c28564e0349bba15 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Date: Fri, 19 Oct 2018 14:34:16 +0100 Subject: [PATCH 09/17] Add support to search for users. --- prospyr/resources.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/prospyr/resources.py b/prospyr/resources.py index 4b32aca..ee5911f 100644 --- a/prospyr/resources.py +++ b/prospyr/resources.py @@ -364,10 +364,9 @@ def __str__(self): class User(Resource, mixins.Readable): class Meta(object): list_path = 'users/' + search_path = 'users/search/' detail_path = 'users/{id}/' - objects = ListOnlyManager() - id = fields.Integer() name = fields.String(required=True) email = fields.Email(required=True) From 52fb956b1dcb3c3f5484a11041e1276047e498dc Mon Sep 17 00:00:00 2001 From: Pedro Gomes Date: Tue, 13 Nov 2018 15:08:32 +0000 Subject: [PATCH 10/17] Fix update for Person. --- prospyr/mixins.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/prospyr/mixins.py b/prospyr/mixins.py index 197f14c..c6461e5 100644 --- a/prospyr/mixins.py +++ b/prospyr/mixins.py @@ -91,7 +91,7 @@ class Updateable(object): _update_success_codes = {codes.ok} - def update(self, using='default', email=None): + def update(self, using='default', email=None, emails=None): """ Update this Resource. True on success. """ @@ -127,6 +127,13 @@ def update(self, using='default', email=None): except KeyError: # this may happen if the lead doesn't have an email, by default we add as work email data['email'] = {'email': email, 'category': 'work'} + if emails: + try: + data['emails'][0].email = emails + except KeyError: + # this may happen if the lead doesn't have an email, by default we add as work email + data['emails'] = [{'email': emails, 'category': 'work'}] + conn = self._get_conn(using) path = self.Meta.detail_path.format(id=self.id) resp = conn.put(conn.build_absolute_url(path), json=data) From 4872162baf2b2114f420b7875e3145a5d768ee3f Mon Sep 17 00:00:00 2001 From: Pedro Gomes Date: Tue, 13 Nov 2018 18:05:09 +0000 Subject: [PATCH 11/17] Update mixins.py --- prospyr/mixins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prospyr/mixins.py b/prospyr/mixins.py index c6461e5..7df450b 100644 --- a/prospyr/mixins.py +++ b/prospyr/mixins.py @@ -129,7 +129,7 @@ def update(self, using='default', email=None, emails=None): data['email'] = {'email': email, 'category': 'work'} if emails: try: - data['emails'][0].email = emails + data['emails'][0]['email'] = emails except KeyError: # this may happen if the lead doesn't have an email, by default we add as work email data['emails'] = [{'email': emails, 'category': 'work'}] From 6c1c23667ca9f1d725fdb088c7461d4bdd092501 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Date: Thu, 15 Nov 2018 10:42:19 +0000 Subject: [PATCH 12/17] Update Opportunity fields. --- prospyr/resources.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prospyr/resources.py b/prospyr/resources.py index ee5911f..627fc6b 100644 --- a/prospyr/resources.py +++ b/prospyr/resources.py @@ -533,7 +533,7 @@ class Meta(object): allow_none=True, validate=OneOf(choices=('None', 'Low', 'Medium', 'High')), ) - stage = fields.String( + status = fields.String( allow_none=True, validate=OneOf(choices=('Open', 'Won', 'Lost', 'Abandoned')), ) From 55b7102eba1cc211263e4dd926d4316f75cf129f Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 30 Jan 2019 13:17:15 +0000 Subject: [PATCH 13/17] Update for create with emails params --- prospyr/mixins.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/prospyr/mixins.py b/prospyr/mixins.py index 7df450b..c1e0a8f 100644 --- a/prospyr/mixins.py +++ b/prospyr/mixins.py @@ -20,7 +20,7 @@ class Creatable(object): # pworks uses 200 OK for creates. 201 CREATED is here through optimism. _create_success_codes = {codes.created, codes.ok} - def create(self, using='default', email=None): + def create(self, using='default', email=None, emails=None): """ Create a new instance of this Resource. True on success. """ @@ -34,6 +34,9 @@ def create(self, using='default', email=None): data = self._raw_data if email: data['email'] = {'category': 'work', 'email': email} + + if emails: + data['emails'] = [{'email': emails, 'category': 'work'}] resp = conn.post(conn.build_absolute_url(path), json=data) if resp.status_code in self._create_success_codes: From af16b245c92a2bac81bc1644f34dd21f766451d9 Mon Sep 17 00:00:00 2001 From: ahmed-abdelsalam <33804215+ahmed-abdelsalam@users.noreply.github.com> Date: Wed, 29 May 2019 20:31:21 +0200 Subject: [PATCH 14/17] Fix Input Invalid error for category field fix category None for fields: - websites - Phone Numbers --- prospyr/mixins.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/prospyr/mixins.py b/prospyr/mixins.py index c1e0a8f..58f5ce2 100644 --- a/prospyr/mixins.py +++ b/prospyr/mixins.py @@ -136,7 +136,19 @@ def update(self, using='default', email=None, emails=None): except KeyError: # this may happen if the lead doesn't have an email, by default we add as work email data['emails'] = [{'email': emails, 'category': 'work'}] - + try: + for number in data['phone_numbers']: + if number['category'] is None : + number['category'] = 'work' # set default category value to work + except KeyError: + data['phone_numbers'] = [{'number': '', 'category': 'work'}] + try: + for website in data['websites']: + if website['category'] is None : + website['category'] = 'work' # set default category value to work + except KeyError: + data['websites'] = [{'url': '', 'category': 'work'}] + conn = self._get_conn(using) path = self.Meta.detail_path.format(id=self.id) resp = conn.put(conn.build_absolute_url(path), json=data) From 2513b003634d265e7db7437555745519c40ecc63 Mon Sep 17 00:00:00 2001 From: ahmed-abdelsalam <33804215+ahmed-abdelsalam@users.noreply.github.com> Date: Tue, 18 Jun 2019 07:51:55 +0200 Subject: [PATCH 15/17] fix update opportunity problem --- prospyr/mixins.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/prospyr/mixins.py b/prospyr/mixins.py index 58f5ce2..6b057ec 100644 --- a/prospyr/mixins.py +++ b/prospyr/mixins.py @@ -137,18 +137,24 @@ def update(self, using='default', email=None, emails=None): # this may happen if the lead doesn't have an email, by default we add as work email data['emails'] = [{'email': emails, 'category': 'work'}] try: - for number in data['phone_numbers']: - if number['category'] is None : - number['category'] = 'work' # set default category value to work + if 'phone_numbers' in data: + for number in data['phone_numbers']: + if number['category'] is None : + number['category'] = 'work' # set default category value to work except KeyError: data['phone_numbers'] = [{'number': '', 'category': 'work'}] try: - for website in data['websites']: - if website['category'] is None : - website['category'] = 'work' # set default category value to work + if 'websites' in data: + for website in data['websites']: + if website['category'] is None : + website['category'] = 'work' # set default category value to work except KeyError: data['websites'] = [{'url': '', 'category': 'work'}] - + if type(self).__name__ == 'Opportunity': + try: + del data['close_date'] + except KeyError: + pass conn = self._get_conn(using) path = self.Meta.detail_path.format(id=self.id) resp = conn.put(conn.build_absolute_url(path), json=data) From 658c74eb7e56cdd2b34b455f3aab84caee1dd5a0 Mon Sep 17 00:00:00 2001 From: ahmed-abdelsalam <33804215+ahmed-abdelsalam@users.noreply.github.com> Date: Mon, 22 Jul 2019 10:54:47 +0200 Subject: [PATCH 16/17] fix update custom_field type DATE fix update custom_field type DATE --- prospyr/mixins.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/prospyr/mixins.py b/prospyr/mixins.py index 6b057ec..a08d535 100644 --- a/prospyr/mixins.py +++ b/prospyr/mixins.py @@ -116,7 +116,11 @@ def update(self, using='default', email=None, emails=None): elif cf['data_type'] == TYPE_FLOAT: value = float(cf['value']) if cf['value'] else None elif cf['data_type'] == TYPE_DATE: - value = int(cf['value']) if cf['value'] else None + date_match = re.search(r'\d{2}/\d{2}/\d{4}', cf['value']) + if date_match: + value = date_match.group() + else: + value = None else: value = cf['value'] else: From 4acd5572b1ac7bcfefdd0d5276724898acf5b9a4 Mon Sep 17 00:00:00 2001 From: Ahmed Date: Wed, 24 Jul 2019 03:47:14 +0200 Subject: [PATCH 17/17] fix win_probability none bug --- prospyr/mixins.py | 1 + prospyr/resources.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/prospyr/mixins.py b/prospyr/mixins.py index a08d535..47a4ba2 100644 --- a/prospyr/mixins.py +++ b/prospyr/mixins.py @@ -2,6 +2,7 @@ from __future__ import absolute_import, print_function, unicode_literals +import re from logging import getLogger from datetime import datetime from requests import codes diff --git a/prospyr/resources.py b/prospyr/resources.py index 627fc6b..b9f00ef 100644 --- a/prospyr/resources.py +++ b/prospyr/resources.py @@ -538,7 +538,7 @@ class Meta(object): validate=OneOf(choices=('Open', 'Won', 'Lost', 'Abandoned')), ) tags = fields.List(fields.String) - win_probability = fields.Integer() + win_probability = fields.Integer(allow_none=True) date_created = Unix() date_modified = Unix() custom_fields = NestedResource(CustomField, many=True, schema=schema.CustomFieldSchema, custom_field=True)