Skip to content

Commit

Permalink
add 'select' options to find methods; replace 'search_key_name' arg w…
Browse files Browse the repository at this point in the history
…ith separate 'where' args;
  • Loading branch information
indepndnt committed Jan 31, 2024
1 parent 6e5f17b commit 948a2fc
Show file tree
Hide file tree
Showing 19 changed files with 313 additions and 230 deletions.
24 changes: 12 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,16 @@ project's `pyproject.toml` file, or a `.civipy` file in the user's home folder.
Settings in environment variables will overwrite any file settings. Alternatively,
you can call `civipy.config.SETTINGS.init()` to set the configuration values.

| Setting | Environment Variable | `.civipy` File | `pyproject.toml` File |
|---------------------------|----------------------|----------------------------|--------------------------------|
| | | | `[tool.civipy]` |
| Connection *(required)* | `CIVI_REST_BASE` | `rest_base=http://civi.py` | `rest-base = "http://civi.py"` |
| API Version | `CIVI_API_VERSION` | `api_version=4` | `api-version = "4"` |
| Access Token *(required)* | `CIVI_USER_KEY` | `user_key=...` | `user-key = "..."` |
| Site Token *(required)* | `CIVI_SITE_KEY` | `site_key=...` | `site-key = "..."` |
| Log File | `CIVI_LOG_FILE` | `log_file=/tmp/civipy.log` | `log-file = "/tmp/civipy.log"` |
| Log Level | `CIVI_LOG_LEVEL` | `log_level=WARNING` | `log-level = "WARNING"` |
| Config File | `CIVI_CONFIG` | | |
| Setting | Environment Variable | `.civipy` File | `pyproject.toml` File |
|---------------------------------|----------------------|----------------------------|--------------------------------|
| | | | `[tool.civipy]` |
| Connection *(required)* | `CIVI_REST_BASE` | `rest_base=http://civi.py` | `rest-base = "http://civi.py"` |
| API Version | `CIVI_API_VERSION` | `api_version=4` | `api-version = "4"` |
| Access Token *(required)* | `CIVI_USER_KEY` | `user_key=...` | `user-key = "..."` |
| Site Token *(required for v3)* | `CIVI_SITE_KEY` | `site_key=...` | `site-key = "..."` |
| Log File | `CIVI_LOG_FILE` | `log_file=/tmp/civipy.log` | `log-file = "/tmp/civipy.log"` |
| Log Level | `CIVI_LOG_LEVEL` | `log_level=WARNING` | `log-level = "WARNING"` |
| Config File | `CIVI_CONFIG` | | |

### Connection
The Connection setting lets you specify the URL of your REST API, or the `cv` or
Expand All @@ -50,8 +50,8 @@ for working with them.
```python
from civipy import CiviContact, CiviEmail

contact = CiviContact.action("get", primary_email="[email protected]")
email = CiviEmail.find_or_create(search_key=["contact_id", "email"], **kwargs)
contact = CiviContact.find_by_email("[email protected]")
email = CiviEmail.find_or_create(where={"contact_id": contact.id, "email": "[email protected]"})
contact.update(nick_name="Ana")
```

Expand Down
2 changes: 2 additions & 0 deletions civipy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from civipy.contact import CiviGroup, CiviGroupContact
from civipy.contribution import CiviContribution, CiviContributionRecur
from civipy.event import CiviEvent, CiviParticipant
from civipy.financial import CiviEntityFinancialTrxn
from civipy.grant import CiviGrant
from civipy.mailing import CiviMailingEventQueue
from civipy.membership import CiviMembership, CiviMembershipPayment
Expand All @@ -31,6 +32,7 @@
"CiviCustomField",
"CiviCustomValue",
"CiviEmail",
"CiviEntityFinancialTrxn",
"CiviEntityTag",
"CiviEvent",
"CiviGrant",
Expand Down
4 changes: 2 additions & 2 deletions civipy/address.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ class CiviCountry(CiviCRMBase):
civicrm_entity_table = "country"

@classmethod
def find_by_country_code(cls, country_code: str):
return cls.find(iso_code=country_code)
def find_by_country_code(cls, country_code: str, select: list[str] | None = None):
return cls.find(select=select, iso_code=country_code)


class CiviStateProvince(CiviCRMBase):
Expand Down
168 changes: 90 additions & 78 deletions civipy/base/base.py
Original file line number Diff line number Diff line change
@@ -1,101 +1,113 @@
import json
from typing import TypeVar
from civipy.base.config import logger
from civipy.base.utils import get_unique
from civipy.exceptions import CiviProgrammingError
from civipy.interface import get_interface, CiviValue, CiviResponse, Interface

CiviEntity = TypeVar("CiviEntity", bound="CiviCRMBase")


class CiviCRMBase:
###################################
# Common API Read Action methods #
###################################
@classmethod
def get(cls, **kwargs) -> CiviResponse:
return cls.action("get", **kwargs)
"""Make an API request with the "get" action and return the full response."""
query = cls._interface().limit(25)
query.update(kwargs)
return cls.action("get", **query)

###################################
# Common API Write Action methods #
###################################
@classmethod
def create(cls, **kwargs):
response = cls.action("create", **kwargs)
def create(cls, **kwargs: CiviValue) -> CiviEntity:
"""Make an API request with the "create" action and return an object of class cls
populated with the created object's data."""
query = cls._interface().values(kwargs)
response = cls.action("create", **query)
logger.debug("new record created! full response: %s" % str(response))
if not isinstance(response.get("values"), int):
value = get_unique(response)
return cls(value)
else:
return response
return cls(get_unique(response))

def update(self, **kwargs):
def update(self: CiviEntity, **kwargs: CiviValue) -> CiviResponse:
"""Update the current object with the values specified in kwargs. Returns the full
API response."""
self.civi.update(kwargs)
kwargs["id"] = self.civi_id
self.action("create", **kwargs)
query = self._interface().where({"id": self.civi_id})
query.update(self._interface().values(kwargs))
return self.action("create", **query)

##############################
# Common convenience methods #
##############################
@classmethod
def find(cls, search_key: str | list[str] = "id", **kwargs) -> CiviValue | None:
"""Looks for an existing object in CiviCRM with parameter search_key
equal to the value for search_key specified in kwargs. Returns an
object of class cls populated with this object's data if found, otherwise
def find(cls, select: list[str] | None = None, **kwargs: CiviValue) -> CiviEntity | None:
"""Looks for an existing object in CiviCRM with parameters equal to the values
specified in kwargs. If using API v4 and select is specified, the result will include
the specified keys.
Returns an object of class cls populated with this object's data if found, otherwise
returns None."""
search_query = cls._interface().search_query(search_key, kwargs)
response = cls.action("get", **search_query)
query = cls._interface().where(kwargs)
if select:
query["select"] = select
response = cls.get(**query)
if response["count"] == 0:
return None
return cls(get_unique(response))

@classmethod
def find_and_update(cls, search_key: str | list[str] = "id", **kwargs):
"""Looks for an existing object in CiviCRM with parameter search_key
equal to the value for search_key specified in kwargs. If a unique
record is found, record is also updated with additional values in kwargs.
Returns an object of class cls populated with this object's data if
found, otherwise returns None."""
search_query = cls._interface().search_query(search_key, kwargs)
response = cls.action("get", **search_query)
if response["count"] == 0:
return
else:
value = get_unique(response)
value.update(kwargs)
new_response = cls.action("create", **value)
updated_value = get_unique(new_response)
# not all fields are included in the return from an update, so we merge both sources
updated_value.update(value)
return cls(updated_value)
def find_all(cls, select: list[str] | None = None, **kwargs: CiviValue) -> list[CiviEntity]:
"""Looks for multiple existing objects in CiviCRM with parameters equal to the
values specified in kwargs. If using API v4 and select is specified, the result will
include the specified keys.
Returns a list of objects of class cls populated with data. Returns an empty list
if no matching values found."""
query = cls._interface().where(kwargs)
if select:
query["select"] = select
response = cls.action("get", **query)
return [cls(v) for v in response["values"]]

@classmethod
def find_all(cls, search_key: str | list[str] = "id", **kwargs):
"""Looks for multiple existing objects in CiviCRM with parameter
search_key equal to the value for search_key specified in
kwargs. Returns a list of objects of class cls populated with data.
Returns an empty list if no matching values found."""
search_query = cls._interface().search_query(search_key, kwargs)
response = cls.action("get", **search_query)
return [cls(v) for v in response["values"]]
def find_and_update(cls, where: CiviValue, **kwargs: CiviValue) -> CiviEntity | None:
"""Looks for an existing object in CiviCRM with parameters equal to the values
specified in `where`.
If a unique record is found, record is also updated with values in `kwargs`.
Returns an object of class cls populated with this object's data if found, otherwise
returns None."""
query = cls._interface().where(where)
response = cls.get(**query)
if response["count"] == 0:
return None

value = get_unique(response)
value.update(kwargs)
new_response = cls.action("create", **value)
updated_value = get_unique(new_response)
# not all fields are included in the return from an update, so we merge both sources
updated_value.update(value)
return cls(updated_value)

@classmethod
def find_or_create(cls, search_key: str | list[str] = "id", do_update: bool = False, **kwargs):
"""Looks for an existing object in CiviCRM with parameter
search_key equal to the value for search_key
specified in kwargs. Returns this object if it exists,
otherwise creates a new object."""
def find_or_create(cls, where: CiviValue, do_update: bool = False, **kwargs: CiviValue) -> CiviEntity:
"""Looks for an existing object in CiviCRM with parameters search_keys equal to the
values for search_keys specified in kwargs.
If a unique record is found and do_update is True, record is also updated with
values in `kwargs`.
If no record is found, a new record is created with the data in `where` and `kwargs`.
Returns an object of class cls populated with the found, updated, or created
object's data."""
if do_update:
obj = cls.find_and_update(search_key=search_key, **kwargs)
obj = cls.find_and_update(where, **kwargs)
else:
obj = cls.find(search_key=search_key, **kwargs)
obj = cls.find(**kwargs)

if obj is None:
return cls.create(**kwargs)
else:
return obj
query = where.copy()
query.update(kwargs)
return cls.create(**query)
return obj

#############################
# Direct API access methods #
#############################
@classmethod
def action(cls, action: str, **kwargs) -> CiviResponse:
"""Calls the CiviCRM API action and returns parsed JSON on success."""
Expand All @@ -108,22 +120,22 @@ def action(cls, action: str, **kwargs) -> CiviResponse:

@classmethod
def _interface(cls) -> Interface:
if cls._interface_reference is None:
cls._interface_reference = get_interface()
return cls._interface_reference

############################
# Instance support methods #
############################
def pprint(self):
"""Instantiate the appropriate API interface and store it on the CiviCRMBase class,
so that it will be instantiated only once and available to all entity classes."""
if CiviCRMBase._interface_reference is None:
CiviCRMBase._interface_reference = get_interface()
return CiviCRMBase._interface_reference

def pprint(self: CiviEntity) -> None:
"""Print the current record's data in a human-friendly format."""
print(json.dumps(self.civi, sort_keys=True, indent=4))

def __init__(self, data: CiviValue):
def __init__(self: CiviEntity, data: CiviValue) -> None:
self.civi = data

REPR_FIELDS = ["display_name", "name"]

def __repr__(self):
def __repr__(self: CiviEntity):
label = None

for field_name in self.REPR_FIELDS:
Expand All @@ -133,14 +145,14 @@ def __repr__(self):

return f"<{self.__class__.__name__} {self.civi_id}: {label}>"

def __getattr__(self, key: str):
def __getattr__(self: CiviEntity, key: str):
if key in self.civi:
return self.civi[key]
elif key.startswith("civi_"):
return self.civi[key[5:]]
return object.__getattribute__(self, key)

def __setattr__(self, key: str, value: str | int | None):
def __setattr__(self: CiviEntity, key: str, value: str | int | None) -> None:
if key == "civi":
object.__setattr__(self, key, value)
elif key in self.civi:
Expand Down
Loading

0 comments on commit 948a2fc

Please sign in to comment.