diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 8feb53ccef..a8abf56d47 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -43,7 +43,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d with: python-version: 3.14 activate-environment: true @@ -72,7 +72,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d with: python-version: 3.14 activate-environment: true @@ -105,7 +105,7 @@ jobs: # If it's a pull request, don't get full history. fetch-depth: ${{ github.event_name == 'pull_request' && 1 || 0 }} - - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d with: python-version: 3.14 activate-environment: true diff --git a/.github/workflows/python-checks.yml b/.github/workflows/python-checks.yml index bc2ab5b7e2..563690df5c 100644 --- a/.github/workflows/python-checks.yml +++ b/.github/workflows/python-checks.yml @@ -76,7 +76,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d with: python-version: ${{ matrix.python-version }} activate-environment: true @@ -126,7 +126,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d with: python-version: 3.14 activate-environment: true @@ -189,7 +189,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d with: python-version: ${{ matrix.python-version }} activate-environment: true @@ -224,7 +224,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d with: python-version: 3.14 activate-environment: true @@ -263,7 +263,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d with: python-version: 3.14 activate-environment: true @@ -315,7 +315,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d with: python-version: 3.14 activate-environment: true @@ -397,7 +397,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d with: python-version: 3.14 activate-environment: true @@ -434,7 +434,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d with: python-version: 3.14 activate-environment: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1f7056aa40..7c56e51a41 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d with: python-version: 3.14 activate-environment: true diff --git a/docs/conf.py b/docs/conf.py index d31f39ee00..942e92c342 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -92,6 +92,7 @@ "psycopg2-binary": ("https://www.psycopg.org/docs/", None), "pyairtable": ("https://pyairtable.readthedocs.io/en/stable/", None), "PyGitHub": ("https://pygithub.readthedocs.io/en/latest/", None), + "pyratelimiter": ("https://pyratelimiter.readthedocs.io/en/latest/", None), "requests-toolbelt": ("https://toolbelt.readthedocs.io/en/stable/", None), "rich": ("https://rich.readthedocs.io/en/stable/", None), "simple-salesforce": ("https://simple-salesforce.readthedocs.io/en/latest/", None), diff --git a/docs/index.rst b/docs/index.rst index 2af5cc9647..39893776e9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -187,6 +187,7 @@ Indices and tables sftp shopify sisense + solidarity_tech targetsmart turbovote twilio diff --git a/docs/solidarity_tech.rst b/docs/solidarity_tech.rst new file mode 100644 index 0000000000..10865857c9 --- /dev/null +++ b/docs/solidarity_tech.rst @@ -0,0 +1,82 @@ +############### +Solidarity Tech +############### + +Overview +======== + +What is Solidarity Tech? +------------------------ + +`Solidarity Tech`_'s all-in-one nonprofit CRM includes digital tools like +`texting `__, +`calling `__, +`email `__ & +`websites `__ for +`advocacy groups `__, +`unions `__ & +grassroots organizers. + +The SolidarityTech Connector +---------------------------- + +As of September 2026, parsons' :class:`~parsons.solidarity_tech.solidarity_tech.SolidarityTech` connector supports +all endpoints described in the `Solidarity Tech API Documentation `_. +The documented `rate limits `__ +are applied automatically, although advanced configuration is available. + +Quickstart +========== + +To instantiate the :class:`~parsons.solidarity_tech.solidarity_tech.SolidarityTech` class, +you can either store your Solidarity Tech bearer authorization key as +an environmental variable (``SOLIDARITY_TECH_BEARER_KEY``) or pass it as a keyword argument. + +.. code-block:: python + :caption: Load bearer authorization key from environment variable + + from parsons import SolidarityTech + st = SolidarityTech() + +.. code-block:: python + :caption: Pass bearer authorization key as argument + + from parsons import SolidarityTech + st = SolidarityTech(api_token='SOME_BEARER_KEY') + +You can then call various endpoints: + +.. code-block:: python + :caption: Get all events + + events = st.get_events() + +.. code-block:: python + :caption: Create a new user + + st.create_user( + first_name="Elizabeth", + last_name="Flynn", + email="egflynn@example.com" + ) + +API +==== + +.. autoclass:: parsons.solidarity_tech.solidarity_tech.SolidarityTech + :inherited-members: + :members: + +.. automodule:: parsons.solidarity_tech.exceptions + :inherited-members: + :members: + +.. automodule:: parsons.solidarity_tech.datatypes + :inherited-members: + :members: + +.. automodule:: parsons.solidarity_tech.auth + :inherited-members: + :members: + +.. _Solidarity Tech: https://www.solidarity.tech/ diff --git a/parsons/__init__.py b/parsons/__init__.py index 5bdcbba920..38db6d1184 100644 --- a/parsons/__init__.py +++ b/parsons/__init__.py @@ -100,6 +100,7 @@ "SFTP": "parsons.sftp.sftp", "Shopify": "parsons.shopify.shopify", "Sisense": "parsons.sisense.sisense", + "SolidarityTech": "parsons.solidarity_tech.solidarity_tech", "TargetSmartAPI": "parsons.targetsmart.targetsmart_api", "TargetSmartAutomation": "parsons.targetsmart.targetsmart_automation", "TurboVote": "parsons.turbovote.turbovote", diff --git a/parsons/solidarity_tech/__init__.py b/parsons/solidarity_tech/__init__.py new file mode 100644 index 0000000000..cc1e950d23 --- /dev/null +++ b/parsons/solidarity_tech/__init__.py @@ -0,0 +1,17 @@ +"""Parsons SolidarityTech API client.""" + +import parsons.solidarity_tech.datatypes as solidarity_tech_datatypes +from parsons.solidarity_tech.exceptions import ( + STFailedAuthenticationError, + STFailedResponseError, + STUnexpectedResponseError, +) +from parsons.solidarity_tech.solidarity_tech import SolidarityTech + +__all__ = [ + "STFailedAuthenticationError", + "STFailedResponseError", + "STUnexpectedResponseError", + "SolidarityTech", + "solidarity_tech_datatypes", +] diff --git a/parsons/solidarity_tech/auth.py b/parsons/solidarity_tech/auth.py new file mode 100644 index 0000000000..f07b5ab360 --- /dev/null +++ b/parsons/solidarity_tech/auth.py @@ -0,0 +1,36 @@ +"""Authentication classes for Parsons Solidarity Tech API connector.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from requests.auth import AuthBase + +if TYPE_CHECKING: + import requests + + +class SolidarityTechAuth(AuthBase): + """Attaches API key / bearer token to the given Request object.""" + + api_key: str | bytes + + def __init__(self, api_key: str | bytes) -> None: + self.api_key = api_key + + def __eq__(self, other: object) -> bool: + """Check if two instances have the same API key.""" + return self.api_key == getattr(other, "api_key", None) + + def __hash__(self) -> int: + """Ensure that two instsances with the same key have the same hash.""" + return hash(self.api_key) + + def __repr__(self) -> str: + """Return a string representation of the instance.""" + return f"" + + def __call__(self, r: requests.PreparedRequest) -> requests.PreparedRequest: + """Add authorization header to the supplied request.""" + r.headers["authorization"] = f"Bearer {self.api_key}" + return r diff --git a/parsons/solidarity_tech/base.py b/parsons/solidarity_tech/base.py new file mode 100644 index 0000000000..ddfd3ffb4b --- /dev/null +++ b/parsons/solidarity_tech/base.py @@ -0,0 +1,236 @@ +"""Methods used across multiple endpoints of the Parsons Solidarity Tech API connector.""" + +from __future__ import annotations + +import logging +from datetime import datetime +from enum import Enum +from typing import TYPE_CHECKING, Any + +import pyrate_limiter +import requests_ratelimiter + +from parsons.solidarity_tech.auth import SolidarityTechAuth +from parsons.solidarity_tech.exceptions import ( + STFailedAuthenticationError, + STFailedResponseError, + STUnexpectedResponseError, +) +from parsons.utilities import check_env +from parsons.utilities.api_connector import APIConnector, _JsonType + +if TYPE_CHECKING: + from collections.abc import Mapping + + import requests + +logger = logging.getLogger(__name__) + + +class SolidarityTechBase: + """Base class for interacting with the SolidarityTech API.""" + + def __init__( + self, api_token: str | None = None, *, session: requests.Session | None = None + ) -> None: + """ + Instantiate the SolidarityTech class. + + Args: + api_token: + A valid Bearer token for authorization. + Not required if the `SOLIDARITY_TECH_BEARER_KEY` env variable is set. + session: + A custom :class:`requests.Session` instance for advanced configuration. + Providing your own :class:`~requests.Session` will bypass + built-in rate limiting, so you will need to provide your own solution. + + """ + api_token = str(check_env.check("SOLIDARITY_TECH_BEARER_KEY", api_token)).strip() + self.api_url = "https://api.solidarity.tech/v1/" + self.api = APIConnector( + uri=self.api_url, + auth=SolidarityTechAuth(api_token), + ratelimit=requests_ratelimiter.Limiter( + requests_ratelimiter.HostBucketFactory( + rates=[pyrate_limiter.Rate(60, pyrate_limiter.Duration.SECOND * 30)] + ) + ) + if not session + else None, + session=session, + ) + + def _get_resources(self, endpoint: str, **kwargs) -> requests.Response: + """ + Process parameters and handle GET requests for lists of resources. + + If provided as keyword args, ``limit``, ``cursor``, ``offset``, ``since``, and ``include_count`` + will be added to params, prefaced with an underscore, and removed from kwargs. + If the ``params`` kwarg contains pairs with a value of None, they will be removed from ``params``. + + Args: + endpoint: + The url request string. + If ``url`` is a relative URL, + it will be joined with the ``uri`` of the :class:`parsons.utilities.APIConnector`. + If ``url`` is an absolute URL, + it will be used as is. + **kwargs: + Additional parameters to pass to :meth:`parsons.utilities.APIConnector.request`. + + Raises: + KeyError: + If one of the previously-mentioned parameters is provided as + a discrete kwarg AND via the ``params`` kwarg. + + Returns: + The response from the API. + + """ + since = kwargs.get("since") + if isinstance(since, datetime): + kwargs["since"] = int(since.timestamp()) + + param_mapping = { + "limit": "_limit", + "cursor": "_cursor", + "offset": "_offset", + "since": "_since", + "include_count": "_include_count", + } + params: dict[str, _JsonType] = {} + for key, value in param_mapping.items(): + if key in kwargs: + params[value] = kwargs[key] + del kwargs[key] + + if "params" in kwargs: + for key, value in kwargs.get("params", {}).items(): + if key in params: + err_msg = f"Request param '{key}' already exists." + raise KeyError(err_msg) + if value is None: + continue + params[key] = value + + if params: + kwargs["params"] = params + + logger.debug("Processing GET request at endpoint: %s", endpoint, extra=params) + return self.api.request(url=endpoint, req_type="GET", raise_on_error=False, **kwargs) + + def _get_single_resource(self, endpoint: str, resource_id: int, **kwargs) -> requests.Response: + """Handle GET requests for single resources.""" + complete_endpoint = f"{endpoint}/{resource_id}" + + logger.debug("Processing GET request at endpoint: %s", complete_endpoint) + return self.api.request( + url=complete_endpoint, req_type="GET", raise_on_error=False, **kwargs + ) + + def _post_request( + self, + endpoint: str, + payload: Mapping[str, _JsonType] | None = None, + **kwargs, + ) -> requests.Response: + """Handle POST requests.""" + logger.debug("Processing POST request at endpoint: %s", endpoint, extra=payload) + return self.api.request( + url=endpoint, req_type="POST", json=payload, raise_on_error=False, **kwargs + ) + + def _put_request( + self, + endpoint: str, + resource_id: int, + payload: Mapping[str, _JsonType] | None = None, + **kwargs, + ) -> requests.Response: + """Handle PUT requests.""" + complete_endpoint = f"{endpoint}/{resource_id}" + logger.debug("Processing PUT request at endpoint: %s", complete_endpoint, extra=payload) + return self.api.request( + url=complete_endpoint, req_type="PUT", json=payload, raise_on_error=False, **kwargs + ) + + def _delete_request(self, endpoint: str, resource_id: int | str, **kwargs) -> requests.Response: + """Handle DELETE requests.""" + complete_endpoint = f"{endpoint}/{resource_id}" + logger.debug("Processing DEL request at endpoint: %s", complete_endpoint) + return self.api.request( + url=complete_endpoint, req_type="DELETE", raise_on_error=False, **kwargs + ) + + def _handle_status_codes( + self, res: requests.Response, codes: dict[int, tuple[bool, str]] + ) -> bool: + """ + Handle status codes. + + Args: + res: The response object. + codes: Expected status codes and their corresponding pass/fail status and descriptive messages. + + Raises: + :class:`STFailedAuthenticationError`: If the operation fails with HTTP error 401 (Unauthorized). + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + bool: True if the status code indicates success, False otherwise. + + """ + if res.status_code == 401: + raise STFailedAuthenticationError(response=res) + + if res.status_code in codes: + success = codes[res.status_code][0] + result_message = res.reason or codes[res.status_code][1] + if success is True: + logger.debug(result_message, extra={"status_code": res.status_code}) + return success + raise STFailedResponseError(result_message, response=res) + + raise STUnexpectedResponseError(response=res) + + def _add_if_field_not_empty( + self, receiving_dict: dict, key: str, value: Any | None, *, overwrite: bool = False + ) -> dict: + """ + Add a key/value pair to a dictionary if the value is not None. + + Args: + receiving_dict: + The dictionary to add the key/value pair to. + key: + The key to add. + value: + The value to add. + overwrite: + Whether to overwrite the value if the key already exists. + + Raises: + KeyError: If the key already exists in the dictionary and overwrite is not True. + + Returns: + The updated dictionary. + + """ + if overwrite is not True and key in receiving_dict: + err_msg = f"'{key}' already exists." + raise KeyError(err_msg) + + if value is not None: + receiving_dict[key] = value.value if isinstance(value, Enum) else value + logger.debug( + "Added '%s' with value '%s' to payload or parameters dictionary", key, value + ) + else: + logger.debug( + "Skipping adding '%s' to payload or parameters dictionary as value is None", + key, + ) + + return receiving_dict diff --git a/parsons/solidarity_tech/datatypes.py b/parsons/solidarity_tech/datatypes.py new file mode 100644 index 0000000000..3ab86e20ec --- /dev/null +++ b/parsons/solidarity_tech/datatypes.py @@ -0,0 +1,318 @@ +"""Types, Enums, and TypedDicts for known Solidarity Tech values.""" + +from __future__ import annotations + +import numbers +from enum import Enum +from typing import TYPE_CHECKING, Literal, TypedDict + +if TYPE_CHECKING: + from parsons.utilities.api_connector import _JsonType + +# Type Aliases + +CompareValueType = str | numbers.Rational | bool + + +# Enums + + +class AttendanceStatus(Enum): + """Attendance statuses for an event RSVP.""" + + YES = "yes" + NO = "no" + MAYBE = "maybe" + WAITLISTED = "waitlisted" + + +class EventType(Enum): + """Event types for a Solidarity Tech event.""" + + VIRTUAL = "virtual" + IN_PERSON = "in_person" + HYBRID = "hybrid" + + +class FieldType(Enum): + """Field types for Solidarity Tech user properties.""" + + INPUT = "input" + TEXT_AREA = "textarea" + NUMBER = "number" + DATE = "date" + CHECKBOX = "checkbox" + SELECT = "select" + RADIOS = "radios" + CHECKBOXES = "checkboxes" + + +class InviteType(Enum): + """Methods used to invite Solidarity Tech team members.""" + + SMS = "sms" + EMAIL = "email" + + +class ScopeType(Enum): + """Scopes for Solidarity Tech records.""" + + ORGANIZATION = "Organization" + CHAPTER = "Chapter" + + +class InteractionType(Enum): + """Types of interactions recorded in Solidarity Tech user notes.""" + + IN_PERSON = "in_person" + CALL = "call" + TEXT = "text" + EMAIL = "email" + + +# TypedDicts (Component / Sub-types First) + + +class UserPropertyDataValue(TypedDict): + label: dict[str, _JsonType] + value: str + + +class ActionData(TypedDict): + id: int + user_id: int + agent_user_id: int | None + field_type: str | None + old_value: str | None + new_value: str | None + data_import_id: int | None + created_at: str + updated_at: str + + +class TranscriptData(TypedDict): + summary: str | None + rating: int | None + sentiment: str | None + engagement_analysis: str | None + engagement_analysis_justification: str | None + + +class DonationChargeDataChapter(TypedDict): + id: int + name: str + + +class DonationChargeDataUser(TypedDict): + id: int + email: str + first_name: str + last_name: str + phone_number: str + created_at: str + address1: str | None + address2: str | None + city: str | None + state: str | None + zip_code: str | None + country_name: str | None + + +class DonationChargeDataActionPage(TypedDict): + id: int + title: str + url_slug: str + + +class AddressData(TypedDict): + address1: str | None + address2: str | None + city: str | None + state: str | None + zip_code: str | None + country: str | None + + +# --- TypedDicts (Main Entities) --- + + +class Metadata(TypedDict): + """Standard metadata dictionary returned by the SolidarityTech API.""" + + total_count: int + limit: int + offset: int + + +class UserPropertyData(TypedDict): + id: int + name: str + key: str + field_type: FieldType + options: list[UserPropertyDataValue] | None + scope_id: int | None + scope_type: ScopeType | None + + +class ActivityData(TypedDict): + id: int + user_id: int + name: str + actionable_id: int + actionable_type: str + action: ActionData + created_at: str + updated_at: str + + +class ActivityMetadata(TypedDict): + total_count: int | None + limit: int + offset: int + cursor: int | None + next_cursor: int | None + + +class AgentAssignmentData(TypedDict): + id: int + agent_user_id: int + user_id: int + created_at: str + is_active: bool + + +class CallData(TypedDict): + id: int + user_id: int + chapter_id: int | None + direction: str + from_number: str | None + to_number: str | None + phonebank_id: int | None + agent_user_id: int | None + notes: str | None + duration: int + picked_up: bool + left_voicemail: bool + twilio_call_sid: str + created_at: str + ended_at: str | None + transcription: TranscriptData | None + + +class ChapterData(TypedDict): + id: int + name: str + assigned_user_count: int | None + logo_url: str | None + organization_id: int + chapter_phone_number: str | None + calendar_feed_url: str | None + + +class DonationChargeData(TypedDict): + id: int + amount: int + created_at: str + updated_at: str + success: bool + refunded: bool + receipt_number: str + hash_id: str + processing_fee_cents: int | None + external_donation_id: str | None + external_donation_date: str | None + is_external: bool + amount_in_dollars: str + currency: str + currency_symbol: str + receipt_url: str + brand: str + last4: str + json: dict[str, _JsonType] + user: DonationChargeDataUser + action_page: DonationChargeDataActionPage + chapter: DonationChargeDataChapter + + +# The format of this has to be different because "from" is a reserved keyword in Python +EmailSenderData = TypedDict( + "EmailSenderData", + { + "id": int, + "name": str, + "email": str, + "from": str, + "default_for_scope": bool, + "scope_type": str, + "scope_id": int, + "created_at": str, + }, +) + + +class FieldSurveyURL(TypedDict): + url: str + expires_at: str + + +class QueryRule(TypedDict): + id: str + type: str + operator: str + value: CompareValueType | list[CompareValueType] + + +class QueryParams(TypedDict): + condition: Literal["AND", "OR"] + valid: bool + rules: list[QueryRule] + + +class UserRelationshipData(TypedDict): + id: str + text: str + + +class UserData(TypedDict): + id: int + hash_id: str + phone_number: str | None + email: str | None + first_name: str | None + last_name: str | None + alternate_name: str | None + preferred_language: str + second_language: str | None + chapter_id: int + chapter_ids: list[int] + branch_id: int | None + created_at: str + custom_user_properties: dict[str, str | list[str]] + address: AddressData + sms_permission: bool + call_permission: bool + email_permission: bool + other_emails: list[str] + other_phone_numbers: list[str] + + +class UserMergeMetadata(TypedDict): + message: str + primary_user_id: int + merged_user_ids: list[int] + merged_count: int + not_found_user_ids: list[int] | None + + +class UserDeleteMetadata(TypedDict): + message: str + id: int | None + + +class ChapterPhoneNumberData(TypedDict): + id: int + phone_number: str + assigned_user_count: int + chapters: list[ChapterData] + created_at: str diff --git a/parsons/solidarity_tech/exceptions.py b/parsons/solidarity_tech/exceptions.py new file mode 100644 index 0000000000..9acf329702 --- /dev/null +++ b/parsons/solidarity_tech/exceptions.py @@ -0,0 +1,47 @@ +"""Exceptions raised by Parsons Solidarity Tech API connector.""" + +import requests +from requests.exceptions import HTTPError + + +class STResponseError(HTTPError): + """Base exception for all Solidarity Tech response errors.""" + + def __init__( + self, message: str | None = None, *args, err_msg: str, response: requests.Response, **kwargs + ) -> None: + if response.status_code: + err_msg += f" (Status Code {response.status_code})" + if message: + err_msg += f" -- {message}" + super().__init__(err_msg, *args, **kwargs) + + +class STFailedAuthenticationError(STResponseError): + """Status code 401, Unauthorized.""" + + def __init__( + self, message: str | None = None, *args, response: requests.Response, **kwargs + ) -> None: + err_msg = "Authentication failed or not provided" + super().__init__(message, *args, err_msg=err_msg, response=response, **kwargs) + + +class STFailedResponseError(STResponseError): + """Status code indicates a known failure.""" + + def __init__( + self, message: str | None = None, *args, response: requests.Response, **kwargs + ) -> None: + err_msg = "Request Failed" + super().__init__(message, *args, err_msg=err_msg, response=response, **kwargs) + + +class STUnexpectedResponseError(STResponseError): + """Status code is not expected.""" + + def __init__( + self, message: str | None = None, *args, response: requests.Response, **kwargs + ) -> None: + err_msg = "Unexpected Response" + super().__init__(message, *args, err_msg=err_msg, response=response, **kwargs) diff --git a/parsons/solidarity_tech/solidarity_tech.py b/parsons/solidarity_tech/solidarity_tech.py new file mode 100644 index 0000000000..e86095a466 --- /dev/null +++ b/parsons/solidarity_tech/solidarity_tech.py @@ -0,0 +1,96 @@ +"""Combined class for interacting with all SolidarityTech API endpoints.""" + +from __future__ import annotations + +import logging + +from parsons.solidarity_tech.st_activities import SolidarityTechActivities +from parsons.solidarity_tech.st_agent_assignments import SolidarityTechAgentAssignments +from parsons.solidarity_tech.st_automation_enrollments import SolidarityTechAutomationEnrollments +from parsons.solidarity_tech.st_calls import SolidarityTechCalls +from parsons.solidarity_tech.st_chapter_phone_numbers import SolidarityTechChapterPhoneNumbers +from parsons.solidarity_tech.st_chapters import SolidarityTechChapters +from parsons.solidarity_tech.st_custom_user_properties import SolidarityTechCustomUserProperties +from parsons.solidarity_tech.st_donation_charges import SolidarityTechDonationCharges +from parsons.solidarity_tech.st_email_blasts import SolidarityTechEmailBlasts +from parsons.solidarity_tech.st_email_senders import SolidarityTechEmailSenders +from parsons.solidarity_tech.st_emails import SolidarityTechEmails +from parsons.solidarity_tech.st_event_attendances import SolidarityTechEventAttendances +from parsons.solidarity_tech.st_event_rsvps import SolidarityTechEventRSVPs +from parsons.solidarity_tech.st_event_sessions import SolidarityTechEventSessions +from parsons.solidarity_tech.st_events import SolidarityTechEvents +from parsons.solidarity_tech.st_field_survey_urls import SolidarityTechFieldSurveyURLs +from parsons.solidarity_tech.st_organizations import SolidarityTechOrganizations +from parsons.solidarity_tech.st_pages import SolidarityTechPages +from parsons.solidarity_tech.st_phonebanks import SolidarityTechPhonebanks +from parsons.solidarity_tech.st_scheduled_calls import SolidarityTechScheduledCalls +from parsons.solidarity_tech.st_scheduled_tasks import SolidarityTechScheduledTasks +from parsons.solidarity_tech.st_task_agents import SolidarityTechTaskAgents +from parsons.solidarity_tech.st_task_assignments import SolidarityTechTaskAssignments +from parsons.solidarity_tech.st_team_members import SolidarityTechTeamMembers +from parsons.solidarity_tech.st_text_blasts import SolidarityTechTextBlasts +from parsons.solidarity_tech.st_text_templates import SolidarityTechTextTemplates +from parsons.solidarity_tech.st_textbanks import SolidarityTechTextbanks +from parsons.solidarity_tech.st_texts import SolidarityTechTexts +from parsons.solidarity_tech.st_user_actions import SolidarityTechUserActions +from parsons.solidarity_tech.st_user_lists import SolidarityTechUserLists +from parsons.solidarity_tech.st_user_notes import SolidarityTechUserNotes +from parsons.solidarity_tech.st_user_relationships import SolidarityTechUserRelationships +from parsons.solidarity_tech.st_users import SolidarityTechUsers + +logger = logging.getLogger(__name__) + + +class SolidarityTech( + SolidarityTechActivities, + SolidarityTechAgentAssignments, + SolidarityTechAutomationEnrollments, + SolidarityTechCalls, + SolidarityTechChapterPhoneNumbers, + SolidarityTechChapters, + SolidarityTechCustomUserProperties, + SolidarityTechDonationCharges, + SolidarityTechEmailBlasts, + SolidarityTechEmailSenders, + SolidarityTechEmails, + SolidarityTechEventAttendances, + SolidarityTechEventRSVPs, + SolidarityTechEventSessions, + SolidarityTechEvents, + SolidarityTechFieldSurveyURLs, + SolidarityTechOrganizations, + SolidarityTechPages, + SolidarityTechPhonebanks, + SolidarityTechScheduledCalls, + SolidarityTechScheduledTasks, + SolidarityTechTaskAgents, + SolidarityTechTaskAssignments, + SolidarityTechTeamMembers, + SolidarityTechTextBlasts, + SolidarityTechTextTemplates, + SolidarityTechTextbanks, + SolidarityTechTexts, + SolidarityTechUserActions, + SolidarityTechUserLists, + SolidarityTechUserNotes, + SolidarityTechUserRelationships, + SolidarityTechUsers, +): + """ + Parsons connector for interacting with `Solidarity Tech`_ endpoints. + + The SolidarityTech connector provides a complete + interface for interacting with the Solidarity Tech API. + It inherits from multiple endpoint-specific classes, + all of which ultimately inherit shared methods from + :class:`~parsons.solidarity_tech.base.SolidarityTechBase`. + + If you only need limited functionality rather than the full connector, + you can import an individual component class directly. + + .. code-block:: python + :caption: Importing a component class directly. + + from parsons.solidarity_tech import SolidarityTechEvents + + """ diff --git a/parsons/solidarity_tech/st_activities.py b/parsons/solidarity_tech/st_activities.py new file mode 100644 index 0000000000..8a428f07de --- /dev/null +++ b/parsons/solidarity_tech/st_activities.py @@ -0,0 +1,83 @@ +"""Methods for interacting with the SolidarityTech Activities endpoint.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from parsons import Table +from parsons.solidarity_tech.base import SolidarityTechBase + +if TYPE_CHECKING: + from datetime import datetime + + from parsons.solidarity_tech.datatypes import ActivityData, ActivityMetadata + from parsons.utilities.api_connector import _JsonType + +logger = logging.getLogger(__name__) + + +class SolidarityTechActivities(SolidarityTechBase): + """Methods for interacting with the SolidarityTech activities endpoint.""" + + def get_activities( + self, + limit: int = 20, + cursor: int | None = None, + since: int | datetime = 0, + user_id: int | None = None, + *, + include_count: bool = False, + ) -> tuple[Table, ActivityMetadata]: + """ + Retrieve a list of activities. + + Args: + limit: + Limits the number of items returned. + Default is 20, maximum is 100. + cursor: + Keyset pagination cursor. + Pass the meta.next_cursor value from the previous response to fetch the next (older) page. + This is the recommended way to paginate; it stays fast at any depth. + Records are returned newest first (descending id). + since: + UTC timestamp in seconds since the Unix epoch to filter calls created after this time. + user_id: + User ID to filter activities for a specific user. + include_count: + When true, meta.total_count is populated with the full result count. + Off by default because counting an entire history is expensive. + Omit it for normal paging. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + All the activities entries, along with request metadata. + + Documentation Reference: + ``__ + + """ + params: dict[str, _JsonType] = {} + self._add_if_field_not_empty(params, "user_id", user_id) + + res = self._get_resources( + "activities", + limit=limit, + cursor=cursor, + since=since, + include_count=include_count or None, + params=params, + additional_headers={"accept": "application/json"}, + ) + + expected_responses = {200: (True, "successful")} + self._handle_status_codes(res=res, codes=expected_responses) + + data: list[ActivityData] = res.json()["data"] + meta: ActivityMetadata = res.json()["meta"] + + return Table(data, name="Solidarity Tech Activities"), meta diff --git a/parsons/solidarity_tech/st_agent_assignments.py b/parsons/solidarity_tech/st_agent_assignments.py new file mode 100644 index 0000000000..51c84c84bc --- /dev/null +++ b/parsons/solidarity_tech/st_agent_assignments.py @@ -0,0 +1,242 @@ +"""Methods for interacting with the SolidarityTech Agent Assignments endpoint.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from parsons import Table +from parsons.solidarity_tech.base import SolidarityTechBase + +if TYPE_CHECKING: + from datetime import datetime + + from parsons.solidarity_tech.datatypes import AgentAssignmentData, Metadata + from parsons.utilities.api_connector import _JsonType + +logger = logging.getLogger(__name__) + + +class SolidarityTechAgentAssignments(SolidarityTechBase): + """Methods for interacting with the SolidarityTech agent assignments endpoint.""" + + def get_agent_assignments( + self, + limit: int = 20, + offset: int = 0, + since: int | datetime = 0, + user_id: int | None = None, + agent_user_id: int | None = None, + ) -> tuple[Table, Metadata]: + """ + Retrieve a list of agent assignments. + + Args: + limit: + Limits the number of items returned. + Default is 20, maximum is 100. + offset: + Number of items to skip before starting to return the results. + since: + UTC timestamp in seconds since the Unix epoch to filter calls created after this time. + user_id: + User ID to filter agent assignments related to a specific user. + agent_user_id: + Agent User ID to filter agent user assignments related to a specific agent user. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + All the agent assignment entries, along with request metadata. + + Documentation Reference: + ``__ + + """ + params: dict[str, _JsonType] = {} + self._add_if_field_not_empty(params, "user_id", user_id) + self._add_if_field_not_empty(params, "agent_user_id", agent_user_id) + + res = self._get_resources( + "agent_assignments", + limit=limit, + offset=offset, + since=since, + params=params, + ) + + expected_responses = {200: (True, "successful")} + self._handle_status_codes(res=res, codes=expected_responses) + + data: list[AgentAssignmentData] = res.json()["data"] + meta: Metadata = res.json()["meta"] + + return Table(data, name="Solidarity Tech Agent Assignments"), meta + + def get_agent_assignment( + self, + resource_id: int, + ) -> tuple[AgentAssignmentData, Metadata]: + """ + Retrieve a single agent assignment. + + Args: + resource_id: + ID of the agent assignment to retrieve. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + A single agent assignment entry. + + Documentation Reference: + ``__ + + """ + res = self._get_single_resource("agent_assignments", resource_id) + + expected_responses = { + 200: (True, "agent assignment found"), + 404: (False, "agent assignment not found"), + } + self._handle_status_codes(res=res, codes=expected_responses) + + data: AgentAssignmentData = res.json()["data"] + meta: Metadata = res.json()["meta"] + + return data, meta + + def create_agent_assignment( + self, + user_id: int, + agent_user_id: int, + *, + is_active: bool | None = None, + ) -> AgentAssignmentData: + """ + Create an agent assignment with specified details. + + Args: + user_id: + Identifier for the user. + agent_user_id: + Identifier for the agent user. + is_active: + Whether the assignment is currently active. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + payload: dict[str, _JsonType] = {"user_id": user_id, "agent_user_id": agent_user_id} + self._add_if_field_not_empty(payload, "is_active", is_active) + + res = self._post_request( + "agent_assignments", + payload=payload, + additional_headers={"content-type": "application/json"}, + ) + + expected_responses = { + 201: (True, "agent assignment created"), + 404: (False, "agent or user agent not in organization"), + } + self._handle_status_codes(res=res, codes=expected_responses) + + data: AgentAssignmentData = res.json()["data"] + + return data + + def update_agent_assignment( + self, + resource_id: int, + user_id: int, + agent_user_id: int | None = None, + *, + is_active: bool | None = None, + ) -> AgentAssignmentData: + """ + Update an agent assignment with specified details. + + Args: + resource_id: + Identifier for the agent assignment to update. + user_id: + Identifier for the user. + agent_user_id: + Identifier for the agent user. + is_active: + Whether the assignment is currently active. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + payload: dict[str, _JsonType] = {"user_id": user_id, "agent_user_id": agent_user_id} + self._add_if_field_not_empty(payload, "is_active", is_active) + + res = self._put_request( + "agent_assignments", + resource_id, + payload=payload, + additional_headers={"content-type": "application/json"}, + ) + + expected_responses = { + 200: (True, "agent assignment updated"), + 404: (False, "agent assignment not found"), + 422: (False, "unprocessable entity"), + } + self._handle_status_codes(res=res, codes=expected_responses) + + data: AgentAssignmentData = res.json()["data"] + + return data + + def delete_agent_assignment( + self, + resource_id: int, + ) -> bool: + """ + Delete an agent assignment with specified ID. + + Args: + resource_id: + Identifier for the agent assignment to update. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + res = self._delete_request("agent_assignments", resource_id) + + expected_responses = {404: (False, "agent assignment not found")} + return self._handle_status_codes(res=res, codes=expected_responses) diff --git a/parsons/solidarity_tech/st_automation_enrollments.py b/parsons/solidarity_tech/st_automation_enrollments.py new file mode 100644 index 0000000000..9cdb6f0cbf --- /dev/null +++ b/parsons/solidarity_tech/st_automation_enrollments.py @@ -0,0 +1,56 @@ +"""Methods for interacting with the SolidarityTech Automation Enrollments endpoint.""" + +from __future__ import annotations + +import logging + +from parsons.solidarity_tech.base import SolidarityTechBase, _JsonType + +logger = logging.getLogger(__name__) + + +class SolidarityTechAutomationEnrollments(SolidarityTechBase): + """Methods for interacting with the SolidarityTech automation enrollments endpoint.""" + + def enroll_user_in_automation( + self, + automation_id: int, + user_id: int, + ) -> dict[str, _JsonType]: + """ + Retrieve a list of agent assignments. + + Args: + automation_id: + The ID of the automation to enroll the user in. + user_id: + The ID of the user to enroll. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + payload: dict[str, _JsonType] = {"automation_id": automation_id, "user_id": user_id} + + res = self._post_request( + "automation_enrollments", + payload=payload, + additional_headers={"content-type": "application/json"}, + ) + + expected_responses = { + 201: (True, "enrollment created"), + 403: (False, "automation not accessible"), + 422: (False, "inactive automation"), + } + self._handle_status_codes(res=res, codes=expected_responses) + + return res.json()["data"] diff --git a/parsons/solidarity_tech/st_calls.py b/parsons/solidarity_tech/st_calls.py new file mode 100644 index 0000000000..7e286b5670 --- /dev/null +++ b/parsons/solidarity_tech/st_calls.py @@ -0,0 +1,72 @@ +"""Methods for interacting with the SolidarityTech Calls endpoint.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from parsons import Table +from parsons.solidarity_tech.base import SolidarityTechBase + +if TYPE_CHECKING: + from datetime import datetime + + from parsons.solidarity_tech.datatypes import CallData, Metadata + from parsons.utilities.api_connector import _JsonType + +logger = logging.getLogger(__name__) + + +class SolidarityTechCalls(SolidarityTechBase): + """Methods for interacting with the SolidarityTech calls endpoint.""" + + def get_calls( + self, + user_id: int | None = None, + limit: int = 20, + offset: int = 0, + since: int | datetime = 0, + ) -> tuple[Table, Metadata]: + """ + Retrieve a list of calls. + + Args: + user_id: + User ID to filter calls related to a specific user. + limit: + Limits the number of items returned. + Default is 20, maximum is 100. + offset: + Number of items to skip before starting to return the results. + since: + UTC timestamp in seconds since the Unix epoch to filter calls created after this time. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + All the calls entries. + + Documentation Reference: + ``__ + + """ + params: dict[str, _JsonType] = {"user_id": user_id} + + res = self._get_resources( + "calls", + limit=limit, + offset=offset, + since=since, + params=params, + additional_headers={"accept": "application/json"}, + ) + + expected_responses = {200: (True, "successful")} + self._handle_status_codes(res=res, codes=expected_responses) + + data: list[CallData] = res.json()["data"] + meta: Metadata = res.json()["meta"] + + return Table(data, name="Solidarity Tech Calls"), meta diff --git a/parsons/solidarity_tech/st_chapter_phone_numbers.py b/parsons/solidarity_tech/st_chapter_phone_numbers.py new file mode 100644 index 0000000000..88e69ce968 --- /dev/null +++ b/parsons/solidarity_tech/st_chapter_phone_numbers.py @@ -0,0 +1,72 @@ +"""Methods for interacting with the SolidarityTech Chapter Phone Numbers endpoint.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from parsons import Table +from parsons.solidarity_tech.base import SolidarityTechBase + +if TYPE_CHECKING: + from datetime import datetime + + from parsons.solidarity_tech.datatypes import ChapterPhoneNumberData, Metadata + from parsons.utilities.api_connector import _JsonType + +logger = logging.getLogger(__name__) + + +class SolidarityTechChapterPhoneNumbers(SolidarityTechBase): + """Methods for interacting with the SolidarityTech chapter phone numbers endpoint.""" + + def get_chapter_phone_numbers( + self, + limit: int = 20, + offset: int = 0, + since: int | datetime = 0, + chapter_id: int | None = None, + ) -> tuple[Table, Metadata]: + """ + Retrieve a list of chapter phone numbers. + + Args: + limit: + Limits the number of items returned. + Default is 20, maximum is 100. + offset: + Number of items to skip before starting to return the results. + since: + UTC timestamp in seconds since the Unix epoch to filter calls created after this time. + chapter_id: + Filters chapter phone numbers by chapter_id within the accessible scope. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + All the chapter phone numbers entries. + + Documentation Reference: + ``__ + + """ + params: dict[str, _JsonType] = {} + self._add_if_field_not_empty(params, "chapter_id", chapter_id) + + res = self._get_resources( + "chapter_phone_numbers", + limit=limit, + offset=offset, + since=since, + params=params, + ) + + expected_responses = {200: (True, "chapter phone numbers listed")} + self._handle_status_codes(res=res, codes=expected_responses) + + data: list[ChapterPhoneNumberData] = res.json()["data"] + meta: Metadata = res.json()["meta"] + + return Table(data, name="Solidarity Tech Chapter Phone Numbers"), meta diff --git a/parsons/solidarity_tech/st_chapters.py b/parsons/solidarity_tech/st_chapters.py new file mode 100644 index 0000000000..61d941244d --- /dev/null +++ b/parsons/solidarity_tech/st_chapters.py @@ -0,0 +1,66 @@ +"""Methods for interacting with the SolidarityTech Chapters endpoint.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from parsons import Table +from parsons.solidarity_tech.base import SolidarityTechBase + +if TYPE_CHECKING: + from datetime import datetime + + from parsons.solidarity_tech.datatypes import ChapterData, Metadata + + +logger = logging.getLogger(__name__) + + +class SolidarityTechChapters(SolidarityTechBase): + """Methods for interacting with the SolidarityTech chapters endpoint.""" + + def get_chapters( + self, + limit: int = 20, + offset: int = 0, + since: int | datetime = 0, + ) -> tuple[Table, Metadata]: + """ + Retrieve a list of chapters. + + Args: + limit: + Limits the number of items returned. + Default is 20, maximum is 100. + offset: + Number of items to skip before starting to return the results. + since: + UTC timestamp in seconds since the Unix epoch to filter chapters created after this time. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + All the chapters entries. + + Documentation Reference: + ``__ + + """ + res = self._get_resources( + "chapters", + limit=limit, + offset=offset, + since=since, + additional_headers={"accept": "application/json"}, + ) + + expected_responses = {200: (True, "successful")} + self._handle_status_codes(res=res, codes=expected_responses) + + data: list[ChapterData] = res.json()["data"] + meta: Metadata = res.json()["meta"] + + return Table(data, name="Solidarity Tech Chapters"), meta diff --git a/parsons/solidarity_tech/st_custom_user_properties.py b/parsons/solidarity_tech/st_custom_user_properties.py new file mode 100644 index 0000000000..516f8110da --- /dev/null +++ b/parsons/solidarity_tech/st_custom_user_properties.py @@ -0,0 +1,226 @@ +"""Methods for interacting with the SolidarityTech Custom User Properties endpoint.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from parsons import Table +from parsons.solidarity_tech.base import SolidarityTechBase + +if TYPE_CHECKING: + from datetime import datetime + + from parsons.solidarity_tech.datatypes import ( + FieldType, + Metadata, + ScopeType, + UserPropertyData, + ) + from parsons.utilities.api_connector import _JsonType + +logger = logging.getLogger(__name__) + + +class SolidarityTechCustomUserProperties(SolidarityTechBase): + """Methods for interacting with the SolidarityTech custom user properties endpoint.""" + + def get_custom_user_properties( + self, + limit: int = 20, + offset: int = 0, + since: int | datetime = 0, + scope_id: int | None = None, + scope_type: ScopeType | None = None, + ) -> tuple[Table, Metadata]: + """ + Retrieve a list of custom user properties. + + Args: + limit: + Limits the number of items returned. Default is 20, maximum is 100. + offset: + Number of items to skip before starting to return the results. + since: + UTC timestamp in seconds since the Unix epoch to filter calls created after this time. + scope_id: + ID of the scope to filter custom user properties by. + scope_type: + Type of the scope to filter custom user properties by. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + All custom user properties entries. + + Documentation Reference: + ``__ + + """ + params: dict[str, _JsonType] = {} + self._add_if_field_not_empty(params, "scope_id", scope_id) + self._add_if_field_not_empty(params, "description", scope_type) + + res = self._get_resources( + "custom_user_properties", + limit=limit, + offset=offset, + since=since, + params=params, + additional_headers={"accept": "application/json"}, + ) + + expected_responses = {200: (True, "successful")} + self._handle_status_codes(res=res, codes=expected_responses) + + data: list[UserPropertyData] = res.json()["data"] + meta: Metadata = res.json()["meta"] + + return Table(data, name="Solidarity Tech Custom User Properties"), meta + + def create_custom_user_property( + self, + label: str, + field_type: FieldType, + description: str | None = None, + options: list[dict[str, str | dict[str, str]]] | None = None, + scope_type: ScopeType | None = None, + scope_id: int | None = None, + ) -> UserPropertyData: + """ + Create a custom user property. + + Args: + label: + Display label for the property. + description: + Optional description of the property. + field_type: + Type of field for data entry. + options: + Options for select, radios, checkbox, or checkboxes field types. + See documentation. + scope_type: + Type of scope for the property. + scope_id: + ID of the scope for the property. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Created custom user property entry. + + Documentation Reference: + ``__ + + """ + payload: dict[str, _JsonType] = {"label": label, "field_type": field_type.value} + self._add_if_field_not_empty(payload, "description", description) + self._add_if_field_not_empty(payload, "options", options) + self._add_if_field_not_empty(payload, "scope_type", scope_type) + self._add_if_field_not_empty(payload, "scope_id", scope_id) + + res = self._post_request( + "custom_user_properties", + payload=payload, + additional_headers={"accept": "application/json", "content-type": "application/json"}, + ) + + expected_responses = { + 201: (True, "created"), + 422: (False, "validation failed"), + } + self._handle_status_codes(res=res, codes=expected_responses) + + return res.json()["data"] + + def delete_custom_user_property_option( + self, + custom_user_property_id: int, + resource_id: str, + ) -> UserPropertyData: + """ + Remove an option from a custom user property. + + Args: + custom_user_property_id: + Custom user property ID + resource_id: + Value of the option to remove + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Custom user property entry, as it exists after deleting the option. + + Documentation Reference: + ``__ + + """ + res = self._delete_request( + "custom_user_properties", + f"{custom_user_property_id}/options/{resource_id}", + additional_headers={"accept": "application/json"}, + ) + + expected_responses = { + 200: (True, "option removed"), + 404: (False, "option or custom user property not found"), + 422: (False, "validation failed"), + } + self._handle_status_codes(res=res, codes=expected_responses) + + return res.json()["data"] + + def create_custom_user_property_option( + self, + resource_id: int, + label: list[dict[str, str | dict[str, str]]], + value: str | None = None, + ) -> UserPropertyData: + """ + Create an option for a custom user property. + + Args: + resource_id: + Custom user property ID + label: + Multi-language labels for the option + See documentation + value: + Internal value for the option (will be auto-generated if not provided) + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Custom user property entry, as it exists after creating the new option. + + Documentation Reference: + ``__ + + """ + payload: dict[str, _JsonType] = {"label": label} + self._add_if_field_not_empty(payload, "value", value) + + res = self._post_request( + f"custom_user_properties/{resource_id}/options", + payload=payload, + additional_headers={"accept": "application/json", "content-type": "application/json"}, + ) + + expected_responses = { + 201: (True, "option created"), + 404: (False, "custom user property not found"), + 422: (False, "validation failed"), + } + self._handle_status_codes(res=res, codes=expected_responses) + + return res.json()["data"] diff --git a/parsons/solidarity_tech/st_donation_charges.py b/parsons/solidarity_tech/st_donation_charges.py new file mode 100644 index 0000000000..cad7e853fb --- /dev/null +++ b/parsons/solidarity_tech/st_donation_charges.py @@ -0,0 +1,94 @@ +"""Methods for interacting with the SolidarityTech Donation Charges endpoint.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from parsons import Table +from parsons.solidarity_tech.base import SolidarityTechBase + +if TYPE_CHECKING: + from datetime import datetime + + from parsons.solidarity_tech.datatypes import DonationChargeData, Metadata + +logger = logging.getLogger(__name__) + + +class SolidarityTechDonationCharges(SolidarityTechBase): + """Methods for interacting with the SolidarityTech donation charges endpoint.""" + + def get_donation_charges( + self, + limit: int = 20, + offset: int = 0, + since: int | datetime = 0, + ) -> tuple[Table, Metadata]: + """ + Retrieve a list of donation charges. + + Args: + limit: + Limits the number of items returned. + Default is 20, maximum is 100. + offset: + Number of items to skip before starting to return the results. + since: + UTC timestamp in seconds since the Unix epoch to filter calls created after this time. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + All the donation charges. + + Documentation Reference: + ``__ + + """ + res = self._get_resources( + "donation_charges", + limit=limit, + offset=offset, + since=since, + additional_headers={"accept": "application/json"}, + ) + + expected_responses = {200: (True, "donation charges listed")} + self._handle_status_codes(res=res, codes=expected_responses) + + data: list[DonationChargeData] = res.json()["data"] + meta: Metadata = res.json()["meta"] + + return Table(data, name="Solidarity Tech Donation Charges"), meta + + def get_donation_charge( + self, + resource_id: int, + ) -> DonationChargeData: + """ + Retrieve a single donation charge. + + Args: + resource_id: + ID of the donation charge to retrieve. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + A single donation charge entry. + + Documentation Reference: + ``__ + + """ + res = self._get_single_resource("donation_charges", resource_id) + + expected_responses = {404: (False, "donation charge not found")} + self._handle_status_codes(res=res, codes=expected_responses) + + return res.json() diff --git a/parsons/solidarity_tech/st_email_blasts.py b/parsons/solidarity_tech/st_email_blasts.py new file mode 100644 index 0000000000..33a7f1cf55 --- /dev/null +++ b/parsons/solidarity_tech/st_email_blasts.py @@ -0,0 +1,91 @@ +"""Methods for interacting with the SolidarityTech Email Blasts endpoint.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from parsons import Table +from parsons.solidarity_tech.base import SolidarityTechBase + +if TYPE_CHECKING: + from datetime import datetime + +logger = logging.getLogger(__name__) + + +class SolidarityTechEmailBlasts(SolidarityTechBase): + """Methods for interacting with the SolidarityTech email blasts endpoint.""" + + def get_email_blasts( + self, + limit: int = 20, + offset: int = 0, + since: int | datetime = 0, + ) -> Table: + """ + Retrieve a list of email blasts. + + Args: + limit: + Limits the number of items returned. + Default is 20, maximum is 100. + offset: + Number of items to skip before starting to return the results. + since: + UTC timestamp in seconds since the Unix epoch to filter calls created after this time. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + All the email blasts. + + Documentation Reference: + ``__ + + """ + res = self._get_resources( + "email_blasts", + limit=limit, + offset=offset, + since=since, + ) + + expected_responses = {200: (True, "email blasts listed")} + self._handle_status_codes(res=res, codes=expected_responses) + + return Table(res.json()) + + def get_email_blast( + self, + resource_id: int, + ) -> dict: + """ + Retrieve a single email blast. + + Args: + resource_id: + ID of the email blast to retrieve. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + A single email blast entry. + + Documentation Reference: + ``__ + + """ + res = self._get_single_resource("email_blasts", resource_id) + + expected_responses = { + 200: (True, "email blast found"), + 422: (False, "email blast not found"), + } + self._handle_status_codes(res=res, codes=expected_responses) + + return res.json() diff --git a/parsons/solidarity_tech/st_email_senders.py b/parsons/solidarity_tech/st_email_senders.py new file mode 100644 index 0000000000..fa2f882fa0 --- /dev/null +++ b/parsons/solidarity_tech/st_email_senders.py @@ -0,0 +1,60 @@ +"""Methods for interacting with the SolidarityTech Email Senders endpoint.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from parsons import Table +from parsons.solidarity_tech.base import SolidarityTechBase + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from parsons.solidarity_tech.datatypes import EmailSenderData, Metadata + + +class SolidarityTechEmailSenders(SolidarityTechBase): + """Methods for interacting with the SolidarityTech email senders endpoint.""" + + def get_email_senders( + self, + limit: int = 20, + offset: int = 0, + ) -> tuple[Table, Metadata]: + """ + Retrieve a list of email senders available for the API key's scope. + + Use these sender IDs when sending emails via the POST /emails endpoint. + + Args: + limit: + Limits the number of items returned. + Default is 20, maximum is 100. + offset: + Number of items to skip before starting to return the results. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + All the email senders. + + Documentation Reference: + ``__ + + """ + res = self._get_resources( + "email_senders", + limit=limit, + offset=offset, + additional_headers={"accept": "application/json"}, + ) + + expected_responses = {200: (True, "email senders listed")} + self._handle_status_codes(res=res, codes=expected_responses) + + data: list[EmailSenderData] = res.json()["data"] + meta: Metadata = res.json()["meta"] + return Table(data, name="Solidarity Tech Email Senders"), meta diff --git a/parsons/solidarity_tech/st_emails.py b/parsons/solidarity_tech/st_emails.py new file mode 100644 index 0000000000..f994649143 --- /dev/null +++ b/parsons/solidarity_tech/st_emails.py @@ -0,0 +1,88 @@ +"""Methods for interacting with the SolidarityTech Emails endpoint.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from parsons.solidarity_tech.base import SolidarityTechBase + +if TYPE_CHECKING: + from parsons.utilities.api_connector import _JsonType + +logger = logging.getLogger(__name__) + + +class SolidarityTechEmails(SolidarityTechBase): + """Methods for interacting with the SolidarityTech emails endpoint.""" + + def send_one_off_email( + self, + user_id: int, + subject: str, + body_html: str, + body_plain: str | None = None, + email_sender_id: int | None = None, + reply_to: str | None = None, + attachment_urls: list[str] | None = None, + *, + track_opens: bool = True, + track_clicks: bool = True, + ) -> bool: + """ + Send a single transactional email to a user. + + Supports Liquid templating for personalization (e.g., {{ first_name }}). + + Args: + user_id: + ID of the user to send email to. + subject: + Email subject line (supports Liquid templating). + body_html: + HTML content of the email (supports Liquid templating). + body_plain: + Plain text fallback content. + email_sender_id: + ID of configured email sender (uses org default if omitted). + reply_to: + Reply-to email address. + attachment_urls: + Array of URLs to files to attach (max 5). + track_opens: + Enable open tracking. Default is True. + track_clicks: + Enable click tracking. Default is True. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + params: dict[str, _JsonType] = { + "user_id": user_id, + "subject": subject, + "body_html": body_html, + "track_opens": track_opens, + "track_clicks": track_clicks, + } + self._add_if_field_not_empty(params, "body_plain", body_plain) + self._add_if_field_not_empty(params, "email_sender_id", email_sender_id) + self._add_if_field_not_empty(params, "reply_to", reply_to) + self._add_if_field_not_empty(params, "attachment_urls", attachment_urls) + + res = self._post_request("emails", params=params) + + expected_responses = { + 201: (True, "email sent successfully"), + 404: (False, "user not found"), + 422: (False, "missing required parameters"), + } + return self._handle_status_codes(res=res, codes=expected_responses) diff --git a/parsons/solidarity_tech/st_event_attendances.py b/parsons/solidarity_tech/st_event_attendances.py new file mode 100644 index 0000000000..295891f6be --- /dev/null +++ b/parsons/solidarity_tech/st_event_attendances.py @@ -0,0 +1,158 @@ +"""Methods for interacting with the SolidarityTech Event Attendances endpoint.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from parsons import Table +from parsons.solidarity_tech.base import SolidarityTechBase + +if TYPE_CHECKING: + from datetime import datetime + + from parsons.utilities.api_connector import _JsonType + +logger = logging.getLogger(__name__) + + +class SolidarityTechEventAttendances(SolidarityTechBase): + """Methods for interacting with the SolidarityTech event attendances endpoint.""" + + def get_event_attendances( + self, + limit: int = 20, + offset: int = 0, + since: int | datetime = 0, + event_id: int | None = None, + session_id: int | None = None, + ) -> Table: + """ + Retrieve a list of event attendances. + + Args: + limit: + Limits the number of items returned. + Default is 20, maximum is 100. + offset: + Number of items to skip before starting to return the results. + since: + UTC timestamp in seconds since the Unix epoch to filter calls created after this time. + event_id: + Filters attendances by event_id within the accessible scope. + session_id: + Filters attendances by session_id (calendar item id) within the accessible scope. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + All the event attendance entries. + + Documentation Reference: + ``__ + + """ + params: dict[str, _JsonType] = {} + self._add_if_field_not_empty(params, "event_id", event_id) + self._add_if_field_not_empty(params, "session_id", session_id) + + res = self._get_resources( + "event_attendances", + limit=limit, + offset=offset, + since=since, + params=params, + ) + + expected_responses = {200: (True, "event attendances listed")} + self._handle_status_codes(res=res, codes=expected_responses) + + return Table(res.json()) + + def create_event_attendance( + self, + event_id: int, + event_session_id: int, + user_id: int, + *, + attended: bool, + ) -> bool: + """ + Create an event attendance with the specified details. + + Args: + event_id: + Identifier for the Mobilize event. + event_session_id: + Identifier for the specific event session. + user_id: + Identifier for the user attending to the event. + attended: + Indicates if the user attended the event. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + payload: dict[str, _JsonType] = { + "attended": attended, + "event_id": event_id, + "event_session_id": event_session_id, + "user_id": user_id, + } + + res = self._post_request( + "event_attendances", + payload=payload, + additional_headers={"content-type": "application/json"}, + ) + + expected_responses = { + 201: (True, "event attendance created"), + 404: (False, "event not found"), + } + return self._handle_status_codes(res=res, codes=expected_responses) + + def delete_event_attendance( + self, + resource_id: str, + ) -> bool: + """ + Delete an event attendance with the specified ID. + + Args: + resource_id: + Identifier of the event attendance to delete + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + res = self._delete_request( + "event_attendances", + resource_id, + ) + + expected_responses = { + 200: (True, "event attendance deleted"), + 404: (False, "event attendance not found"), + } + return self._handle_status_codes(res=res, codes=expected_responses) diff --git a/parsons/solidarity_tech/st_event_rsvps.py b/parsons/solidarity_tech/st_event_rsvps.py new file mode 100644 index 0000000000..bd80383d92 --- /dev/null +++ b/parsons/solidarity_tech/st_event_rsvps.py @@ -0,0 +1,279 @@ +"""Methods for interacting with the SolidarityTech Event RSVPs endpoint.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from parsons import Table +from parsons.solidarity_tech.base import SolidarityTechBase + +if TYPE_CHECKING: + from datetime import datetime + + from parsons.solidarity_tech.datatypes import AttendanceStatus + from parsons.utilities.api_connector import _JsonType + +logger = logging.getLogger(__name__) + + +class SolidarityTechEventRSVPs(SolidarityTechBase): + """Methods for interacting with the SolidarityTech event rsvps endpoint.""" + + def get_event_rsvps( + self, + limit: int = 20, + offset: int = 0, + since: int | datetime = 0, + event_id: int | None = None, + session_id: int | None = None, + user_id: int | None = None, + *, + full_user_payload: bool = False, + ) -> Table: + """ + Retrieve a list of event rsvps. + + Args: + limit: + Limits the number of items returned. + Default is 20, maximum is 100. + offset: + Number of items to skip before starting to return the results. + since: + UTC timestamp in seconds since the Unix epoch to filter calls created after this time. + event_id: + Filters rsvps by event_id within the accessible scope. + session_id: + Filters rsvps by session_id (calendar item id) within the accessible scope. + user_id: + Filters rsvps by user_id within the accessible scope. + full_user_payload: + If True, includes complete user data in the response instead of just basic details. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + All the event rsvps. + + Documentation Reference: + ``__ + + """ + params: dict[str, _JsonType] = {"full_user_payload": full_user_payload} + self._add_if_field_not_empty(params, "event_id", event_id) + self._add_if_field_not_empty(params, "session_id", session_id) + self._add_if_field_not_empty(params, "user_id", user_id) + + res = self._get_resources( + "event_rsvps", + limit=limit, + offset=offset, + since=since, + params=params, + ) + + expected_responses = {200: (True, "event rsvps listed")} + self._handle_status_codes(res=res, codes=expected_responses) + + return Table(res.json()) + + def get_event_rsvp( + self, + resource_id: int, + *, + full_user_payload: bool = False, + ) -> dict: + """ + Retrieve a single event rsvp. + + Args: + resource_id: + ID of the event rsvp to retrieve. + full_user_payload: + If True, includes complete user data in the response instead of just basic details. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + A single event rsvp. + + Documentation Reference: + ``__ + + """ + params: dict[str, _JsonType] = {"full_user_payload": full_user_payload} + + res = self._get_single_resource("event_rsvps", resource_id, params=params) + + expected_responses = { + 200: (True, "event rsvp found"), + 404: (False, "event rsvp not found"), + } + self._handle_status_codes(res=res, codes=expected_responses) + + return res.json() + + def create_event_rsvp( + self, + event_id: int, + event_session_id: int, + is_attending: AttendanceStatus, + agent_user_id: int | None, + user_id: int | None = None, + source: str | None = None, + source_system: str | None = None, + *, + is_confirmed: bool | None = None, + skip_email_confirmation: bool = False, + ) -> bool: + """ + Create an event rsvp with the specified details. + + Args: + event_id: + Identifier for the Mobilize event. + event_session_id: + Identifier for the specific event session. + is_attending: + Indicates if the user is attending the event. + agent_user_id: + Identifier for the agent user, if applicable. + user_id: + Identifier for the user RSVPing to the event. + source: + Source of the RSVP. + source_system: + System from which the RSVP originated. + is_confirmed: + Indicates if the RSVP is confirmed. + skip_email_confirmation: + If True, skips sending the initial email confirmation to the user. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + payload: dict[str, _JsonType] = { + "is_attending": is_attending.value, + "agent_user_id": agent_user_id, + "event_id": event_id, + "event_session_id": event_session_id, + "skip_email_confirmation": skip_email_confirmation, + } + self._add_if_field_not_empty(payload, "user_id", user_id) + self._add_if_field_not_empty(payload, "is_confirmed", is_confirmed) + self._add_if_field_not_empty(payload, "source", source) + self._add_if_field_not_empty(payload, "source_system", source_system) + + res = self._post_request( + "event_rsvps", payload=payload, additional_headers={"content-type": "application/json"} + ) + + expected_responses = { + 201: (True, "event rsvp created"), + 404: (False, "event not found"), + } + return self._handle_status_codes(res=res, codes=expected_responses) + + def update_event_rsvp( + self, + resource_id: int, + is_attending: AttendanceStatus | None = None, + agent_user_id: int | None = None, + source: str | None = None, + source_system: str | None = None, + *, + is_confirmed: bool | None = None, + ) -> bool: + """ + Update an event rsvp with the specified details. + + Args: + resource_id: + Identifier of the event rsvp to update. + is_attending: + Indicates if the user is attending the event. + agent_user_id: + Identifier for the agent user, if applicable. + source: + Source of the RSVP. + source_system: + System from which the RSVP originated. + is_confirmed: + Indicates if the RSVP is confirmed. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + payload: dict[str, _JsonType] = {} + self._add_if_field_not_empty(payload, "is_attending", is_attending) + self._add_if_field_not_empty(payload, "is_confirmed", is_confirmed) + self._add_if_field_not_empty(payload, "agent_user_id", agent_user_id) + self._add_if_field_not_empty(payload, "source", source) + self._add_if_field_not_empty(payload, "source_system", source_system) + + res = self._put_request( + "event_rsvps", + resource_id, + payload=payload, + additional_headers={"content-type": "application/json"}, + ) + + expected_responses = { + 200: (True, "event rsvp updated"), + 404: (False, "event rsvp not found"), + } + return self._handle_status_codes(res=res, codes=expected_responses) + + def delete_event_rsvp( + self, + resource_id: str, + ) -> bool: + """ + Delete an event rsvp with the specified ID. + + Args: + resource_id: + Identifier of the event rsvp to delete + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + res = self._delete_request( + "event_rsvps", + resource_id, + ) + + expected_responses = {404: (False, "event rsvp not found")} + return self._handle_status_codes(res=res, codes=expected_responses) diff --git a/parsons/solidarity_tech/st_event_sessions.py b/parsons/solidarity_tech/st_event_sessions.py new file mode 100644 index 0000000000..552c0f7ef0 --- /dev/null +++ b/parsons/solidarity_tech/st_event_sessions.py @@ -0,0 +1,457 @@ +"""Methods for interacting with the SolidarityTech Event Sessions endpoint.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Literal + +from parsons import Table +from parsons.solidarity_tech.base import SolidarityTechBase + +if TYPE_CHECKING: + from datetime import datetime + + from parsons.solidarity_tech.datatypes import EventType + from parsons.utilities.api_connector import _JsonType + +logger = logging.getLogger(__name__) + + +class SolidarityTechEventSessions(SolidarityTechBase): + """Methods for interacting with the SolidarityTech event sessions endpoint.""" + + def get_event_sessions( + self, + limit: int = 20, + offset: int = 0, + since: int | datetime = 0, + event_id: int = 0, + starts_after: int | datetime | None = None, + starts_before: int | datetime | None = None, + chapter_id: int | None = None, + event_tags: list[str] | str | None = None, + *, + upcoming: bool | None = None, + include_rsvp_counts: bool | None = None, + include_confirmed_counts: bool | None = None, + include_hosts: bool | None = None, + count: bool | None = None, + ) -> Table: + """ + Retrieve a list of event rsvps. + + Args: + limit: + Limits the number of items returned. + Default is 20, maximum is 100. + offset: + Number of items to skip before starting to return the results. + since: + UTC timestamp in seconds since the Unix epoch to filter calls created after this time. + event_id: + Filters sessions by event_id within the accessible scope. + starts_after: + UTC timestamp in seconds since the Unix epoch; + only sessions with start_time at or after this moment. + starts_before: + UTC timestamp in seconds since the Unix epoch; + only sessions with start_time at or before this moment. + chapter_id: + Only sessions of events scoped to this chapter. + Chapters outside your accessible scope simply match nothing. + event_tags: + Comma-separated list of tags. + Matches sessions whose own tags OR whose parent event tags overlap with the list. + upcoming: + If True, returns only sessions that have not ended yet, + sorted by start_time ascending (soonest first). + include_rsvp_counts: + If True, each session in the response includes an rsvp_counts object keyed by RSVP status. + (e.g. {"yes": 12, "no": 3}) + include_confirmed_counts: + If True, each session includes a confirmed_counts object + (the same per-status breakdown as rsvp_counts, restricted to RSVPs an organizer confirmed). + include_hosts: + If True, each session includes a hosts array of + {id, first_name, last_name} objects resolved from host_user_ids, in host order. + count: + If True, returns {"count": n} of matching sessions instead of the rows. + Combines with all other filters. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + All the event sessions. + + Documentation Reference: + ``__ + + """ + if isinstance(event_tags, list): + event_tags = ",".join(str(tag) for tag in event_tags) + + params: dict[str, _JsonType] = { + "event_id": event_id, + } + self._add_if_field_not_empty(params, "upcoming", upcoming) + self._add_if_field_not_empty(params, "starts_after", starts_after) + self._add_if_field_not_empty(params, "starts_before", starts_before) + self._add_if_field_not_empty(params, "chapter_id", chapter_id) + self._add_if_field_not_empty(params, "event_tags", event_tags) + self._add_if_field_not_empty(params, "include_rsvp_counts", include_rsvp_counts) + self._add_if_field_not_empty(params, "include_confirmed_counts", include_confirmed_counts) + self._add_if_field_not_empty(params, "include_hosts", include_hosts) + self._add_if_field_not_empty(params, "count", count) + + res = self._get_resources( + "event_sessions", + limit=limit, + offset=offset, + since=since, + params=params, + ) + + expected_responses = {200: (True, "filtered event sessions listed")} + self._handle_status_codes(res=res, codes=expected_responses) + + return Table(res.json()) + + def create_event_sessions( + self, + event_id: int, + start_time: int, + end_time: int, + title: str, + event_type: Literal[EventType.VIRTUAL, EventType.IN_PERSON] | None = None, + location_name: str | None = None, + location_data: dict[str, str] | None = None, + location_address: str | None = None, + note: str | None = None, + max_capacity: int | None = None, + tags: list[str] | None = None, + *, + show_rsvp_bar: bool | None = None, + show_title_in_form: bool | None = None, + ) -> bool: + """ + Create an event rsvp with the specified details. + + Args: + event_id: + Identifier for the Mobilize event. + start_time: + UTC timestamp in seconds since the Unix epoch. + end_time: + UTC timestamp in seconds since the Unix epoch. + title: + Title of the event session. + event_type: + Type of session. + location_name: + Name of the location. + location_data: + Structured location details. + Components and coordinates may be sent either as native JSON (array/object) + or as JSON-encoded strings; both are stored and returned as JSON strings. + Neighborhood is the components entry whose types include "neighborhood"; + NYC borough is the entry whose types include "sublocality_level_1". + location_address: + Physical address of the event location. + note: + Additional notes for the event session. + max_capacity: + Maximum capacity for the event session. + tags: + Array of tags for the event session. + show_rsvp_bar: + Flag to show RSVP buttons bar. + show_title_in_form: + Flag to show title in the form. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + payload: dict[str, _JsonType] = { + "event_id": event_id, + "start_time": start_time, + "end_time": end_time, + "title": title, + } + self._add_if_field_not_empty(payload, "event_type", event_type) + self._add_if_field_not_empty(payload, "location_name", location_name) + self._add_if_field_not_empty(payload, "location_data", location_data) + self._add_if_field_not_empty(payload, "location_address", location_address) + self._add_if_field_not_empty(payload, "show_rsvp_bar", show_rsvp_bar) + self._add_if_field_not_empty(payload, "show_title_in_form", show_title_in_form) + self._add_if_field_not_empty(payload, "note", note) + self._add_if_field_not_empty(payload, "max_capacity", max_capacity) + self._add_if_field_not_empty(payload, "tags", tags) + + res = self._post_request( + "event_rsvps", payload=payload, additional_headers={"content-type": "application/json"} + ) + + expected_responses = { + 201: (True, "event session created"), + 422: (False, "unprocessable entity"), + } + return self._handle_status_codes(res=res, codes=expected_responses) + + def get_event_session( + self, + resource_id: int, + *, + include_hosts: bool = False, + ) -> dict: + """ + Retrieve a single event session. + + Args: + resource_id: + ID of the event session to retrieve. + include_hosts: + If True, the session includes a hosts array of + {id, first_name, last_name} objects resolved from host_user_ids, + in host order. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + A single event session. + + Documentation Reference: + ``__ + + """ + params: dict[str, _JsonType] = {"include_hosts": include_hosts} + res = self._get_single_resource("event_sessions", resource_id, params=params) + + expected_responses = { + 200: (True, "event session found"), + 404: (False, "event session not found"), + } + self._handle_status_codes(res=res, codes=expected_responses) + + return res.json() + + def update_event_session( + self, + resource_id: int, + start_time: int | None = None, + end_time: int | None = None, + title: str | None = None, + location_name: str | None = None, + location_address: str | None = None, + location_data: dict[str, str] | None = None, + note: str | None = None, + max_capacity: int | None = None, + tags: list[str] | None = None, + *, + show_rsvp_bar: bool | None = None, + show_title_in_form: bool | None = None, + ) -> bool: + """ + Update an event session with the specified details. + + Args: + resource_id: + Identifier of the event session to update. + start_time: + UTC timestamp in seconds since the Unix epoch. + end_time: + UTC timestamp in seconds since the Unix epoch. + title: + Title of the event session. + location_name: + Name of the location. + location_address: + Physical address of the event location. + location_data: + See :meth:`create_event_session`. + ``components``/``coordinates`` accept native JSON or JSON strings + and are stored/returned as JSON strings. + Omit to leave the existing location_data unchanged. + note: + Additional notes for the event session. + max_capacity: + Maximum capacity of the event session. + tags: + List of tags for the event session. + show_rsvp_bar: + Flag to show RSVP buttons bar. + show_title_in_form: + Flag to show title in the form. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + payload: dict[str, _JsonType] = {} + self._add_if_field_not_empty(payload, "start_time", start_time) + self._add_if_field_not_empty(payload, "end_time", end_time) + self._add_if_field_not_empty(payload, "title", title) + self._add_if_field_not_empty(payload, "location_name", location_name) + self._add_if_field_not_empty(payload, "location_address", location_address) + self._add_if_field_not_empty(payload, "location_data", location_data) + self._add_if_field_not_empty(payload, "show_rsvp_bar", show_rsvp_bar) + self._add_if_field_not_empty(payload, "show_title_in_form", show_title_in_form) + self._add_if_field_not_empty(payload, "note", note) + self._add_if_field_not_empty(payload, "max_capacity", max_capacity) + self._add_if_field_not_empty(payload, "tags", tags) + + res = self._put_request( + "event_sessions", + resource_id, + payload=payload, + additional_headers={"content-type": "application/json"}, + ) + + expected_responses = { + 200: (True, "event session updated"), + 404: (False, "event session not found"), + 422: (False, "unprocessable entity"), + } + return self._handle_status_codes(res=res, codes=expected_responses) + + def delete_event_session( + self, + resource_id: str, + ) -> bool: + """ + Delete an event session with the specified ID. + + Args: + resource_id: + Identifier of the event session to delete + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + res = self._delete_request( + "event_sessions", + resource_id, + ) + + expected_responses = { + 404: (False, "event session not found"), + } + return self._handle_status_codes(res=res, codes=expected_responses) + + def add_event_host( + self, + resource_id: int, + user_id: int, + ) -> bool: + """ + Add a user as a host of the event session. + + .. admonition:: Atomic and Idempotent + + Adding a user who is already a host returns 200 without duplicating. + The user must belong to your organization. + Hosts are readable on the session as host_user_ids and usable in + message templates via the {{ event-session.hosts }}, + {{ event-session.host }}, and {{ event-session.host-names }} merge tags. + + Args: + resource_id: + Identifier of the event session. + user_id: + ID of the user to add as a host. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + payload: dict[str, _JsonType] = {"user_id": user_id} + + res = self._post_request( + f"event_sessions/{resource_id}/hosts", + payload=payload, + additional_headers={"content-type": "application/json"}, + ) + + expected_responses = { + 200: (True, "host added"), + 404: (False, "user or event session not found"), + } + return self._handle_status_codes(res=res, codes=expected_responses) + + def remove_event_host( + self, + resource_id: str, + user_id: int, + ) -> bool: + """ + Remove a user from the event session hosts. + + .. admonition:: Atomic and Idempotent + + Removing a user who is not a host returns 200. + + + Args: + resource_id: + Identifier of the event session. + user_id: + ID of the user to remove from hosts. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + res = self._delete_request( + "event_sessions", + f"{resource_id}/hosts/{user_id}", + ) + + expected_responses = { + 200: (True, "host removed"), + } + return self._handle_status_codes(res=res, codes=expected_responses) diff --git a/parsons/solidarity_tech/st_events.py b/parsons/solidarity_tech/st_events.py new file mode 100644 index 0000000000..dbe740a3e2 --- /dev/null +++ b/parsons/solidarity_tech/st_events.py @@ -0,0 +1,227 @@ +"""Methods for interacting with the SolidarityTech Events endpoint.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from parsons import Table +from parsons.solidarity_tech.base import SolidarityTechBase + +if TYPE_CHECKING: + from datetime import datetime + + from parsons.solidarity_tech.datatypes import EventType, ScopeType + from parsons.utilities.api_connector import _JsonType + +logger = logging.getLogger(__name__) + + +class SolidarityTechEvents(SolidarityTechBase): + """Methods for interacting with the SolidarityTech events API.""" + + def get_events( + self, + limit: int = 20, + offset: int = 0, + since: int | datetime = 0, + scope_id: int | None = None, + scope_type: ScopeType | None = None, + ) -> Table: + """ + Retrieve events accessible within the given scope. + + Each event in the response includes ``primary_event_id`` and ``is_co_hosted_mirror``. + For co-hosted events that appear across multiple organizations, + ``primary_event_id`` always resolves to the original event ID, + allowing you to identify that two events from different scopes represent the same real world event. + Each event session also includes ``primary_session_id`` for the same purpose. + Events with an event page also include ``image_url`` and ``description`` fields, plus ``accessibility_info``; this is + an optional per-language hash of accessibility details from the event page settings + (e.g. {"en": "Wheelchair accessible entrance"}), null when not provided. + + Args: + limit: + Limits the number of items returned. + Default is 20, maximum is 100. + offset: + Number of items to skip before starting to return the results. + since: + UTC timestamp in seconds since the Unix epoch to filter calls created after this time. + scope_id: + ID of the scope to filter events by. + scope_type: + Type of the scope to filter events by. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + All the events. + + Documentation Reference: + ``__ + + """ + params: dict[str, _JsonType] = {} + self._add_if_field_not_empty(params, "scope_id", scope_id) + self._add_if_field_not_empty(params, "scope_type", scope_type) + + res = self._get_resources( + "events", + limit=limit, + offset=offset, + since=since, + params=params, + ) + + expected_responses = {200: (True, "events listed")} + self._handle_status_codes(res=res, codes=expected_responses) + + return Table(res.json()) + + def create_event( + self, + title: str, + event_type: EventType, + start_time: int, + end_time: int, + scope_id: str, + scope_type: ScopeType, + location_address: str | None = None, + virtual_url: str | None = None, + location_name: str | None = None, + session_title: str | None = None, + tags: list[str] | None = None, + max_capacity: int | None = None, + latitude: float | None = None, + longitude: float | None = None, + *, + skip_duplicate_check: bool = False, + ) -> bool: + """ + Create an event with its first event session. + + The event session inherits the title from the event unless ``session_title`` is provided. + + Args: + title: + Event title (max 65 characters). + event_type: + Type of event. + start_time: + Start time as UNIX timestamp. + end_time: + End time as UNIX timestamp. + location_address: + For virtual: meeting URL. + For in_person and hybrid: street address for the in-person session. + virtual_url: + Meeting URL for the virtual session when event_type is hybrid. + location_name: + Display name for location (e.g., "City Hall"). + scope_id: + ID of the scope (Organization or Chapter). + scope_type: + Type of scope. + session_title: + Title for the first event session (defaults to event title). + tags: + Event tags. + max_capacity: + Maximum capacity for the event session (0 = unlimited). + latitude: + Latitude for ``in_person`` events (optional, will geocode if not provided). + longitude: + Longitude for ``in_person`` events (optional, will geocode if not provided). + skip_duplicate_check: + If True, bypasses duplicate event detection. Default is False. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + payload: dict[str, _JsonType] = { + "title": title, + "event_type": event_type.value, + "start_time": start_time, + "end_time": end_time, + "scope_id": scope_id, + "scope_type": scope_type.value, + } + self._add_if_field_not_empty(payload, "location_address", location_address) + self._add_if_field_not_empty(payload, "virtual_url", virtual_url) + self._add_if_field_not_empty(payload, "location_name", location_name) + self._add_if_field_not_empty(payload, "session_title", session_title) + self._add_if_field_not_empty(payload, "tags", tags) + self._add_if_field_not_empty(payload, "max_capacity", max_capacity) + self._add_if_field_not_empty(payload, "latitude", latitude) + self._add_if_field_not_empty(payload, "longitude", longitude) + self._add_if_field_not_empty(payload, "skip_duplicate_check", skip_duplicate_check) + + res = self._post_request( + "events", payload=payload, additional_headers={"content-type": "application/json"} + ) + + expected_responses = { + 201: (True, "event created"), + 404: (False, "scope not found"), + 409: (False, "duplicate event detected"), + 422: (False, "validation error"), + } + return self._handle_status_codes(res=res, codes=expected_responses) + + def get_event( + self, + resource_id: int, + *, + include_hosts: bool = False, + ) -> dict: + """ + Retrieve a single event. + + The response includes ``primary_event_id`` + (always resolves to the original event ID, even for co-hosted mirrors) and + ``is_co_hosted_mirror`` (true if this event is a mirror copy from a co-host relationship). + Event sessions include ``primary_session_id`` for the same purpose. + If the event has an event page, the response also includes ``image_url`` (the event page image), + ``description`` (plain text content from the event page), and ``accessibility_info`` + (an optional per-language hash of accessibility details from the event page settings, + e.g. {"en": "Wheelchair accessible entrance"}). + These fields are null when no event page exists or the value is not set. + + Args: + resource_id: + ID of the event to retrieve. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + A single event. + + Documentation Reference: + ``__ + + """ + params: dict[str, _JsonType] = {"include_hosts": include_hosts} + + res = self._get_single_resource("event_sessions", resource_id, params=params) + + expected_responses = { + 200: (True, "event found"), + 404: (False, "event not found"), + } + self._handle_status_codes(res=res, codes=expected_responses) + + return res.json() diff --git a/parsons/solidarity_tech/st_field_survey_urls.py b/parsons/solidarity_tech/st_field_survey_urls.py new file mode 100644 index 0000000000..b02ec01edb --- /dev/null +++ b/parsons/solidarity_tech/st_field_survey_urls.py @@ -0,0 +1,69 @@ +"""Methods for interacting with the SolidarityTech Field Survey URLs endpoint.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from parsons.solidarity_tech.base import SolidarityTechBase, _JsonType + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from parsons.solidarity_tech.datatypes import FieldSurveyURL + + +class SolidarityTechFieldSurveyURLs(SolidarityTechBase): + """Methods for generating SolidarityTech field survey URLs.""" + + def generate_field_survey_url( + self, + user_id: int, + agent_user_id: int, + page_id: int, + ) -> FieldSurveyURL: + """ + Generate a field survey URL for the given user, agent, and page. + + Response contains complete URL with access token (expires in 24 hours), + and an ISO 8601 timestamp of when the access token expires. + + Args: + user_id: + The ID of the user to generate URL for. + agent_user_id: + The ID of the agent user. + page_id: + The ID of the action page (field survey). + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + URL and expiration timestamp. + + Documentation Reference: + ``__ + + """ + payload: dict[str, _JsonType] = { + "user_id": user_id, + "agent_user_id": agent_user_id, + "page_id": page_id, + } + + res = self._post_request( + "field_survey_urls", + payload=payload, + additional_headers={"accept": "application/json", "content-type": "application/json"}, + ) + + expected_responses = { + 200: (True, "field survey URL generated"), + 404: (False, "user, agent, or page not found"), + 422: (False, "missing required parameters"), + } + self._handle_status_codes(res=res, codes=expected_responses) + + return res.json() diff --git a/parsons/solidarity_tech/st_organizations.py b/parsons/solidarity_tech/st_organizations.py new file mode 100644 index 0000000000..9907ee79e5 --- /dev/null +++ b/parsons/solidarity_tech/st_organizations.py @@ -0,0 +1,91 @@ +"""Methods for interacting with the SolidarityTech Organizations endpoint.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from parsons import Table +from parsons.solidarity_tech.base import SolidarityTechBase + +if TYPE_CHECKING: + from datetime import datetime + +logger = logging.getLogger(__name__) + + +class SolidarityTechOrganizations(SolidarityTechBase): + """Methods for interacting with the SolidarityTech organizations API.""" + + def get_organizations( + self, + limit: int = 20, + offset: int = 0, + since: int | datetime = 0, + ) -> Table: + """ + Retrieve a list of organizations. + + Args: + limit: + Limits the number of items returned. + Default is 20, maximum is 100. + offset: + Number of items to skip before starting to return the results. + since: + UTC timestamp in seconds since the Unix epoch to filter calls created after this time. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + All the organizations. + + Documentation Reference: + ``__ + + """ + res = self._get_resources( + "organizations", + limit=limit, + offset=offset, + since=since, + ) + + expected_responses = {200: (True, "organizations listed")} + self._handle_status_codes(res=res, codes=expected_responses) + + return Table(res.json()) + + def get_organization( + self, + resource_id: int, + ) -> dict: + """ + Retrieve a single organization. + + Args: + resource_id: + ID of the organization to retrieve. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + A single organization entry. + + Documentation Reference: + ``__ + + """ + res = self._get_single_resource("organizations", resource_id) + + expected_responses = { + 200: (True, "organization found"), + 404: (False, "organization not found"), + } + self._handle_status_codes(res=res, codes=expected_responses) + + return res.json() diff --git a/parsons/solidarity_tech/st_pages.py b/parsons/solidarity_tech/st_pages.py new file mode 100644 index 0000000000..0fb4749520 --- /dev/null +++ b/parsons/solidarity_tech/st_pages.py @@ -0,0 +1,114 @@ +"""Methods for interacting with the SolidarityTech Pages endpoint.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from parsons import Table +from parsons.solidarity_tech.base import SolidarityTechBase + +if TYPE_CHECKING: + from datetime import datetime + + from parsons.utilities.api_connector import _JsonType + +logger = logging.getLogger(__name__) + + +class SolidarityTechPages(SolidarityTechBase): + """Methods for interacting with the SolidarityTech pages endpoint.""" + + def get_pages( + self, + limit: int = 20, + offset: int = 0, + since: int | datetime = 0, + *, + include_action_counts: bool = False, + ) -> Table: + """ + Retrieve a list of pages. + + Args: + limit: + Limits the number of items returned. + Default is 20, maximum is 100. + offset: + Number of items to skip before starting to return the results. + since: + UTC timestamp in seconds since the Unix epoch to filter calls created after this time. + include_action_counts: + If True, each page includes ``action_count`` (total submissions) and ``action_goal`` + (the next milestone the public progress bar would display for that count). + Default is False. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + All the pages. + + Documentation Reference: + ``__ + + """ + params: dict[str, _JsonType] = {"include_action_counts": include_action_counts} + + res = self._get_resources( + "pages", + limit=limit, + offset=offset, + since=since, + params=params, + ) + + expected_responses = {200: (True, "pages listed")} + self._handle_status_codes(res=res, codes=expected_responses) + + return Table(res.json()) + + def get_page( + self, + resource_id: int, + *, + include_action_counts: bool = False, + ) -> dict: + """ + Retrieve a single page. + + Args: + resource_id: + ID of the page to retrieve. + include_action_counts: + If True, the page includes ``action_count`` (total submissions) and ``action_goal`` + (the next milestone the public progress bar would display for that count). + Default is False. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + A single page entry. + + Raises: + STFailedResponseError: If the page is not found. + STUnexpectedResponseError: If the operation fails with an unexpected status code. + + Documentation Reference: + ``__ + + """ + params: dict[str, _JsonType] = {"include_action_counts": include_action_counts} + + res = self._get_single_resource("pages", resource_id, params=params) + + expected_responses = { + 200: (True, "page found"), + 404: (False, "page not found"), + } + self._handle_status_codes(res=res, codes=expected_responses) + + return res.json() diff --git a/parsons/solidarity_tech/st_phonebanks.py b/parsons/solidarity_tech/st_phonebanks.py new file mode 100644 index 0000000000..1e76d2c237 --- /dev/null +++ b/parsons/solidarity_tech/st_phonebanks.py @@ -0,0 +1,114 @@ +"""Methods for interacting with the SolidarityTech Phonebanks endpoint.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from parsons import Table +from parsons.solidarity_tech.base import SolidarityTechBase + +if TYPE_CHECKING: + from datetime import datetime + + from parsons.utilities.api_connector import _JsonType + +logger = logging.getLogger(__name__) + + +class SolidarityTechPhonebanks(SolidarityTechBase): + """Methods for interacting with the SolidarityTech phonebanks endpoint.""" + + def get_phonebanks( + self, + limit: int = 20, + offset: int = 0, + since: int | datetime = 0, + event_id: int = 0, + ids: list[int] | str | None = None, + *, + include_stats: bool = False, + ) -> Table: + """ + Retrieve a list of phonebanks. + + Args: + limit: + Limits the number of items returned. + Default is 20, maximum is 100. + offset: + Number of items to skip before starting to return the results. + since: + UTC timestamp in seconds since the Unix epoch to filter calls created after this time. + event_id: + Filters phonebanks by event_id within the accessible scope. + ids: + Filters to specific phonebank ids. + Accepts a comma-separated string (e.g. "12,34"). + include_stats: + If True, each phonebank row also includes aggregate funnel numbers + ``attempts`` (contact attempts), ``contacted`` (distinct people attempted), + and ``reached`` (distinct people on answered calls). + Default is False. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + All the phonebanks. + + Documentation Reference: + ``__ + + """ + if isinstance(ids, list): + ids = ",".join(str(phonebank_id) for phonebank_id in ids) + + params: dict[str, _JsonType] = {"event_id": event_id, "include_stats": include_stats} + self._add_if_field_not_empty(params, "ids", ids) + + res = self._get_resources( + "phonebanks", + limit=limit, + offset=offset, + since=since, + params=params, + ) + + expected_responses = {200: (True, "phonebanks listed")} + self._handle_status_codes(res=res, codes=expected_responses) + + return Table(res.json()) + + def get_phonebank( + self, + resource_id: int, + ) -> dict: + """ + Retrieve a single phonebank. + + Args: + resource_id: + ID of the phonebank to retrieve. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + A single phonebank entry. + + Documentation Reference: + ``__ + + """ + res = self._get_single_resource("phonebanks", resource_id) + + expected_responses = { + 200: (True, "phonebank found"), + 404: (False, "phonebank not found"), + } + self._handle_status_codes(res=res, codes=expected_responses) + + return res.json() diff --git a/parsons/solidarity_tech/st_scheduled_calls.py b/parsons/solidarity_tech/st_scheduled_calls.py new file mode 100644 index 0000000000..911bd72c87 --- /dev/null +++ b/parsons/solidarity_tech/st_scheduled_calls.py @@ -0,0 +1,104 @@ +"""Methods for interacting with the SolidarityTech Scheduled Calls endpoint.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from parsons import Table +from parsons.solidarity_tech.base import SolidarityTechBase + +if TYPE_CHECKING: + from datetime import datetime + + from parsons.utilities.api_connector import _JsonType + +logger = logging.getLogger(__name__) + + +class SolidarityTechScheduledCalls(SolidarityTechBase): + """Methods for interacting with the SolidarityTech scheduled calls endpoint.""" + + def get_scheduled_calls( + self, + limit: int = 20, + offset: int = 0, + since: int | datetime = 0, + user_id: int | None = None, + agent_user_id: int | None = None, + ) -> Table: + """ + Retrieve a list of scheduled calls. + + Args: + limit: + Limits the number of items returned. + Default is 20, maximum is 100. + offset: + Number of items to skip before starting to return the results. + since: + UTC timestamp in seconds since the Unix epoch to filter calls created after this time. + user_id: + User ID to filter scheduled calls related to a specific user. + agent_user_id: + Agent User ID to filter agent user assignments related to a specific agent user. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + All the scheduled calls. + + Documentation Reference: + ``__ + + """ + params: dict[str, _JsonType] = {} + self._add_if_field_not_empty(params, "user_id", user_id) + self._add_if_field_not_empty(params, "agent_user_id", agent_user_id) + + res = self._get_resources( + "scheduled_calls", + limit=limit, + offset=offset, + since=since, + params=params, + ) + + expected_responses = {200: (True, "scheduled calls listed")} + self._handle_status_codes(res=res, codes=expected_responses) + + return Table(res.json()) + + def get_scheduled_call( + self, + resource_id: int, + ) -> dict: + """ + Retrieve a single scheduled call. + + Args: + resource_id: + ID of the scheduled call to retrieve. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + A single scheduled call entry. + + Documentation Reference: + ``__ + + """ + res = self._get_single_resource("scheduled_calls", resource_id) + + expected_responses = { + 200: (True, "scheduled call found"), + 404: (False, "scheduled call not found"), + } + self._handle_status_codes(res=res, codes=expected_responses) + + return res.json() diff --git a/parsons/solidarity_tech/st_scheduled_tasks.py b/parsons/solidarity_tech/st_scheduled_tasks.py new file mode 100644 index 0000000000..39895d59f2 --- /dev/null +++ b/parsons/solidarity_tech/st_scheduled_tasks.py @@ -0,0 +1,269 @@ +"""Methods for interacting with the SolidarityTech Scheduled Tasks endpoint.""" + +from __future__ import annotations + +import logging +from datetime import datetime +from typing import TYPE_CHECKING + +from parsons import Table +from parsons.solidarity_tech.base import SolidarityTechBase + +if TYPE_CHECKING: + from parsons.utilities.api_connector import _JsonType + +logger = logging.getLogger(__name__) + + +class SolidarityTechScheduledTasks(SolidarityTechBase): + """Methods for interacting with the SolidarityTech scheduled tasks endpoint.""" + + def get_scheduled_tasks( + self, + limit: int = 20, + offset: int = 0, + since: int | datetime = 0, + user_id: int | None = None, + agent_user_id: int | None = None, + ) -> Table: + """ + Retrieve a list of scheduled tasks. + + Args: + limit: + Limits the number of items returned. + Default is 20, maximum is 100. + offset: + Number of items to skip before starting to return the results. + since: + UTC timestamp in seconds since the Unix epoch to filter calls created after this time. + user_id: + User ID to filter scheduled tasks related to a specific user. + agent_user_id: + Agent User ID to filter agent user assignments related to a specific agent user. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + All the scheduled task entries. + + Documentation Reference: + ``__ + + """ + params: dict[str, _JsonType] = {} + self._add_if_field_not_empty(params, "user_id", user_id) + self._add_if_field_not_empty(params, "agent_user_id", agent_user_id) + + res = self._get_resources( + "scheduled_tasks", + limit=limit, + offset=offset, + since=since, + params=params, + ) + + expected_responses = {200: (True, "scheduled tasks listed")} + self._handle_status_codes(res=res, codes=expected_responses) + + return Table(res.json()) + + def get_scheduled_task( + self, + resource_id: int, + ) -> dict: + """ + Retrieve a single scheduled task. + + Args: + resource_id: + ID of the scheduled task to retrieve. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + A single scheduled task entry. + + Documentation Reference: + ``__ + + """ + res = self._get_single_resource("scheduled_tasks", resource_id) + + expected_responses = { + 200: (True, "scheduled task found"), + 404: (False, "scheduled task not found"), + } + self._handle_status_codes(res=res, codes=expected_responses) + + return res.json() + + def create_scheduled_task( + self, + due_at: str | int | float | datetime, + remind_at: str | int | float | datetime | None = None, + agent_user_id: int | None = None, + user_id: int | None = None, + notes: str | None = None, + *, + marked_as_completed: bool | None = None, + ) -> bool: + """ + Create an scheduled task with specified details. + + Args: + due_at: + The date and time when the task is due. + Accepts either an ISO 8601 formatted date-time string + or a UNIX timestamp as a string or integer. + remind_at: + The date and time when a reminder for the task should be sent. + Accepts either an ISO 8601 formatted date-time string + or a UNIX timestamp as a string or integer. + agent_user_id: + Identifier for the agent user assigned to the task. + user_id: + Identifier for the user who created the task. + ``notes``: + Additional notes or details about the task. + marked_as_completed: + Indicates if the task has been marked as completed. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + if isinstance(due_at, datetime): + due_at = due_at.timestamp() + if isinstance(remind_at, datetime): + remind_at = remind_at.timestamp() + + payload: dict[str, _JsonType] = {"due_at": due_at} + self._add_if_field_not_empty(payload, "remind_at", remind_at) + self._add_if_field_not_empty(payload, "agent_user_id", agent_user_id) + self._add_if_field_not_empty(payload, "user_id", user_id) + self._add_if_field_not_empty(payload, "notes", notes) + self._add_if_field_not_empty(payload, "marked_as_completed", marked_as_completed) + + res = self._post_request( + "scheduled_tasks", + payload=payload, + additional_headers={"content-type": "application/json"}, + ) + + expected_responses = { + 201: (True, "scheduled task created"), + 404: (False, "agent or user agent not in organization"), + } + return self._handle_status_codes(res=res, codes=expected_responses) + + def update_scheduled_task( + self, + resource_id: int, + due_at: str | int | float | datetime | None = None, + remind_at: str | int | float | datetime | None = None, + agent_user_id: int | None = None, + user_id: int | None = None, + notes: str | None = None, + *, + marked_as_completed: bool | None = None, + ) -> bool: + """ + Update a scheduled task with specified details. + + Args: + resource_id: + Identifier for the scheduled task to update. + due_at: + The date and time when the task is due. + Accepts either an ISO 8601 formatted date-time string + or a UNIX timestamp as a string or integer. + remind_at: + Reminder time for the task. + agent_user_id: + Identifier for the agent user. + user_id: + Identifier for the user. + ``notes``: + Additional notes or details about the task. + marked_as_completed: + Indicates if the task has been marked as completed. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + if isinstance(due_at, datetime): + due_at = due_at.timestamp() + if isinstance(remind_at, datetime): + remind_at = remind_at.timestamp() + + payload: dict[str, _JsonType] = {} + self._add_if_field_not_empty(payload, "due_at", due_at) + self._add_if_field_not_empty(payload, "remind_at", remind_at) + self._add_if_field_not_empty(payload, "agent_user_id", agent_user_id) + self._add_if_field_not_empty(payload, "user_id", user_id) + self._add_if_field_not_empty(payload, "notes", notes) + self._add_if_field_not_empty(payload, "marked_as_completed", marked_as_completed) + + res = self._put_request( + "scheduled_tasks", + resource_id, + payload=payload, + additional_headers={"content-type": "application/json"}, + ) + + expected_responses = { + 200: (True, "scheduled task updated"), + 404: (False, "scheduled task not found"), + 422: (False, "unprocessable entity"), + } + return self._handle_status_codes(res=res, codes=expected_responses) + + def delete_scheduled_task( + self, + resource_id: int, + ) -> bool: + """ + Delete a scheduled task with the specified ID. + + Args: + resource_id: + Identifier for the scheduled task to delete. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + res = self._delete_request("scheduled_tasks", resource_id) + + expected_responses = {404: (False, "scheduled task not found")} + return self._handle_status_codes(res=res, codes=expected_responses) diff --git a/parsons/solidarity_tech/st_task_agents.py b/parsons/solidarity_tech/st_task_agents.py new file mode 100644 index 0000000000..3583396ae4 --- /dev/null +++ b/parsons/solidarity_tech/st_task_agents.py @@ -0,0 +1,163 @@ +"""Methods for interacting with the SolidarityTech Task Agents endpoint.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from parsons import Table +from parsons.solidarity_tech.base import SolidarityTechBase + +if TYPE_CHECKING: + from datetime import datetime + + from parsons.utilities.api_connector import _JsonType + +logger = logging.getLogger(__name__) + + +class SolidarityTechTaskAgents(SolidarityTechBase): + """Methods for interacting with the SolidarityTech task agents endpoint.""" + + def get_task_agents( + self, + limit: int = 20, + offset: int = 0, + since: int | datetime = 0, + task_id: int | None = None, + ) -> Table: + """ + Retrieve a list of task agents. + + Args: + limit: + Limits the number of items returned. + Default is 20, maximum is 100. + offset: + Number of items to skip before starting to return the results. + since: + UTC timestamp in seconds since the Unix epoch to filter calls created after this time. + task_id: + Filters task agents by task within the accessible scope. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + All the task agent entries. + + Documentation Reference: + ``__ + + """ + params: dict[str, _JsonType] = {} + self._add_if_field_not_empty(params, "task_id", task_id) + + res = self._get_resources( + "task_agents", + limit=limit, + offset=offset, + since=since, + params=params, + ) + + expected_responses = {200: (True, "task agents listed")} + self._handle_status_codes(res=res, codes=expected_responses) + + return Table(res.json()) + + def get_task_agent( + self, + resource_id: int, + ) -> dict: + """ + Retrieve a single task agent. + + Args: + resource_id: + ID of the task agent to retrieve. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + A single task agent entry. + + Documentation Reference: + ``__ + + """ + res = self._get_single_resource("task_agents", resource_id) + + expected_responses = { + 200: (True, "task agent found"), + 404: (False, "task agent not found"), + } + self._handle_status_codes(res=res, codes=expected_responses) + + return res.json() + + def create_task_agent( + self, + user_id: int, + task_id: int, + ) -> bool: + """ + Create an task agent with specified details. + + Args: + user_id: + Identifier for the task agent. + task_id: + Identifier for the task. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + payload: dict[str, _JsonType] = {"user_id": user_id, "task_id": task_id} + + res = self._post_request( + "task_agents", payload=payload, additional_headers={"content-type": "application/json"} + ) + + expected_responses = {201: (True, "task agent created")} + return self._handle_status_codes(res=res, codes=expected_responses) + + def delete_task_agent( + self, + resource_id: int, + ) -> bool: + """ + Delete a task agent with the specified ID. + + Args: + resource_id: + Identifier for the task agent to delete. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + res = self._delete_request("task_agents", resource_id) + + expected_responses = {404: (False, "task agent not found")} + return self._handle_status_codes(res=res, codes=expected_responses) diff --git a/parsons/solidarity_tech/st_task_assignments.py b/parsons/solidarity_tech/st_task_assignments.py new file mode 100644 index 0000000000..386a941a92 --- /dev/null +++ b/parsons/solidarity_tech/st_task_assignments.py @@ -0,0 +1,218 @@ +"""Methods for interacting with the SolidarityTech Task Assignments endpoint.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from parsons import Table +from parsons.solidarity_tech.base import SolidarityTechBase + +if TYPE_CHECKING: + from datetime import datetime + + from parsons.utilities.api_connector import _JsonType + +logger = logging.getLogger(__name__) + + +class SolidarityTechTaskAssignments(SolidarityTechBase): + """Methods for interacting with the SolidarityTech task assignments endpoint.""" + + def get_task_assignments( + self, + limit: int = 20, + offset: int = 0, + since: int | datetime = 0, + task_id: int = 0, + agent_user_id: int = 0, + ) -> Table: + """ + Retrieve a list of task assignments. + + Args: + limit: + Limits the number of items returned. + Default is 20, maximum is 100. + offset: + Number of items to skip before starting to return the results. + since: + UTC timestamp in seconds since the Unix epoch to filter calls created after this time. + task_id: + Filters task assignments by phonebank or textbank task within the accessible scope. + agent_user_id: + Filters task assignments by agent user within the accessible scope. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + All the task assignment entries. + + Documentation Reference: + ``__ + + """ + params: dict[str, _JsonType] = {"task_id": task_id, "agent_user_id": agent_user_id} + + res = self._get_resources( + "task_assignments", + limit=limit, + offset=offset, + since=since, + params=params, + ) + + expected_responses = {200: (True, "task assignments listed")} + self._handle_status_codes(res=res, codes=expected_responses) + + return Table(res.json()) + + def get_task_assignment( + self, + resource_id: int, + ) -> dict: + """ + Retrieve a single task assignment. + + Args: + resource_id: + ID of the task assignment to retrieve. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + A single task assignment entry. + + Documentation Reference: + ``__ + + """ + res = self._get_single_resource("task_assignments", resource_id) + + expected_responses = { + 200: (True, "task assignment found"), + 404: (False, "task assignment not found"), + } + self._handle_status_codes(res=res, codes=expected_responses) + + return res.json() + + def create_task_assignment( + self, + user_id: int, + task_id: int, + agent_user_id: int | None = None, + ) -> bool: + """ + Create a task assignment. + + Assigns a user to participate in a phonebank or textbank campaign. + + Args: + user_id: + Identifier for the task assignment user. + task_id: + Identifier for the phonebank or textbank task. + agent_user_id: + Identifier for the agent user who will conduct outreach (volunteer or staff member). + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + payload: dict[str, _JsonType] = { + "user_id": user_id, + "task_id": task_id, + } + self._add_if_field_not_empty(payload, "agent_user_id", agent_user_id) + + res = self._post_request( + "task_assignments", + payload=payload, + additional_headers={"content-type": "application/json"}, + ) + + expected_responses = {201: (True, "task assignment created")} + return self._handle_status_codes(res=res, codes=expected_responses) + + def update_task_assignment( + self, + resource_id: int, + agent_user_id: int | None = None, + ) -> bool: + """ + Update an task assignment with the specified details. + + Args: + resource_id: + Identifier of the task assignment to update. + agent_user_id: + Identifier for the agent user, if applicable. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + payload: dict[str, _JsonType] = {} + self._add_if_field_not_empty(payload, "agent_user_id", agent_user_id) + + res = self._put_request( + "scheduled_tasks", + resource_id, + payload=payload, + additional_headers={"content-type": "application/json"}, + ) + + expected_responses = { + 200: (True, "task assignment updated"), + 404: (False, "event rsvp not found"), + } + return self._handle_status_codes(res=res, codes=expected_responses) + + def delete_task_assignment( + self, + resource_id: int, + ) -> bool: + """ + Delete a task assignment with the specified ID. + + Args: + resource_id: + Identifier of the task assignment to delete. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + res = self._delete_request("task_assignments", resource_id) + + expected_responses = {404: (False, "task assignment not found")} + return self._handle_status_codes(res=res, codes=expected_responses) diff --git a/parsons/solidarity_tech/st_team_members.py b/parsons/solidarity_tech/st_team_members.py new file mode 100644 index 0000000000..0b3bbe88cb --- /dev/null +++ b/parsons/solidarity_tech/st_team_members.py @@ -0,0 +1,190 @@ +"""Methods for interacting with the SolidarityTech Team Members endpoint.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from parsons import Table +from parsons.solidarity_tech.base import SolidarityTechBase, _JsonType + +if TYPE_CHECKING: + from datetime import datetime + + from parsons.solidarity_tech.datatypes import InviteType, ScopeType + +logger = logging.getLogger(__name__) + + +class SolidarityTechTeamMembers(SolidarityTechBase): + """Methods for interacting with the SolidarityTech team members endpoint.""" + + def get_team_members( + self, + limit: int = 20, + offset: int = 0, + since: int | datetime = 0, + ) -> Table: + """ + Retrieve a list of team members. + + Args: + limit: + Limits the number of items returned. + Default is 20, maximum is 100. + offset: + Number of items to skip before starting to return the results. + since: + UTC timestamp in seconds since the Unix epoch to filter calls created after this time. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + All the team member entries. + + Documentation Reference: + ``__ + + """ + res = self._get_resources( + "team_members", + limit=limit, + offset=offset, + since=since, + ) + + expected_responses = {200: (True, "team members listed")} + self._handle_status_codes(res=res, codes=expected_responses) + + return Table(res.json()) + + def create_team_member( + self, + role_id: int, + scope_type: ScopeType, + scope_id: int, + invite_via: InviteType, + member_id: str | None = None, + phone_number: str | None = None, + email: str | None = None, + full_name: str | None = None, + first_name: str | None = None, + last_name: str | None = None, + task_id: int | None = None, + ) -> bool: + """ + Create a new team member. + + Args: + member_id: + Hash ID of existing user (optional if phone_number or email provided). + phone_number: + Phone number of the person (primary key for user lookup/creation). + email: + Email of the person (secondary key for user lookup/creation). + full_name: + Full name for new user creation. + first_name: + First name for new user creation. + last_name: + Last name for new user creation. + role_id: + ID of the role to assign. + scope_type: + Type of scope. + scope_id: + ID of the scope (Chapter or Organization). + invite_via: + How to send the invitation. + task_id: + Optional task ID to assign the member to. + + Raises: + :class:`ValueError`: If none of ``member_id``, ``phone_number`` or ``email`` is provided. + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + if not member_id and not phone_number and not email: + err_msg = "One of member_id, phone_number, or email is required." + raise ValueError(err_msg) + + payload: dict[str, _JsonType] = { + "role_id": role_id, + "scope_type": scope_type.value, + "scope_id": scope_id, + "invite_via": invite_via.value, + } + self._add_if_field_not_empty(payload, "member_id", member_id) + self._add_if_field_not_empty(payload, "phone_number", phone_number) + self._add_if_field_not_empty(payload, "email", email) + self._add_if_field_not_empty(payload, "full_name", full_name) + self._add_if_field_not_empty(payload, "first_name", first_name) + self._add_if_field_not_empty(payload, "last_name", last_name) + self._add_if_field_not_empty(payload, "task_id", task_id) + + res = self._post_request( + "team_members", payload=payload, additional_headers={"content-type": "application/json"} + ) + + expected_responses = { + 201: (True, "team member created"), + 422: (False, "invalid parameters"), + } + return self._handle_status_codes(res=res, codes=expected_responses) + + def update_team_member( + self, + resource_id: int, + role_id: int, + scope_type: ScopeType, + scope_id: int, + ) -> bool: + """ + Update a team member with the specified details. + + Args: + resource_id: + Team member ID (UserRoleScope ID). + role_id: + ID of the role to assign. + scope_type: + Type of scope. + scope_id: + ID of the scope (Chapter or Organization). + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Raises: + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Documentation Reference: + ``__ + + """ + payload: dict[str, _JsonType] = { + "role_id": role_id, + "scope_type": scope_type.value, + "scope_id": scope_id, + } + + res = self._put_request( + "team_members", + resource_id, + payload=payload, + additional_headers={"content-type": "application/json"}, + ) + + expected_responses = {200: (True, "team member updated")} + return self._handle_status_codes(res=res, codes=expected_responses) diff --git a/parsons/solidarity_tech/st_text_blasts.py b/parsons/solidarity_tech/st_text_blasts.py new file mode 100644 index 0000000000..aef637635b --- /dev/null +++ b/parsons/solidarity_tech/st_text_blasts.py @@ -0,0 +1,91 @@ +"""Methods for interacting with the SolidarityTech Text Blasts endpoint.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from parsons import Table +from parsons.solidarity_tech.base import SolidarityTechBase + +if TYPE_CHECKING: + from datetime import datetime + +logger = logging.getLogger(__name__) + + +class SolidarityTechTextBlasts(SolidarityTechBase): + """Methods for interacting with the SolidarityTech text blasts endpoint.""" + + def get_text_blasts( + self, + limit: int = 20, + offset: int = 0, + since: int | datetime = 0, + ) -> Table: + """ + Retrieve a list of text blasts. + + Args: + limit: + Limits the number of items returned. + Default is 20, maximum is 100. + offset: + Number of items to skip before starting to return the results. + since: + UTC timestamp in seconds since the Unix epoch to filter calls created after this time. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + All the text blast entries. + + Documentation Reference: + ``__ + + """ + res = self._get_resources( + "text_blasts", + limit=limit, + offset=offset, + since=since, + ) + + expected_responses = {200: (True, "text blasts listed")} + self._handle_status_codes(res=res, codes=expected_responses) + + return Table(res.json()) + + def get_text_blast( + self, + resource_id: int, + ) -> dict: + """ + Retrieve a single text blast. + + Args: + resource_id: + ID of the text blast to retrieve. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + A single text blast entry. + + Documentation Reference: + ``__ + + """ + res = self._get_single_resource("text_blasts", resource_id) + + expected_responses = { + 200: (True, "text blast found"), + 404: (False, "text blast not found"), + } + self._handle_status_codes(res=res, codes=expected_responses) + + return res.json() diff --git a/parsons/solidarity_tech/st_text_templates.py b/parsons/solidarity_tech/st_text_templates.py new file mode 100644 index 0000000000..790bd9c331 --- /dev/null +++ b/parsons/solidarity_tech/st_text_templates.py @@ -0,0 +1,245 @@ +"""Methods for interacting with the SolidarityTech Text Templates endpoint.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from parsons import Table +from parsons.solidarity_tech.base import SolidarityTechBase + +if TYPE_CHECKING: + from datetime import datetime + + from parsons.solidarity_tech.datatypes import ScopeType + from parsons.utilities.api_connector import _JsonType + +logger = logging.getLogger(__name__) + + +class SolidarityTechTextTemplates(SolidarityTechBase): + """Methods for interacting with the SolidarityTech text templates endpoint.""" + + def get_text_templates( + self, + limit: int = 20, + offset: int = 0, + since: int | datetime = 0, + event_id: int = 0, + ) -> Table: + """ + Retrieve a list of text templates. + + Args: + limit: + Limits the number of items returned. + Default is 20, maximum is 100. + offset: + Number of items to skip before starting to return the results. + since: + UTC timestamp in seconds since the Unix epoch to filter calls created after this time. + event_id: + Filters rsvps by event_id within the accessible scope. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + All the text templates. + + Documentation Reference: + ``__ + + """ + params: dict[str, _JsonType] = {"event_id": event_id} + + res = self._get_resources( + "text_templates", + limit=limit, + offset=offset, + since=since, + params=params, + ) + + expected_responses = {200: (True, "text templates listed")} + self._handle_status_codes(res=res, codes=expected_responses) + + return Table(res.json()) + + def get_text_template( + self, + resource_id: int, + ) -> dict: + """ + Retrieve a single text template. + + Args: + resource_id: + ID of the text template to retrieve. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + A single text template. + + Documentation Reference: + ``__ + + """ + res = self._get_single_resource("text_templates", resource_id) + + expected_responses = { + 200: (True, "text template found"), + 404: (False, "text template not found"), + } + self._handle_status_codes(res=res, codes=expected_responses) + + return res.json() + + def create_text_template( + self, + scope_id: int, + scope_type: ScopeType, + name: str | None = None, + template: dict[str, str] | None = None, + event_id: int | None = None, + ) -> bool: + """ + Create an text template with the specified details. + + Args: + scope_id: + Identifier for the scope. + scope_type: + Type of the scope. + name: + Name of the entity. + template: + Template content in various languages, + where keys are 2-character language codes + (e.g., "en" for English, "fr" for French). + event_id: + Identifier for the associated event, if applicable. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + payload: dict[str, _JsonType] = { + "scope_id": scope_id, + "scope_type": scope_type.value, + } + self._add_if_field_not_empty(payload, "name", name) + self._add_if_field_not_empty(payload, "template", template) + self._add_if_field_not_empty(payload, "event_id", event_id) + + res = self._post_request( + "text_templates", + payload=payload, + additional_headers={"content-type": "application/json"}, + ) + + expected_responses = { + 201: (True, "text template created"), + 404: (False, "event not found"), + } + return self._handle_status_codes(res=res, codes=expected_responses) + + def update_text_template( + self, + resource_id: int, + name: str | None = None, + scope_id: int | None = None, + scope_type: ScopeType | None = None, + template: dict[str, str] | None = None, + event_id: int | None = None, + ) -> bool: + """ + Update an text template with the specified details. + + Args: + resource_id: + Identifier of the text template to update. + name: + Name of the entity. + scope_id: + Identifier for the scope. + scope_type: + Type of the scope. + template: + Template content in various languages, + where keys are 2-character language codes + (e.g., "en" for English, "fr" for French). + event_id: + Identifier for the associated event, if applicable. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + payload: dict[str, _JsonType] = {} + self._add_if_field_not_empty(payload, "name", name) + self._add_if_field_not_empty(payload, "scope_id", scope_id) + self._add_if_field_not_empty(payload, "scope_type", scope_type) + self._add_if_field_not_empty(payload, "template", template) + self._add_if_field_not_empty(payload, "event_id", event_id) + + res = self._put_request( + "text_templates", + resource_id, + payload=payload, + additional_headers={"content-type": "application/json"}, + ) + + expected_responses = { + 200: (True, "text template updated"), + 404: (False, "text template not found"), + } + return self._handle_status_codes(res=res, codes=expected_responses) + + def delete_text_template( + self, + resource_id: int, + ) -> bool: + """ + Delete an text template with the specified ID. + + Args: + resource_id: + Identifier of the text template to delete. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + res = self._delete_request("text_templates", resource_id) + + expected_responses = {404: (False, "text template not found")} + return self._handle_status_codes(res=res, codes=expected_responses) diff --git a/parsons/solidarity_tech/st_textbanks.py b/parsons/solidarity_tech/st_textbanks.py new file mode 100644 index 0000000000..6d9a84a51c --- /dev/null +++ b/parsons/solidarity_tech/st_textbanks.py @@ -0,0 +1,114 @@ +"""Methods for interacting with the SolidarityTech Textbanks endpoint.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from parsons import Table +from parsons.solidarity_tech.base import SolidarityTechBase + +if TYPE_CHECKING: + from datetime import datetime + + from parsons.utilities.api_connector import _JsonType + +logger = logging.getLogger(__name__) + + +class SolidarityTechTextbanks(SolidarityTechBase): + """Methods for interacting with the SolidarityTech text banks endpoint.""" + + def get_textbanks( + self, + limit: int = 20, + offset: int = 0, + since: int | datetime = 0, + event_id: int = 0, + ids: list[int] | str | None = None, + *, + include_stats: bool = False, + ) -> Table: + """ + Retrieve a list of textbanks. + + Args: + limit: + Limits the number of items returned. + Default is 20, maximum is 100. + offset: + Number of items to skip before starting to return the results. + since: + UTC timestamp in seconds since the Unix epoch to filter calls created after this time. + event_id: + Filters textbanks by event_id within the accessible scope. + ids: + Filters to specific textbank ids. + Accepts a comma-separated string (e.g. "12,34"). + include_stats: + If True, each textbank row also includes aggregate funnel numbers + ``attempts`` (contact attempts), ``contacted`` (distinct people attempted), + and ``replies`` (distinct attempts that got a response). + Default is False. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + All the textbanks. + + Documentation Reference: + ``__ + + """ + if isinstance(ids, list): + ids = ",".join(str(textbank_id) for textbank_id in ids) + + params: dict[str, _JsonType] = {"event_id": event_id, "include_stats": include_stats} + self._add_if_field_not_empty(params, "ids", ids) + + res = self._get_resources( + "textbanks", + limit=limit, + offset=offset, + since=since, + params=params, + ) + + expected_responses = {200: (True, "textbanks listed")} + self._handle_status_codes(res=res, codes=expected_responses) + + return Table(res.json()) + + def get_textbank( + self, + resource_id: int, + ) -> dict: + """ + Retrieve a single textbank. + + Args: + resource_id: + ID of the textbank to retrieve. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + A single textbank. + + Documentation Reference: + ``__ + + """ + res = self._get_single_resource("textbanks", resource_id) + + expected_responses = { + 200: (True, "textbank found"), + 404: (False, "textbank not found"), + } + self._handle_status_codes(res=res, codes=expected_responses) + + return res.json() diff --git a/parsons/solidarity_tech/st_texts.py b/parsons/solidarity_tech/st_texts.py new file mode 100644 index 0000000000..8fc7602626 --- /dev/null +++ b/parsons/solidarity_tech/st_texts.py @@ -0,0 +1,121 @@ +"""Methods for interacting with the SolidarityTech Texts endpoint.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from parsons import Table +from parsons.solidarity_tech.base import SolidarityTechBase + +if TYPE_CHECKING: + from datetime import datetime + + from parsons.utilities.api_connector import _JsonType + +logger = logging.getLogger(__name__) + + +class SolidarityTechTexts(SolidarityTechBase): + """Methods for interacting with the SolidarityTech texts endpoint.""" + + def get_texts( + self, + user_id: int | None = None, + limit: int = 20, + offset: int = 0, + since: int | datetime = 0, + ) -> Table: + """ + Retrieve a list of texts. + + Args: + user_id: + The ID of the user to retrieve texts for. + limit: + Limits the number of items returned. + Default is 20, maximum is 100. + offset: + Number of items to skip before starting to return the results. + since: + UTC timestamp in seconds since the Unix epoch to filter calls created after this time. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + All the texts. + + Documentation Reference: + ``__ + + """ + params: dict[str, _JsonType] = {} + self._add_if_field_not_empty(params, "user_id", user_id) + + res = self._get_resources( + "texts", + limit=limit, + offset=offset, + since=since, + params=params, + additional_headers={"accept": "application/json"}, + ) + + expected_responses = {200: (True, "texts listed")} + self._handle_status_codes(res=res, codes=expected_responses) + + return Table(res.json()) + + def send_text( + self, + user_id: int, + body: str, + media_urls: list[str] | None = None, + *, + attach_contact_card: bool | None = None, + shorten_urls: bool | None = None, + ) -> bool: + """ + Send a text to a specific user. + + Args: + user_id: + The ID of the user to send a text to. + body: + The text body to send. + media_urls: + List of media to include in the text. + attach_contact_card: + Whether to attach the contact card to the text. + shorten_urls: + Whether to shorten URLs in the text. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + params: dict[str, _JsonType] = { + "user_id": user_id, + "body": body, + } + self._add_if_field_not_empty(params, "media_urls", media_urls) + self._add_if_field_not_empty(params, "attach_contact_card", attach_contact_card) + self._add_if_field_not_empty(params, "shorten_urls", shorten_urls) + + res = self._post_request( + "texts", + params=params, + ) + + expected_responses = {201: (True, "text sent")} + return self._handle_status_codes(res=res, codes=expected_responses) diff --git a/parsons/solidarity_tech/st_user_actions.py b/parsons/solidarity_tech/st_user_actions.py new file mode 100644 index 0000000000..2411b93731 --- /dev/null +++ b/parsons/solidarity_tech/st_user_actions.py @@ -0,0 +1,159 @@ +"""Methods for interacting with the SolidarityTech User Actions endpoint.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Literal + +from parsons import Table +from parsons.solidarity_tech.base import SolidarityTechBase + +if TYPE_CHECKING: + from datetime import datetime + + from parsons.utilities.api_connector import _JsonType + +logger = logging.getLogger(__name__) + + +class SolidarityTechUserActions(SolidarityTechBase): + """Methods for interacting with the SolidarityTech user actions endpoint.""" + + def get_user_actions( + self, + user_id: int | None = None, + page_id: int | None = None, + group_by: Literal["referred_by_user"] | None = None, + limit: int = 20, + offset: int = 0, + since: int | datetime = 0, + ) -> Table: + """ + Retrieve user actions (form submissions). + + .. admonition:: Filtering + + Can be filtered by ``user_id``, ``page_id``, or both. + To get custom form responses for event RSVPs, + first get the event's ``event_page_id`` from + ``GET /events/{id}``, then query this endpoint with that page_id. + Match to RSVPs by user_id. + With ``group_by=referred_by_user`` the response becomes a + eferral leaderboard instead of submission rows. + There is one row per referrer + (``{referred_by_user_id, count, user: {id, first_name, last_name}}``), + ordered by submission count descending, honoring the same filters. + + Args: + user_id: + Filter by user ID. + page_id: + Filter by page ID + group_by: + Set to referred_by_user for a referral leaderboard + (see the endpoint description). + Any other value returns 422. + limit: + Limits the number of items returned. + Default is 20, maximum is 100. + offset: + Number of items to skip before starting to return the results. + since: + UTC timestamp in seconds since the Unix epoch to filter calls created after this time. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + All the user actions. + + Documentation Reference: + ``__ + + """ + params: dict[str, _JsonType] = {} + self._add_if_field_not_empty(params, "user_id", user_id) + self._add_if_field_not_empty(params, "page_id", page_id) + self._add_if_field_not_empty(params, "group_by", group_by) + + res = self._get_resources( + "user_actions", + limit=limit, + offset=offset, + since=since, + params=params, + additional_headers={"accept": "application/json"}, + ) + + expected_responses = { + 200: (True, "user actions retrieved"), + 422: (False, "unprocessable entity"), + } + self._handle_status_codes(res=res, codes=expected_responses) + + return Table(res.json()) + + def create_user_action( + self, + page_id: int, + user_id: int | None = None, + created_at: int | None = None, + data: dict[str, str | int | bool | dict[str, str]] | None = None, + ) -> bool: + """ + Create a user action for a user. + + .. note:: + + This endpoint cannot be used for creating actions + related to donation pages or scheduled call pages. + + Args: + page_id: + Identifier for the Page, required for new user actions. + user_id: + Identifier for the User. + created_at: + UTC timestamp in seconds since the Unix epoch for the creation time of the user action + data: + Action data. See documentation. + + Raises: + :class:`ValueError`: If none of ``user_id``, ``phone_number`` or ``email`` is provided. + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + if ( + not page_id + and isinstance(data, dict) + and "phone_number" not in data + and "email" not in data + ): + err_msg = "Either user_id, phone_number, or email must be provided" + raise ValueError(err_msg) + + payload: dict[str, _JsonType] = {"page_id": page_id} + self._add_if_field_not_empty(payload, "user_id", user_id) + self._add_if_field_not_empty(payload, "created_at", created_at) + self._add_if_field_not_empty(payload, "data", data) + + res = self._post_request( + "user_actions", + payload=payload, + additional_headers={"content-type": "application/json"}, + ) + + expected_responses = { + 201: (True, "user action created"), + 422: (False, "unprocessable entity"), + } + return self._handle_status_codes(res=res, codes=expected_responses) diff --git a/parsons/solidarity_tech/st_user_lists.py b/parsons/solidarity_tech/st_user_lists.py new file mode 100644 index 0000000000..cb1fb0d168 --- /dev/null +++ b/parsons/solidarity_tech/st_user_lists.py @@ -0,0 +1,248 @@ +"""Methods for interacting with the SolidarityTech User Lists endpoint.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from parsons import Table +from parsons.solidarity_tech.base import SolidarityTechBase, _JsonType + +if TYPE_CHECKING: + from datetime import datetime + + from parsons.solidarity_tech.datatypes import QueryParams, ScopeType + +logger = logging.getLogger(__name__) + + +class SolidarityTechUserLists(SolidarityTechBase): + """Methods for interacting with the SolidarityTech user lists endpoint.""" + + def get_user_lists( + self, + limit: int = 20, + offset: int = 0, + since: int | datetime = 0, + ) -> Table: + """ + Retrieve a list of user lists. + + Args: + limit: + Limits the number of items returned. + Default is 20, maximum is 100. + offset: + Number of items to skip before starting to return the results. + since: + UTC timestamp in seconds since the Unix epoch to filter calls created after this time. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + All the user lists. + + Documentation Reference: + ``__ + + """ + res = self._get_resources( + "user_lists", + limit=limit, + offset=offset, + since=since, + ) + + expected_responses = {200: (True, "user lists listed")} + self._handle_status_codes(res=res, codes=expected_responses) + + return Table(res.json()) + + def get_user_list( + self, + resource_id: int, + ) -> dict: + """ + Retrieve a single user list. + + Args: + resource_id: + ID of the user list to retrieve. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + A single user list. + + Documentation Reference: + ``__ + + """ + res = self._get_single_resource("user_lists", resource_id) + + expected_responses = { + 200: (True, "user list found"), + 404: (False, "user list not found"), + } + self._handle_status_codes(res=res, codes=expected_responses) + + return res.json() + + def create_user_list( + self, + name: str, + scope_id: int, + scope_type: ScopeType, + event_id: int | None = None, + user_id: int | None = None, + parameters: QueryParams | None = None, + ) -> bool: + """ + Create a user list with the specified details. + + The parameters field must conform to the QueryBuilder format. + For documentation, see ``__. + + Args: + name: + Name of the user list. + scope_id: + Identifier for the scope. + scope_type: + Type of the scope. + event_id: + Identifier for the associated event, if applicable. + user_id: + Identifier for the associated user. + ``parameters``: + Parameters for filtering users in QueryBuilder format. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + payload: dict[str, _JsonType] = { + "name": name, + "scope_id": scope_id, + "scope_type": scope_type.value, + } + self._add_if_field_not_empty(payload, "event_id", event_id) + self._add_if_field_not_empty(payload, "user_id", user_id) + self._add_if_field_not_empty(payload, "parameters", parameters) + + res = self._post_request( + "user_lists", payload=payload, additional_headers={"content-type": "application/json"} + ) + + expected_responses = { + 201: (True, "user list created"), + 422: (False, "unprocessable entity"), + } + return self._handle_status_codes(res=res, codes=expected_responses) + + def update_user_list( + self, + resource_id: int, + name: str | None = None, + scope_id: int | None = None, + scope_type: str | None = None, + parameters: QueryParams | None = None, + event_id: int | None = None, + ) -> bool: + """ + Update a user list with the specified details. + + The parameters field must conform to the QueryBuilder format. + For documentation, see ``__. + + Args: + resource_id: + Identifier of the user list to update. + name: + Name of the user list. + scope_id: + Identifier of the scope. + scope_type: + Type of the scope. + ``parameters``: + Parameters for filtering users in QueryBuilder format. + event_id: + Identifier for the associated event, if applicable. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + payload: dict[str, _JsonType] = {} + self._add_if_field_not_empty(payload, "name", name) + self._add_if_field_not_empty(payload, "scope_id", scope_id) + self._add_if_field_not_empty(payload, "scope_type", scope_type) + self._add_if_field_not_empty(payload, "parameters", parameters) + self._add_if_field_not_empty(payload, "event_id", event_id) + + res = self._put_request( + "user_lists", + resource_id, + payload=payload, + additional_headers={"content-type": "application/json"}, + ) + + expected_responses = { + 200: (True, "user list updated"), + 404: (False, "user list not found"), + } + return self._handle_status_codes(res=res, codes=expected_responses) + + def delete_user_list( + self, + resource_id: str, + ) -> bool: + """ + Delete a user list with the specified ID. + + Args: + resource_id: + Identifier of the user list to delete + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + res = self._delete_request( + "user_lists", + resource_id, + ) + + expected_responses = { + 200: (True, "user list deleted"), + 404: (False, "user list not found"), + } + return self._handle_status_codes(res=res, codes=expected_responses) diff --git a/parsons/solidarity_tech/st_user_notes.py b/parsons/solidarity_tech/st_user_notes.py new file mode 100644 index 0000000000..da20c45815 --- /dev/null +++ b/parsons/solidarity_tech/st_user_notes.py @@ -0,0 +1,118 @@ +"""Methods for interacting with the SolidarityTech User Notes endpoint.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from parsons.solidarity_tech.base import SolidarityTechBase + +if TYPE_CHECKING: + from parsons.solidarity_tech.datatypes import InteractionType + from parsons.utilities.api_connector import _JsonType + +logger = logging.getLogger(__name__) + + +class SolidarityTechUserNotes(SolidarityTechBase): + """Methods for interacting with the SolidarityTech user notes endpoint.""" + + def create_user_note( + self, + user_id: int, + content: str, + agent_id: int | None = None, + created_at: int | None = None, + interaction_method: InteractionType | None = None, + *, + restricted: bool = False, + ) -> bool: + """ + Create a user note with the specified details. + + Args: + user_id: + Identifier for the user the note refers to. + agent_id: + Identifier for the agent to whom the note + is attributed, if applicable. + content: + Content of the user note. + created_at: + Timestamp for when the note was created. + interaction_method: + Interaction type that produced the note. + restricted: + If True, the note is only visible to team members + with the View Restricted Properties permission. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + params: dict[str, _JsonType] = { + "user_id": user_id, + "content": content, + "restricted": restricted, + } + self._add_if_field_not_empty(params, "agent_id", agent_id) + self._add_if_field_not_empty(params, "created_at", created_at) + self._add_if_field_not_empty(params, "interaction_method", interaction_method) + + res = self._post_request("user_notes", params=params) + + expected_responses = { + 201: (True, "user note created successfully"), + 404: (False, "unprocessable entity"), + 422: (False, "unprocessable entity"), + } + return self._handle_status_codes(res=res, codes=expected_responses) + + def delete_user_note( + self, + resource_id: str, + user_id: int, + agent_id: int | None = None, + ) -> bool: + """ + Delete a user note with the specified ID. + + Args: + resource_id: + Identifier of the user note to delete + user_id: + Identifier for the user the note refers to. + agent_id: + Identifier for the agent to whom the note + is attributed, if applicable. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + params: dict[str, _JsonType] = {"user_id": user_id} + self._add_if_field_not_empty(params, "agent_id", agent_id) + + res = self._delete_request("user_notes", resource_id, params=params) + + expected_responses = { + 200: (True, "user note deleted"), + 404: (False, "user note not found"), + } + return self._handle_status_codes(res=res, codes=expected_responses) diff --git a/parsons/solidarity_tech/st_user_relationships.py b/parsons/solidarity_tech/st_user_relationships.py new file mode 100644 index 0000000000..b095574bdb --- /dev/null +++ b/parsons/solidarity_tech/st_user_relationships.py @@ -0,0 +1,140 @@ +"""Methods for interacting with the SolidarityTech User Relationships endpoint.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from parsons import Table +from parsons.solidarity_tech.base import SolidarityTechBase + +if TYPE_CHECKING: + from parsons.solidarity_tech.datatypes import UserRelationshipData + from parsons.utilities.api_connector import _JsonType + +logger = logging.getLogger(__name__) + + +class SolidarityTechUserRelationships(SolidarityTechBase): + """Methods for interacting with the SolidarityTech user relationships endpoint.""" + + def get_user_relationships( + self, + user_id: int, + ) -> Table: + """ + Retrieve a list of user relationships. + + Args: + user_id: + ID of the user to retrieve relationships for. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + All the user relationships. + + Documentation Reference: + ``__ + + """ + params: dict[str, _JsonType] = {"user_id": user_id} + + res = self._get_resources( + "user_relationships", + params=params, + additional_headers={"accept": "application/json"}, + ) + + expected_responses = { + 200: (True, "user relationships listed"), + } + self._handle_status_codes(res=res, codes=expected_responses) + + data: list[UserRelationshipData] = res.json() + return Table(data, name="Solidarity Tech User Relationships") + + def create_user_relationship( + self, + user_id: int, + related_user_id: int, + relationship_type: str, + ) -> bool: + """ + Create a user relationship between users of the specified type. + + Args: + user_id: + Identifier for the user. + related_user_id: + Identifier for the related user. + relationship_type: + Type of the relationship. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + params: dict[str, _JsonType] = { + "user_id": user_id, + "related_user_id": related_user_id, + "relationship_type": relationship_type, + } + + res = self._post_request("user_relationships", params=params) + + expected_responses = { + 201: (True, "user relationship created"), + 422: (False, "invalid request"), + } + return self._handle_status_codes(res=res, codes=expected_responses) + + def delete_user_relationship( + self, + resource_id: int, + user_id: int, + ) -> bool: + """ + Delete a user relationship. + + Args: + resource_id: + Identifier of the user relationship to delete. + user_id: + Identifier for the user. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + params: dict[str, _JsonType] = {"user_id": user_id} + + res = self._delete_request( + "user_relationships", + resource_id, + params=params, + ) + + expected_responses = { + 200: (True, "user relationship deleted"), + 404: (False, "user relationship not found"), + } + return self._handle_status_codes(res=res, codes=expected_responses) diff --git a/parsons/solidarity_tech/st_users.py b/parsons/solidarity_tech/st_users.py new file mode 100644 index 0000000000..4a3b8ef4ed --- /dev/null +++ b/parsons/solidarity_tech/st_users.py @@ -0,0 +1,530 @@ +"""Methods for interacting with the SolidarityTech Users endpoint.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING +from zoneinfo import ZoneInfo + +from parsons import Table +from parsons.solidarity_tech.base import SolidarityTechBase + +if TYPE_CHECKING: + import numbers + from datetime import datetime + + from parsons.solidarity_tech.datatypes import ( + Metadata, + UserData, + UserDeleteMetadata, + UserMergeMetadata, + ) + from parsons.utilities.api_connector import _JsonType + + +logger = logging.getLogger(__name__) + + +class SolidarityTechUsers(SolidarityTechBase): + """Methods for interacting with the SolidarityTech users endpoint.""" + + def get_users( + self, + limit: int = 20, + offset: int = 0, + since: int | datetime = 0, + user_list_ids: str | list[int] | None = None, + phone_number: str | None = None, + email: str | None = None, + ) -> tuple[Table, Metadata]: + """ + Retrieve a list of users. + + Args: + limit: + Limits the number of items returned. + Default is 20, maximum is 100. + offset: + Number of items to skip before starting to return the results. + since: + UTC timestamp in seconds since the Unix epoch to filter calls created after this time. + user_list_ids: + Comma-separated list of user list IDs to apply. + Or a list of user ID integers. + phone_number: + Filter by phone number (any format accepted, will be normalized). + email: + Filter by email address (case-insensitive). + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + All the users. + + Documentation Reference: + ``__ + + """ + if isinstance(user_list_ids, list): + user_list_ids = ",".join(str(userlist_id) for userlist_id in user_list_ids) + + params: dict[str, _JsonType] = {} + self._add_if_field_not_empty(params, "user_list_ids", user_list_ids) + self._add_if_field_not_empty(params, "phone_number", phone_number) + self._add_if_field_not_empty(params, "email", email) + + res = self._get_resources( + "users", + limit=limit, + offset=offset, + since=since, + params=params, + additional_headers={"accept": "application/json"}, + ) + + expected_responses = { + 200: (True, "users listed"), + 422: (False, "invalid user filter"), + } + self._handle_status_codes(res=res, codes=expected_responses) + + data: list[UserData] = res.json()["data"] + meta: Metadata = res.json()["meta"] + + return Table(data, name="Solidarity Tech Users"), meta + + def get_user( + self, + resource_id: int, + ) -> UserData: + """ + Retrieve a single user. + + Args: + resource_id: + ID of the user to retrieve. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + A single user. + + Documentation Reference: + ``__ + + """ + res = self._get_single_resource( + "users", resource_id, additional_headers={"accept": "application/json"} + ) + + expected_responses = { + 200: (True, "user found"), + 404: (False, "user not found"), + } + self._handle_status_codes(res=res, codes=expected_responses) + + return res.json() + + def create_user( + self, + phone_number: str | None = None, + email: str | None = None, + first_name: str | None = None, + last_name: str | None = None, + alternate_name: str | None = None, + preferred_language: str | None = None, + second_language: str | None = None, + chapter_id: int | None = None, + chapter_ids: list[int] | None = None, + referred_by_user_id: int | None = None, + custom_user_properties: dict[str, str | list[str]] | None = None, + add_tags: list[str] | None = None, + remove_tags: list[str] | None = None, + donation_charge: dict[str, numbers.Number | str] | None = None, + address: dict[str, str | float] | None = None, + assessment: str | None = None, + timezone: ZoneInfo | str | None = None, + lookup_key: str | None = None, + *, + append_custom_user_properties: bool = True, + sms_permission: bool | None = None, + call_permission: bool | None = None, + email_permission: bool | None = None, + require_contact_info: bool = True, + phone_number_textable_validation: bool = True, + ) -> bool: + """ + Create or update a user with the specified details. + + Args: + phone_number: + Phone number of the user. + email: + Email address of the user. + first_name: + First name of the user. + last_name: + Last name of the user. + alternate_name: + Alternate name (nickname, community name, or alternate romanization). Searchable. + Blank values are ignored; an existing alternate name cannot be cleared via the API. + preferred_language: + Preferred language of the user. + second_language: + Second language of the user. + chapter_id: + Primary chapter ID. + Required for new users unless ``chapter_ids`` is provided. + chapter_ids: + Array of chapter IDs for multi-chapter membership. + First element becomes primary if ``chapter_id`` is not provided. + Requires multi-chapter feature for more than one chapter. + referred_by_user_id: + Identifier for the user who referred this user. + custom_user_properties: + Custom property values keyed by ``internal_name``. + Use a string for single-value fields + (text, number, radio, dropdown, single checkbox). + Use an array of strings for Multiple Checkboxes fields + (e.g. ``["Option A", "Option B"]``). + Comma-separated strings are also accepted for Multiple Checkboxes + (e.g. ``"Option A, Option B"``). + For Multiple Checkboxes, see ``append_custom_user_properties`` to + control whether values are merged with or replace existing values. + add_tags: + List of tags to add to the user. + remove_tags: + List of tags to remove to the user. + donation_charge: + Optional external donation charge to create. + address: + Optional address to create. + We will attempt to geocode the address if ``latitude`` and ``longitude`` are not provided. + assessment: + Assessment status key to set on the user (maps to classification). + timezone: + IANA timezone identifier (e.g., "America/New_York", "Europe/London"). + lookup_key: + Custom property key (internal_name) to use for user lookup/deduplication. + Value is read from ``custom_user_properties[lookup_key]``. + Allows matching existing users by external IDs stored in custom properties. + append_custom_user_properties: + Controls how Multiple Checkboxes custom properties are written. + Defaults to True (union new values with existing values, the long-standing API behavior). + Set to False to overwrite existing values, mirroring bulk update REPLACE mode. + Has no effect on non-array field types. + sms_permission: + SMS permission status. + call_permission: + Call permission status. + email_permission: + Email permission status. + require_contact_info: + Whether to require phone_number or email for user creation. + Defaults to True. + phone_number_textable_validation: + Whether to validate that phone number is textable. + Defaults to True. + + Raises: + :class:`ValueError`: If neither ``phone_number`` nor ``email`` is provided. + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + if not phone_number and not email: + err_msg = "Either phone_number or email must be provided" + raise ValueError(err_msg) + + if isinstance(timezone, ZoneInfo): + timezone = str(timezone.key) + + payload: dict[str, _JsonType] = { + "append_custom_user_properties": append_custom_user_properties, + "require_contact_info": require_contact_info, + "phone_number_textable_validation": phone_number_textable_validation, + } + self._add_if_field_not_empty(payload, "phone_number", phone_number) + self._add_if_field_not_empty(payload, "email", email) + self._add_if_field_not_empty(payload, "first_name", first_name) + self._add_if_field_not_empty(payload, "last_name", last_name) + self._add_if_field_not_empty(payload, "alternate_name", alternate_name) + self._add_if_field_not_empty(payload, "preferred_language", preferred_language) + self._add_if_field_not_empty(payload, "second_language", second_language) + self._add_if_field_not_empty(payload, "chapter_id", chapter_id) + self._add_if_field_not_empty(payload, "chapter_ids", chapter_ids) + self._add_if_field_not_empty(payload, "referred_by_user_id", referred_by_user_id) + self._add_if_field_not_empty(payload, "custom_user_properties", custom_user_properties) + self._add_if_field_not_empty(payload, "add_tags", add_tags) + self._add_if_field_not_empty(payload, "remove_tags", remove_tags) + self._add_if_field_not_empty(payload, "donation_charge", donation_charge) + self._add_if_field_not_empty(payload, "address", address) + self._add_if_field_not_empty(payload, "assessment", assessment) + self._add_if_field_not_empty(payload, "sms_permission", sms_permission) + self._add_if_field_not_empty(payload, "call_permission", call_permission) + self._add_if_field_not_empty(payload, "email_permission", email_permission) + self._add_if_field_not_empty(payload, "timezone", timezone) + self._add_if_field_not_empty(payload, "lookup_key", lookup_key) + + res = self._post_request( + "users", payload=payload, additional_headers={"content-type": "application/json"} + ) + + expected_responses = { + 200: (True, "user updated via lookup_key - existing user found"), + 201: (True, "user created with lookup_key - new user"), + 403: (False, "multi-chapter feature not enabled"), + 422: (False, "provided lookup_key without value in custom_user_properties"), + } + return self._handle_status_codes(res=res, codes=expected_responses) + + def update_user( + self, + resource_id: int, + phone_number: str | None = None, + email: str | None = None, + first_name: str | None = None, + last_name: str | None = None, + alternate_name: str | None = None, + preferred_language: str | None = None, + chapter_id: int | None = None, + chapter_ids: list[int] | None = None, + add_chapter_ids: list[int] | None = None, + remove_chapter_ids: list[int] | None = None, + second_language: str | None = None, + referred_by_user_id: int | None = None, + custom_user_properties: dict[str, str | list[str]] | None = None, + address: dict[str, str | float] | None = None, + assessment: str | None = None, + timezone: ZoneInfo | str | None = None, + donation_charge: dict[str, numbers.Number | str] | None = None, + *, + clear_phone_number: bool | None = None, + set_exclusive_chapter: bool | None = None, + append_custom_user_properties: bool = True, + sms_permission: bool | None = None, + call_permission: bool | None = None, + email_permission: bool | None = None, + ) -> bool: + """ + Update a user with the specified details. + + Args: + resource_id: + Identifier of the user to update. + phone_number: + Phone number of the user. + email: + Email of the user. + first_name: + First name of the user. + last_name: + Last name of the user. + alternate_name: + Alternate name (nickname, community name, or alternate romanization). Searchable. + Blank values are ignored; an existing alternate name cannot be cleared via the API. + preferred_language: + Preferred language of the user. + chapter_id: + Primary chapter ID (backwards compatible). + chapter_ids: + Full array of chapter IDs (replaces all). Requires multi-chapter feature. + add_chapter_ids: + Array of chapter IDs to add. Requires multi-chapter feature. + remove_chapter_ids: + Array of chapter IDs to remove. Requires multi-chapter feature. + second_language: + Second language of the user. + referred_by_user_id: + Identifier of the user who referred this user. + Custom property values keyed by ``internal_name``. + Use a string for single-value fields + (text, number, radio, dropdown, single checkbox). + Use an array of strings for Multiple Checkboxes fields + (e.g. ``["Option A", "Option B"]``). + Comma-separated strings are also accepted for Multiple Checkboxes + (e.g. ``"Option A, Option B"``). + For Multiple Checkboxes, see ``append_custom_user_properties`` to + control whether values are merged with or replace existing values. + address: + Optional address to update. + We will attempt to geocode the address if + latitude and longitude are not provided. + assessment: + Assessment status key to set on the user (maps to classification). + timezone: + IANA timezone identifier (e.g., "America/New_York", "Europe/London"). + donation_charge: + Optional external donation charge to create. + clear_phone_number: + If True, clears the user's primary phone number + (and removes it from ``other_phone_numbers``). + Blank ``phone_number`` values are always ignored, so this + explicit flag is the only way to clear a phone number via the API. + Cannot be combined with a non-blank ``phone_number`` + in the same request (returns 422). + set_exclusive_chapter: + When True with ``chapter_id``, + sets that chapter as the only chapter + (removes all other chapter memberships). + append_custom_user_properties: + Controls how Multiple Checkboxes custom properties are written. + Defaults to True (union new values with existing values, the long-standing API behavior). + Set to False to overwrite existing values, mirroring bulk update REPLACE mode. + Has no effect on non-array field types. + sms_permission: + If True, the user has permission to receive SMS messages. + call_permission: + If True, the user has permission to receive call messages. + email_permission: + If True, the user has permission to receive email messages. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Boolean representing success of the operation. + True if the operation was successful, False otherwise. + + Documentation Reference: + ``__ + + """ + if isinstance(timezone, ZoneInfo): + timezone = str(timezone.key) + + payload: dict[str, _JsonType] = { + "append_custom_user_properties": append_custom_user_properties, + } + self._add_if_field_not_empty(payload, "phone_number", phone_number) + self._add_if_field_not_empty(payload, "clear_phone_number", clear_phone_number) + self._add_if_field_not_empty(payload, "email", email) + self._add_if_field_not_empty(payload, "first_name", first_name) + self._add_if_field_not_empty(payload, "last_name", last_name) + self._add_if_field_not_empty(payload, "alternate_name", alternate_name) + self._add_if_field_not_empty(payload, "preferred_language", preferred_language) + self._add_if_field_not_empty(payload, "chapter_id", chapter_id) + self._add_if_field_not_empty(payload, "chapter_ids", chapter_ids) + self._add_if_field_not_empty(payload, "add_chapter_ids", add_chapter_ids) + self._add_if_field_not_empty(payload, "remove_chapter_ids", remove_chapter_ids) + self._add_if_field_not_empty(payload, "set_exclusive_chapter", set_exclusive_chapter) + self._add_if_field_not_empty(payload, "second_language", second_language) + self._add_if_field_not_empty(payload, "referred_by_user_id", referred_by_user_id) + self._add_if_field_not_empty(payload, "custom_user_properties", custom_user_properties) + self._add_if_field_not_empty(payload, "address", address) + self._add_if_field_not_empty(payload, "assessment", assessment) + self._add_if_field_not_empty(payload, "sms_permission", sms_permission) + self._add_if_field_not_empty(payload, "call_permission", call_permission) + self._add_if_field_not_empty(payload, "email_permission", email_permission) + self._add_if_field_not_empty(payload, "timezone", timezone) + self._add_if_field_not_empty(payload, "donation_charge", donation_charge) + + res = self._put_request( + "users", + resource_id, + payload=payload, + additional_headers={"content-type": "application/json"}, + ) + + expected_responses = { + 200: (True, "user updated"), + 422: (False, "cannot set and clear the phone number in the same request"), + } + return self._handle_status_codes(res=res, codes=expected_responses) + + def merge_duplicate_users( + self, + primary_user_id: int, + user_ids: list[int] | str, + ) -> UserMergeMetadata: + """ + Merge two or more users. + + Args: + primary_user_id: + ID of the user to keep (the survivor). + All data from duplicates will be merged into this user. + user_ids: + IDs of the duplicate users to merge into the primary user. + These users will be deactivated after merge. + Also accepts a comma-separated string. + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Data about the merge attempt. + + Documentation Reference: + ``__ + + """ + if isinstance(user_ids, list): + user_ids = ",".join(str(user_id) for user_id in user_ids) + + payload: dict[str, _JsonType] = { + "primary_user_id": primary_user_id, + "user_ids": user_ids, + } + + res = self._post_request( + "users/merge", + payload=payload, + additional_headers={"accept": "application/json", "content-type": "application/json"}, + ) + + expected_responses = { + 200: (True, "users merged successfully"), + 404: (False, "user not found"), + 422: (False, "invalid parameters"), + } + self._handle_status_codes(res=res, codes=expected_responses) + + return res.json() + + def delete_user( + self, + resource_id: str, + ) -> UserDeleteMetadata: + """ + Delete a user with the specified ID. + + Args: + resource_id: + Identifier of the user to delete + + Raises: + :class:`STFailedResponseError`: If the operation fails with a known error code. + :class:`STUnexpectedResponseError`: If the operation fails with an unexpected status code. + + Returns: + Data about the delete operation. + + Documentation Reference: + ``__ + + """ + res = self._delete_request( + "users", resource_id, additional_headers={"accept": "application/json"} + ) + + expected_responses = { + 200: (True, "user deleted"), + 404: (False, "user not found"), + } + self._handle_status_codes(res=res, codes=expected_responses) + + return res.json() diff --git a/parsons/utilities/api_connector.py b/parsons/utilities/api_connector.py index b2ba4fcdc2..bd90e0362e 100644 --- a/parsons/utilities/api_connector.py +++ b/parsons/utilities/api_connector.py @@ -2,9 +2,12 @@ import logging import urllib.parse +import warnings from typing import TYPE_CHECKING, Any, overload import requests +import requests_ratelimiter +from pyrate_limiter import Duration, Limiter, Rate from requests.exceptions import HTTPError from simplejson.errors import JSONDecodeError @@ -49,6 +52,9 @@ def __init__( auth: _AuthType | None = None, pagination_key: str | None = None, data_key: str | None = None, + *, + ratelimit: Limiter | Rate | int | None = None, + session: requests.Session | None = None, ) -> None: """ Initialize the APIConnector. @@ -68,6 +74,19 @@ def __init__( The name of the key in the response json where the data is contained. Required if the data is nested in the response json. + ratelimit: + The rate limit to use in the connectors. + Can be a :class:`~pyrate_limiter.limiter.Limiter` instance, + a :class:`~pyrate_limiter.limiter.Rate` instance, or + an integer representing the number of requests per second, + or None to disable rate limiting. + session: + A preconfigured :class`~requests.Session` for advanced users. + If using `session`, `ratelimit` must be None. + + Raises: + ValueError: + If both `session` and `ratelimit` are provided. """ # Add a trailing slash if it's missing @@ -75,17 +94,92 @@ def __init__( uri = uri + "/" self.uri = uri - self.headers = headers - self.auth = auth self.pagination_key = pagination_key self.data_key = data_key + if session and ratelimit: + err_msg = "session and ratelimit cannot both be provided" + raise ValueError(err_msg) + + if session: + self.session = session + elif ratelimit: + if isinstance(ratelimit, Rate): + ratelimit = Limiter(ratelimit) + elif isinstance(ratelimit, int): + ratelimit = Limiter(requests_ratelimiter.Rate(ratelimit, Duration.SECOND)) + self.session = requests_ratelimiter.LimiterSession(limiter=ratelimit) + else: + self.session = requests.Session() + + if auth: + self.session.auth = auth + + if headers: + self.session.headers = headers # type: ignore[ty:invalid-assignment] # pyright: ignore [reportAttributeAccessIssue] + + @property + def auth(self) -> _AuthType: + """Deprecated access to session authentication. Use session.auth instead.""" + warnings.warn( + "The auth property is deprecated, use session.auth instead.", + DeprecationWarning, + stacklevel=1, + ) + return self.session.auth + + @auth.setter + def auth(self, inp: _AuthType) -> None: + warnings.warn( + "The auth property is deprecated, use session.auth instead.", + DeprecationWarning, + stacklevel=1, + ) + self.session.auth = inp + + @auth.deleter + def auth(self) -> None: + warnings.warn( + "The auth property is deprecated, use session.auth instead.", + DeprecationWarning, + stacklevel=1, + ) + del self.session.auth + + @property + def headers(self) -> _HeadersType: + """Deprecated access to session headers. Use session.headers instead.""" + warnings.warn( + "The headers property is deprecated, use session.headers instead.", + DeprecationWarning, + stacklevel=1, + ) + return self.session.headers + + @headers.setter + def headers(self, inp: _HeadersType) -> None: + warnings.warn( + "The headers property is deprecated, use session.headers instead.", + DeprecationWarning, + stacklevel=1, + ) + self.session.headers = inp # type: ignore[ty:invalid-assignment] # pyright: ignore [reportAttributeAccessIssue] + + @headers.deleter + def headers(self) -> None: + warnings.warn( + "The headers property is deprecated, use session.headers instead.", + DeprecationWarning, + stacklevel=1, + ) + del self.session.headers + def request( self, url: str, req_type: Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], *, - json: Any | None = None, + json: _JsonType | None = None, data: _DataType | None = None, params: _ParamsType | None = None, raise_on_error: bool = True, @@ -93,7 +187,7 @@ def request( **kwargs, ) -> requests.Response: """ - Base request using requests libary. + Make a request using requests libary. Args: url: @@ -122,7 +216,7 @@ def request( ``additional_headers``, the value from ``additional_headers`` takes precedence. This does not mutate ``self.headers``. `**kwargs`: - Additional keyword arguments to pass to :func:`requests.request`. + Additional keyword arguments to add to the :class:`~requests.Request`. """ full_url = urllib.parse.urljoin(self.uri, url) @@ -132,16 +226,19 @@ def request( if additional_headers: complete_headers.update(additional_headers) - resp = requests.request( + req = requests.Request( req_type, full_url, headers=complete_headers, auth=self.auth, json=json, data=data, - params=params, **kwargs, ) + if params: + req.params = params + + resp = self.session.send(req.prepare()) if raise_on_error: self.validate_response(resp) @@ -151,22 +248,22 @@ def request( @overload def get_request( self, - url: ..., + url: str, *, - params: ... = ..., + params: _ParamsType | None = None, return_format: Literal["json"] = "json", - raise_on_error: ... = ..., + raise_on_error: bool = True, **kwargs, ) -> _JsonType: ... @overload def get_request( self, - url: ..., + url: str, *, - params: ... = ..., + params: _ParamsType | None = None, return_format: Literal["content"], - raise_on_error: ... = ..., + raise_on_error: bool = True, **kwargs, ) -> bytes: ... @@ -191,14 +288,14 @@ def get_request( however in some cases, if you are looping through data, you might want to ignore individual failures. `**kwargs`: - Additional keyword arguments to pass to :func:`requests.request`. + Additional keyword arguments to pass to the :class:`~requests.Request`. Returns: The :meth:`requests.Response.json` from the response if `return_format` is ``json``, or :attr:`requests.Response.content` from the response if `return_format` is ``content``. Raises: - RuntimeError: If `return_format` is not ``json`` or ``content``. + RuntimeError: If return_format is not ``json`` or ``content``. """ r = self.request(url, "GET", params=params, raise_on_error=raise_on_error, **kwargs) @@ -210,7 +307,8 @@ def get_request( if return_format == "content": return r.content - raise RuntimeError(f"{return_format} is not a valid format, change to json or content") + err_msg = f"{return_format} is not a valid format, change to json or content" + raise RuntimeError(err_msg) def post_request( self, @@ -240,7 +338,7 @@ def post_request( however in some cases, if you are looping through data, you might want to ignore individual failures. `**kwargs`: - Additional keyword arguments to pass to :func:`requests.request`. + Additional keyword arguments to pass to :class:`~requests.Request`. Returns: If successful, json date from :meth:`requests.Response.json` @@ -270,6 +368,8 @@ def post_request( return r.status_code + return None + def delete_request( self, url: str, @@ -294,7 +394,7 @@ def delete_request( however in some cases, if you are looping through data, you might want to ignore individual failures. `**kwargs`: - Additional keyword arguments to pass to :func:`requests.request`. + Additional keyword arguments to pass to :class:`~requests.Request`. Returns: If successful, json date from :meth:`requests.Response.json` @@ -316,6 +416,8 @@ def delete_request( return r.status_code + return None + def put_request( self, url: str, @@ -344,7 +446,7 @@ def put_request( however in some cases, if you are looping through data, you might want to ignore individual failures. `**kwargs`: - Additional keyword arguments to pass to :func:`requests.request`. + Additional keyword arguments to pass to :class:`~requests.Request`. Returns: If successful, json date from :meth:`requests.Response.json` @@ -368,6 +470,8 @@ def put_request( return r.status_code + return None + def patch_request( self, url: str, @@ -396,7 +500,7 @@ def patch_request( however in some cases, if you are looping through data, you might want to ignore individual failures. `**kwargs`: - Additional keyword arguments to pass to :func:`requests.request`. + Additional keyword arguments to pass to :class:`~requests.Request`. Returns: If successful, json date from :meth:`requests.Response.json` @@ -426,6 +530,8 @@ def patch_request( return r.status_code + return None + def validate_response(self, resp: requests.Response) -> None: """ Validate that the response is not an error code. @@ -459,7 +565,7 @@ def data_parse(self, resp: list) -> list: ... def data_parse(self, resp: dict[str, Any] | list) -> dict[str, Any] | list: """ - Determines if the response json has nested data. + Determine if the response json has nested data. If it is nested, it just returns the data. This is useful in dealing with requests that might return multiple records, @@ -484,7 +590,7 @@ def data_parse(self, resp: dict[str, Any] | list) -> dict[str, Any] | list: def next_page_check_url(self, resp: dict[str, Any]) -> bool: """ - Check to determine if there is a next page. + Determine if there is a next page. This requires that the response json contains a pagination key that is empty if there is not a next page. @@ -496,7 +602,7 @@ def next_page_check_url(self, resp: dict[str, Any]) -> bool: return False def json_check(self, resp: requests.Response) -> bool: - """Check to see if a response has a json included in it.""" + """Check if a response has a json included in it.""" try: resp.json() return True @@ -505,5 +611,5 @@ def json_check(self, resp: requests.Response) -> bool: return False def convert_to_table(self, data: list | Any) -> Table: - """Internal method to create a Parsons table from a data element.""" + """Create a Parsons table from a data element.""" return Table(data) if isinstance(data, list) else Table([data]) diff --git a/pyproject.toml b/pyproject.toml index c03abb3dd9..2d4568a630 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "petl >= 1.7.17", "python-dateutil >= 2.0", "requests >= 2.0", + "requests-ratelimiter >= 0.10", "requests-oauthlib >= 1.0", "simplejson >= 3.18", ] diff --git a/test/test_solidarity-tech/__init__.py b/test/test_solidarity-tech/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/test_solidarity-tech/cassettes/test_st_activities/TestGetActivities.test_get_activities_live.yaml b/test/test_solidarity-tech/cassettes/test_st_activities/TestGetActivities.test_get_activities_live.yaml new file mode 100644 index 0000000000..2b9d512677 --- /dev/null +++ b/test/test_solidarity-tech/cassettes/test_st_activities/TestGetActivities.test_get_activities_live.yaml @@ -0,0 +1,130 @@ +interactions: +- request: + body: null + headers: + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + User-Agent: + - python-requests/2.34.2 + accept: + - application/json + authorization: + - Bearer 9y8eg37zy2viz3rxkjn9qbi829iz88n29cv0q8nfyequbk9j0n9vpo3iyv0hr7v3wxtmmdx58jj00l6b01nnuc1rzvancxaonsiuqm1ohqjh8xx15c6cjr3gxdokzsb5 + method: GET + uri: https://api.solidarity.tech/v1/activities?_limit=20&_since=0 + response: + body: + string: "{\"data\":[{\"id\":111256062,\"user_id\":1186152,\"name\":\"UserPropertyEdit\",\"actionable_id\":76758307,\"actionable_type\":\"UserPropertyEdit\",\"action\":{\"id\":76758307,\"user_id\":1186152,\"agent_user_id\":1140156,\"field_type\":\"assessment\",\"old_value\":\"developing_leader\",\"new_value\":\"\",\"data_import_id\":null,\"created_at\":\"2026-07-26T07:48:13.774-10:00\"},\"created_at\":\"2026-07-26T07:48:14.775-10:00\"},{\"id\":111256059,\"user_id\":1186152,\"name\":\"UserPropertyEdit\",\"actionable_id\":76758306,\"actionable_type\":\"UserPropertyEdit\",\"action\":{\"id\":76758306,\"user_id\":1186152,\"agent_user_id\":1140156,\"field_type\":\"assessment\",\"old_value\":\"supporter\",\"new_value\":\"developing_leader\",\"data_import_id\":null,\"created_at\":\"2026-07-26T07:48:06.268-10:00\"},\"created_at\":\"2026-07-26T07:48:07.273-10:00\"},{\"id\":45417529,\"user_id\":7328098,\"name\":\"Visit\",\"actionable_id\":13301324,\"actionable_type\":\"Visit\",\"action\":{\"id\":13301324,\"ip\":\"70.67.48.195\",\"user_agent\":\"Mozilla/5.0 + (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) + Version/18.6 Safari/605.1.15\",\"referrer\":null,\"landing_page\":null,\"action_page_id\":null,\"user_id\":7328098,\"referring_domain\":null,\"search_keyword\":null,\"browser\":\"Safari\",\"os\":\"Mac\",\"device_type\":\"Desktop\",\"country\":\"Canada\",\"region\":\"British + Columbia\",\"city\":\"Victoria\",\"postal_code\":\"V9A\",\"latitude\":\"48.4496\",\"longitude\":\"-123.4193\",\"utm_source\":null,\"utm_medium\":null,\"utm_term\":null,\"utm_content\":null,\"utm_campaign\":null,\"created_at\":\"2026-04-28T14:37:44.181-10:00\"},\"created_at\":\"2026-04-28T14:37:44.343-10:00\"},{\"id\":44586488,\"user_id\":7328098,\"name\":\"Visit\",\"actionable_id\":13002715,\"actionable_type\":\"Visit\",\"action\":{\"id\":13002715,\"ip\":\"70.67.48.195\",\"user_agent\":\"Mozilla/5.0 + (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) + Version/18.6 Safari/605.1.15\",\"referrer\":null,\"landing_page\":null,\"action_page_id\":null,\"user_id\":7328098,\"referring_domain\":null,\"search_keyword\":null,\"browser\":\"Safari\",\"os\":\"Mac\",\"device_type\":\"Desktop\",\"country\":\"Canada\",\"region\":\"British + Columbia\",\"city\":\"Victoria\",\"postal_code\":\"V9A\",\"latitude\":\"48.4496\",\"longitude\":\"-123.4193\",\"utm_source\":null,\"utm_medium\":null,\"utm_term\":null,\"utm_content\":null,\"utm_campaign\":null,\"created_at\":\"2026-04-23T09:30:10.368-10:00\"},\"created_at\":\"2026-04-23T09:30:10.509-10:00\"},{\"id\":44583015,\"user_id\":7328098,\"name\":\"UserPropertyEdit\",\"actionable_id\":22504955,\"actionable_type\":\"UserPropertyEdit\",\"action\":{\"id\":22504955,\"user_id\":7328098,\"agent_user_id\":7328098,\"field_type\":\"tags\",\"old_value\":\"[]\",\"new_value\":\"[\\\"English\\\"]\",\"data_import_id\":null,\"created_at\":\"2026-04-23T08:53:48.842-10:00\"},\"created_at\":\"2026-04-23T08:53:49.845-10:00\"},{\"id\":44582433,\"user_id\":7328098,\"name\":\"AgentUserCall\",\"actionable_id\":1032061,\"actionable_type\":\"AgentUserCall\",\"action\":{\"id\":1032061,\"user_id\":7328098,\"chapter_id\":293,\"direction\":\"out\",\"from_number\":\"12892105005\",\"to_number\":\"16048360839\",\"phonebank_id\":null,\"agent_user_id\":7328098,\"notes\":\"Just + a test of how the call tool works.\",\"picked_up\":true,\"duration\":33,\"left_voicemail\":false,\"twilio_call_sid\":\"CA630a35575ec4505588e2978cc6f7fe9a\",\"created_at\":\"2026-04-23T08:47:18.430-10:00\",\"ended_at\":\"2026-04-23T08:47:51.195-10:00\",\"transcription\":null},\"created_at\":\"2026-04-23T08:47:18.430-10:00\"},{\"id\":44582249,\"user_id\":7328098,\"name\":\"UserPropertyEdit\",\"actionable_id\":22504903,\"actionable_type\":\"UserPropertyEdit\",\"action\":{\"id\":22504903,\"user_id\":7328098,\"agent_user_id\":7328098,\"field_type\":\"added_from_dashboard\",\"old_value\":null,\"new_value\":null,\"data_import_id\":null,\"created_at\":\"2026-04-23T08:46:02.358-10:00\"},\"created_at\":\"2026-04-23T08:46:03.359-10:00\"},{\"id\":44582248,\"user_id\":7328098,\"name\":\"UserPropertyEdit\",\"actionable_id\":22504902,\"actionable_type\":\"UserPropertyEdit\",\"action\":{\"id\":22504902,\"user_id\":7328098,\"agent_user_id\":7328098,\"field_type\":\"assessment\",\"old_value\":null,\"new_value\":\"potential_core\",\"data_import_id\":null,\"created_at\":\"2026-04-23T08:46:02.329-10:00\"},\"created_at\":\"2026-04-23T08:46:03.332-10:00\"},{\"id\":43999513,\"user_id\":7328097,\"name\":\"Visit\",\"actionable_id\":12651218,\"actionable_type\":\"Visit\",\"action\":{\"id\":12651218,\"ip\":\"38.174.26.54\",\"user_agent\":\"Mozilla/5.0 + (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) + Chrome/146.0.0.0 Safari/537.36\",\"referrer\":null,\"landing_page\":null,\"action_page_id\":null,\"user_id\":7328097,\"referring_domain\":null,\"search_keyword\":null,\"browser\":\"Chrome\",\"os\":\"Mac\",\"device_type\":\"Desktop\",\"country\":\"United + States\",\"region\":null,\"city\":null,\"postal_code\":null,\"latitude\":\"37.751\",\"longitude\":\"-97.822\",\"utm_source\":null,\"utm_medium\":null,\"utm_term\":null,\"utm_content\":null,\"utm_campaign\":null,\"created_at\":\"2026-04-16T13:02:11.378-10:00\"},\"created_at\":\"2026-04-16T13:02:11.483-10:00\"},{\"id\":43837511,\"user_id\":7328098,\"name\":\"open_email\",\"actionable_id\":25520458,\"actionable_type\":\"Ahoy::Message\",\"action\":{\"id\":25520458,\"to\":\"developer@myclimateplan.com\",\"user_id\":7328098,\"subject\":\"\U0001F31F + You're In! Welcome to the People United Team at Solidarity Tech\",\"sent_at\":\"2026-04-15T12:34:54.231-10:00\",\"opened_at\":\"2026-04-15T12:35:59.407-10:00\",\"clicked_at\":null,\"did_bounce\":null,\"email_message_id\":null,\"user_marked_as_spam\":null,\"event_rsvp_id\":null,\"action_page_id\":null,\"user_action_id\":null},\"created_at\":\"2026-04-15T12:35:59.413-10:00\"},{\"id\":43817325,\"user_id\":7328098,\"name\":\"open_email\",\"actionable_id\":25501286,\"actionable_type\":\"Ahoy::Message\",\"action\":{\"id\":25501286,\"to\":\"developer@myclimateplan.com\",\"user_id\":7328098,\"subject\":\"Action + Required: You're invited to Solidarity Tech\",\"sent_at\":\"2026-04-15T11:02:58.184-10:00\",\"opened_at\":\"2026-04-15T11:05:16.830-10:00\",\"clicked_at\":null,\"did_bounce\":null,\"email_message_id\":null,\"user_marked_as_spam\":null,\"event_rsvp_id\":null,\"action_page_id\":null,\"user_action_id\":null},\"created_at\":\"2026-04-15T11:05:16.837-10:00\"},{\"id\":43816997,\"user_id\":7328098,\"name\":\"UserPropertyEdit\",\"actionable_id\":22289553,\"actionable_type\":\"UserPropertyEdit\",\"action\":{\"id\":22289553,\"user_id\":7328098,\"agent_user_id\":1140156,\"field_type\":\"added_from_dashboard\",\"old_value\":null,\"new_value\":null,\"data_import_id\":null,\"created_at\":\"2026-04-15T11:02:42.250-10:00\"},\"created_at\":\"2026-04-15T11:02:43.251-10:00\"},{\"id\":43816968,\"user_id\":7328097,\"name\":\"UserPropertyEdit\",\"actionable_id\":22289551,\"actionable_type\":\"UserPropertyEdit\",\"action\":{\"id\":22289551,\"user_id\":7328097,\"agent_user_id\":1140156,\"field_type\":\"added_from_dashboard\",\"old_value\":null,\"new_value\":null,\"data_import_id\":null,\"created_at\":\"2026-04-15T11:02:33.182-10:00\"},\"created_at\":\"2026-04-15T11:02:34.185-10:00\"},{\"id\":23759782,\"user_id\":4879998,\"name\":\"UserAction\",\"actionable_id\":683747,\"actionable_type\":\"UserAction\",\"action\":{\"id\":683747,\"data\":{\"date\":\"02/05/2026\",\"email\":\"clong@oti.nyc.gov\",\"zip_code\":\"11201\",\"full_name\":\"Chris + test\",\"languages\":[\"en-US\",\"en\"],\"ref_source\":\"web_form\",\"example-rsvp\":[\"dq3ljmsmh\"],\"phone_number\":\"19178730802\",\"action_page_id\":5315,\"sms_permission\":\"false\",\"email_permission\":\"true\",\"pretty_phone_number\":\"(917) + 873-0802\",\"preferred_language_assumed\":\"en\"},\"action_page_id\":5315,\"user_id\":4879998,\"created_at\":\"2026-02-05T13:22:55.316-10:00\",\"updated_at\":\"2026-02-05T13:22:55.316-10:00\",\"source\":\"website\",\"referred_by_user_id\":null,\"ip\":\"174.204.131.7\",\"visit_id\":9286378,\"user_message_link_click_id\":null},\"created_at\":\"2026-02-05T13:22:55.316-10:00\"},{\"id\":23759781,\"user_id\":4879998,\"name\":\"create_account\",\"actionable_id\":null,\"actionable_type\":null,\"action\":null,\"created_at\":\"2026-02-05T13:22:55.271-10:00\"},{\"id\":20645989,\"user_id\":4454716,\"name\":\"UserPropertyEdit\",\"actionable_id\":8271230,\"actionable_type\":\"UserPropertyEdit\",\"action\":{\"id\":8271230,\"user_id\":4454716,\"agent_user_id\":2635252,\"field_type\":\"full_address\",\"old_value\":\"1212 + Broadway Plaza Walnut Creek, CA 94596 United States\",\"new_value\":\"1212 + Broadway Oakland, CA 94612 United States\",\"data_import_id\":null,\"created_at\":\"2026-01-26T08:45:22.418-10:00\"},\"created_at\":\"2026-01-26T08:45:23.419-10:00\"},{\"id\":20645970,\"user_id\":4454716,\"name\":\"UserPropertyEdit\",\"actionable_id\":8271223,\"actionable_type\":\"UserPropertyEdit\",\"action\":{\"id\":8271223,\"user_id\":4454716,\"agent_user_id\":2635252,\"field_type\":\"full_address\",\"old_value\":\"1212 + Broadway Oakland, CA 94612 United States\",\"new_value\":\"1212 Broadway + Plaza Walnut Creek, CA 94596 United States\",\"data_import_id\":null,\"created_at\":\"2026-01-26T08:45:07.116-10:00\"},\"created_at\":\"2026-01-26T08:45:08.117-10:00\"},{\"id\":20644342,\"user_id\":4454716,\"name\":\"UserPropertyEdit\",\"actionable_id\":8270231,\"actionable_type\":\"UserPropertyEdit\",\"action\":{\"id\":8270231,\"user_id\":4454716,\"agent_user_id\":2635252,\"field_type\":\"full_address\",\"old_value\":\"\",\"new_value\":\"1212 + Broadway Oakland, CA 94612 United States\",\"data_import_id\":null,\"created_at\":\"2026-01-26T08:28:51.715-10:00\"},\"created_at\":\"2026-01-26T08:28:52.716-10:00\"},{\"id\":20644189,\"user_id\":4454716,\"name\":\"UserPropertyEdit\",\"actionable_id\":8270164,\"actionable_type\":\"UserPropertyEdit\",\"action\":{\"id\":8270164,\"user_id\":4454716,\"agent_user_id\":2635252,\"field_type\":\"added_from_dashboard\",\"old_value\":null,\"new_value\":null,\"data_import_id\":null,\"created_at\":\"2026-01-26T08:27:00.964-10:00\"},\"created_at\":\"2026-01-26T08:27:01.966-10:00\"},{\"id\":16640151,\"user_id\":1934287,\"name\":\"UserNote\",\"actionable_id\":252418,\"actionable_type\":\"UserNote\",\"action\":{\"id\":252418,\"user_id\":1934287,\"agent_user_id\":2635250,\"content\":\"Test\",\"last_edited_by_agent_user_id\":2635250,\"restricted\":false,\"interaction_method\":null,\"created_at\":\"2026-01-12T13:41:02.958-10:00\",\"updated_at\":\"2026-01-12T13:41:02.958-10:00\"},\"created_at\":\"2026-01-12T13:41:02.958-10:00\"}],\"meta\":{\"total_count\":null,\"limit\":20,\"offset\":0,\"cursor\":null,\"next_cursor\":16640151}}" + headers: + Alt-Svc: + - h3=":443"; ma=86400 + Cache-Control: + - no-cache + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Security-Policy-Report-Only: + - 'default-src ''self''; script-src ''self'' ''unsafe-inline'' ''unsafe-eval'' + https://cdnjs.cloudflare.com https://www.googletagmanager.com https://www.google-analytics.com + https://maps.googleapis.com https://*.posthog.com https://*.i.posthog.com + https://b.solidarity.tech https://*.intercom.io https://*.intercomcdn.com + https://js.stripe.com https://maxcdn.bootstrapcdn.com https://unpkg.com https://oss.maxcdn.com + https://www.gstatic.com https://www.google.com https://cdn.tiny.cloud https://s3.amazonaws.com/solidarity.tech/ + https://js-agent.newrelic.com https://editor.unlayer.com https://code.jquery.com + https://api.mapbox.com https://platform.twitter.com https://cdn.jsdelivr.net + https://plausible.io https://googleads.g.doubleclick.net https://www.googleadservices.com + https://*.sentry-cdn.com https://connect.facebook.net https://sc.lfeeder.com + https://cdn.solidarity.tech; style-src ''self'' ''unsafe-inline'' https://fonts.googleapis.com + https://s3.amazonaws.com/solidarity.tech/ https://irdu.s3.amazonaws.com https://cdn.tiny.cloud + https://cdnjs.cloudflare.com https://maxcdn.bootstrapcdn.com https://unpkg.com + https://*.intercomcdn.com https://api.mapbox.com https://cdn.jsdelivr.net + https://cdn.solidarity.tech; img-src ''self'' data: blob: https:; font-src + ''self'' data: https://fonts.gstatic.com https://s3.amazonaws.com/solidarity.tech/ + https://irdu.s3.amazonaws.com https://cdnjs.cloudflare.com https://*.intercomcdn.com + https://maxcdn.bootstrapcdn.com https://cdn.solidarity.tech; connect-src ''self'' + https://api.stripe.com https://maps.googleapis.com https://*.posthog.com https://*.i.posthog.com + https://b.solidarity.tech https://*.intercom.io wss://*.intercom.io wss://*.intercom-messenger.com + https://*.intercom-messenger.com https://*.intercomcdn.com https://*.intercomusercontent.com + https://*.google-analytics.com https://www.googletagmanager.com wss://*.twilio.com + https://api.twilio.com https://mms.twiliocdn.com https://s3-external-1.amazonaws.com/media.twiliocdn.com/ + https://sdk.twilio.com https://eventgw.us1.twilio.com https://*.airbrake.io + https://api.mapbox.com https://events.mapbox.com https://api.maptiler.com + https://*.ingest.us.sentry.io https://*.nr-data.net https://tiles.openfreemap.org + https://*.basemaps.cartocdn.com https://basemaps.cartocdn.com https://tile.openstreetmap.org + https://server.arcgisonline.com https://demotiles.maplibre.org https://plausible.io + https://googleads.g.doubleclick.net https://www.googleadservices.com https://www.google.com + https://google.com https://pagead2.googlesyndication.com https://s3.amazonaws.com/solidarity.tech/ + https://fonts.googleapis.com https://irdu.s3.amazonaws.com https://fonts.gstatic.com + https://*.sentry-cdn.com https://cdn.jsdelivr.net https://unpkg.com https://sc.lfeeder.com + https://cdn.solidarity.tech; frame-src ''self'' https://js.stripe.com https://hooks.stripe.com + https://intercom-sheets.com https://www.recaptcha.net https://www.google.com + https://www.gstatic.com https://editor.unlayer.com https://platform.twitter.com + https://www.googletagmanager.com https://www.youtube.com https://www.youtube-nocookie.com + https://player.vimeo.com https://*.solidarity.tech; media-src ''self'' data: + https://api.twilio.com https://sdk.twilio.com https://mms.twiliocdn.com https://s3-external-1.amazonaws.com/media.twiliocdn.com/ + https://s3.amazonaws.com/solidarity.tech/ https://*.intercomcdn.com; worker-src + ''self'' blob:; frame-ancestors ''self''; object-src ''none''; base-uri ''self''; + report-uri /csp-violation-reports' + Content-Type: + - application/json; charset=utf-8 + Date: + - Sat, 05 Sep 2026 02:44:28 GMT + Referrer-Policy: + - strict-origin-when-cross-origin + Set-Cookie: + - ahoy_visitor=3326e364-91be-4e82-b9db-513c046f6fea; path=/; expires=Tue, 05 + Sep 2028 02:44:28 GMT; samesite=lax; secure + - ahoy_visit=cd192b14-fcd9-4f7d-a8a0-3a67aa740cfa; path=/; expires=Sat, 05 Sep + 2026 06:44:28 GMT; samesite=lax; secure + Strict-Transport-Security: + - max-age=63072000; includeSubDomains + Transfer-Encoding: + - chunked + Vary: + - Accept-Encoding + Via: + - 1.1 a2c162ce08367d0379d62c009d560b4a.cloudfront.net (CloudFront) + X-Amz-Cf-Id: + - ETwH2vpO_rMO7l10PN9RPppwdwcW4jmn7Sw2L2XXQScCZHnFrg8YCQ== + X-Amz-Cf-Pop: + - BOS50-P2 + X-Cache: + - Miss from cloudfront + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Frame-Options: + - SAMEORIGIN + X-Permitted-Cross-Domain-Policies: + - none + X-Request-Id: + - 0a68c10f-d97f-441b-aa88-c1b7b03d9a92 + X-Xss-Protection: + - '0' + status: + code: 200 + message: OK +version: 1 diff --git a/test/test_solidarity-tech/cassettes/test_st_agent_assignments/TestCreateAgentAssignment.test_create_agent_assignment_live_failure.yaml b/test/test_solidarity-tech/cassettes/test_st_agent_assignments/TestCreateAgentAssignment.test_create_agent_assignment_live_failure.yaml new file mode 100644 index 0000000000..5ff59d047c --- /dev/null +++ b/test/test_solidarity-tech/cassettes/test_st_agent_assignments/TestCreateAgentAssignment.test_create_agent_assignment_live_failure.yaml @@ -0,0 +1,110 @@ +interactions: +- request: + body: '{"user_id": 1191722, "agent_user_id": 99}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + Content-Length: + - '41' + User-Agent: + - python-requests/2.34.2 + authorization: + - Bearer 9y8eg37zy2viz3rxkjn9qbi829iz88n29cv0q8nfyequbk9j0n9vpo3iyv0hr7v3wxtmmdx58jj00l6b01nnuc1rzvancxaonsiuqm1ohqjh8xx15c6cjr3gxdokzsb5 + content-type: + - application/json + method: POST + uri: https://api.solidarity.tech/v1/agent_assignments + response: + body: + string: '{"error":"User or Agent User not found in the organization"}' + headers: + Alt-Svc: + - h3=":443"; ma=86400 + Cache-Control: + - no-cache + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Security-Policy-Report-Only: + - 'default-src ''self''; script-src ''self'' ''unsafe-inline'' ''unsafe-eval'' + https://cdnjs.cloudflare.com https://www.googletagmanager.com https://www.google-analytics.com + https://maps.googleapis.com https://*.posthog.com https://*.i.posthog.com + https://b.solidarity.tech https://*.intercom.io https://*.intercomcdn.com + https://js.stripe.com https://maxcdn.bootstrapcdn.com https://unpkg.com https://oss.maxcdn.com + https://www.gstatic.com https://www.google.com https://cdn.tiny.cloud https://s3.amazonaws.com/solidarity.tech/ + https://js-agent.newrelic.com https://editor.unlayer.com https://code.jquery.com + https://api.mapbox.com https://platform.twitter.com https://cdn.jsdelivr.net + https://plausible.io https://googleads.g.doubleclick.net https://www.googleadservices.com + https://*.sentry-cdn.com https://connect.facebook.net https://sc.lfeeder.com + https://cdn.solidarity.tech; style-src ''self'' ''unsafe-inline'' https://fonts.googleapis.com + https://s3.amazonaws.com/solidarity.tech/ https://irdu.s3.amazonaws.com https://cdn.tiny.cloud + https://cdnjs.cloudflare.com https://maxcdn.bootstrapcdn.com https://unpkg.com + https://*.intercomcdn.com https://api.mapbox.com https://cdn.jsdelivr.net + https://cdn.solidarity.tech; img-src ''self'' data: blob: https:; font-src + ''self'' data: https://fonts.gstatic.com https://s3.amazonaws.com/solidarity.tech/ + https://irdu.s3.amazonaws.com https://cdnjs.cloudflare.com https://*.intercomcdn.com + https://maxcdn.bootstrapcdn.com https://cdn.solidarity.tech; connect-src ''self'' + https://api.stripe.com https://maps.googleapis.com https://*.posthog.com https://*.i.posthog.com + https://b.solidarity.tech https://*.intercom.io wss://*.intercom.io wss://*.intercom-messenger.com + https://*.intercom-messenger.com https://*.intercomcdn.com https://*.intercomusercontent.com + https://*.google-analytics.com https://www.googletagmanager.com wss://*.twilio.com + https://api.twilio.com https://mms.twiliocdn.com https://s3-external-1.amazonaws.com/media.twiliocdn.com/ + https://sdk.twilio.com https://eventgw.us1.twilio.com https://*.airbrake.io + https://api.mapbox.com https://events.mapbox.com https://api.maptiler.com + https://*.ingest.us.sentry.io https://*.nr-data.net https://tiles.openfreemap.org + https://*.basemaps.cartocdn.com https://basemaps.cartocdn.com https://tile.openstreetmap.org + https://server.arcgisonline.com https://demotiles.maplibre.org https://plausible.io + https://googleads.g.doubleclick.net https://www.googleadservices.com https://www.google.com + https://google.com https://pagead2.googlesyndication.com https://s3.amazonaws.com/solidarity.tech/ + https://fonts.googleapis.com https://irdu.s3.amazonaws.com https://fonts.gstatic.com + https://*.sentry-cdn.com https://cdn.jsdelivr.net https://unpkg.com https://sc.lfeeder.com + https://cdn.solidarity.tech; frame-src ''self'' https://js.stripe.com https://hooks.stripe.com + https://intercom-sheets.com https://www.recaptcha.net https://www.google.com + https://www.gstatic.com https://editor.unlayer.com https://platform.twitter.com + https://www.googletagmanager.com https://www.youtube.com https://www.youtube-nocookie.com + https://player.vimeo.com https://*.solidarity.tech; media-src ''self'' data: + https://api.twilio.com https://sdk.twilio.com https://mms.twiliocdn.com https://s3-external-1.amazonaws.com/media.twiliocdn.com/ + https://s3.amazonaws.com/solidarity.tech/ https://*.intercomcdn.com; worker-src + ''self'' blob:; frame-ancestors ''self''; object-src ''none''; base-uri ''self''; + report-uri /csp-violation-reports' + Content-Type: + - application/json; charset=utf-8 + Date: + - Sat, 05 Sep 2026 14:15:32 GMT + Referrer-Policy: + - strict-origin-when-cross-origin + Strict-Transport-Security: + - max-age=63072000; includeSubDomains + Transfer-Encoding: + - chunked + Vary: + - Accept-Encoding + Via: + - 1.1 46da0d9077a488b72feda37621fa49fa.cloudfront.net (CloudFront) + X-Amz-Cf-Id: + - SOlsV8KFQJ8whOiKsBIZ6Ljun6P8S4pr1vprGQxIDFAZexga8JyqcA== + X-Amz-Cf-Pop: + - BOS50-P3 + X-Cache: + - Error from cloudfront + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Frame-Options: + - SAMEORIGIN + X-Permitted-Cross-Domain-Policies: + - none + X-Request-Id: + - 7c10a627-3263-4cd5-af4a-10b57780be88 + X-Xss-Protection: + - '0' + status: + code: 404 + message: Not Found +version: 1 diff --git a/test/test_solidarity-tech/cassettes/test_st_agent_assignments/TestCreateAgentAssignment.test_create_agent_assignment_live_success.yaml b/test/test_solidarity-tech/cassettes/test_st_agent_assignments/TestCreateAgentAssignment.test_create_agent_assignment_live_success.yaml new file mode 100644 index 0000000000..aea78bc6e6 --- /dev/null +++ b/test/test_solidarity-tech/cassettes/test_st_agent_assignments/TestCreateAgentAssignment.test_create_agent_assignment_live_success.yaml @@ -0,0 +1,110 @@ +interactions: +- request: + body: '{"user_id": 1191722, "agent_user_id": 2192958}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + Content-Length: + - '46' + User-Agent: + - python-requests/2.34.2 + authorization: + - Bearer 9y8eg37zy2viz3rxkjn9qbi829iz88n29cv0q8nfyequbk9j0n9vpo3iyv0hr7v3wxtmmdx58jj00l6b01nnuc1rzvancxaonsiuqm1ohqjh8xx15c6cjr3gxdokzsb5 + content-type: + - application/json + method: POST + uri: https://api.solidarity.tech/v1/agent_assignments + response: + body: + string: '{"data":{"id":728496,"agent_user_id":2192958,"user_id":1191722,"created_at":"2026-09-05T10:15:32.623-04:00","is_active":true}}' + headers: + Alt-Svc: + - h3=":443"; ma=86400 + Cache-Control: + - no-cache + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Security-Policy-Report-Only: + - 'default-src ''self''; script-src ''self'' ''unsafe-inline'' ''unsafe-eval'' + https://cdnjs.cloudflare.com https://www.googletagmanager.com https://www.google-analytics.com + https://maps.googleapis.com https://*.posthog.com https://*.i.posthog.com + https://b.solidarity.tech https://*.intercom.io https://*.intercomcdn.com + https://js.stripe.com https://maxcdn.bootstrapcdn.com https://unpkg.com https://oss.maxcdn.com + https://www.gstatic.com https://www.google.com https://cdn.tiny.cloud https://s3.amazonaws.com/solidarity.tech/ + https://js-agent.newrelic.com https://editor.unlayer.com https://code.jquery.com + https://api.mapbox.com https://platform.twitter.com https://cdn.jsdelivr.net + https://plausible.io https://googleads.g.doubleclick.net https://www.googleadservices.com + https://*.sentry-cdn.com https://connect.facebook.net https://sc.lfeeder.com + https://cdn.solidarity.tech; style-src ''self'' ''unsafe-inline'' https://fonts.googleapis.com + https://s3.amazonaws.com/solidarity.tech/ https://irdu.s3.amazonaws.com https://cdn.tiny.cloud + https://cdnjs.cloudflare.com https://maxcdn.bootstrapcdn.com https://unpkg.com + https://*.intercomcdn.com https://api.mapbox.com https://cdn.jsdelivr.net + https://cdn.solidarity.tech; img-src ''self'' data: blob: https:; font-src + ''self'' data: https://fonts.gstatic.com https://s3.amazonaws.com/solidarity.tech/ + https://irdu.s3.amazonaws.com https://cdnjs.cloudflare.com https://*.intercomcdn.com + https://maxcdn.bootstrapcdn.com https://cdn.solidarity.tech; connect-src ''self'' + https://api.stripe.com https://maps.googleapis.com https://*.posthog.com https://*.i.posthog.com + https://b.solidarity.tech https://*.intercom.io wss://*.intercom.io wss://*.intercom-messenger.com + https://*.intercom-messenger.com https://*.intercomcdn.com https://*.intercomusercontent.com + https://*.google-analytics.com https://www.googletagmanager.com wss://*.twilio.com + https://api.twilio.com https://mms.twiliocdn.com https://s3-external-1.amazonaws.com/media.twiliocdn.com/ + https://sdk.twilio.com https://eventgw.us1.twilio.com https://*.airbrake.io + https://api.mapbox.com https://events.mapbox.com https://api.maptiler.com + https://*.ingest.us.sentry.io https://*.nr-data.net https://tiles.openfreemap.org + https://*.basemaps.cartocdn.com https://basemaps.cartocdn.com https://tile.openstreetmap.org + https://server.arcgisonline.com https://demotiles.maplibre.org https://plausible.io + https://googleads.g.doubleclick.net https://www.googleadservices.com https://www.google.com + https://google.com https://pagead2.googlesyndication.com https://s3.amazonaws.com/solidarity.tech/ + https://fonts.googleapis.com https://irdu.s3.amazonaws.com https://fonts.gstatic.com + https://*.sentry-cdn.com https://cdn.jsdelivr.net https://unpkg.com https://sc.lfeeder.com + https://cdn.solidarity.tech; frame-src ''self'' https://js.stripe.com https://hooks.stripe.com + https://intercom-sheets.com https://www.recaptcha.net https://www.google.com + https://www.gstatic.com https://editor.unlayer.com https://platform.twitter.com + https://www.googletagmanager.com https://www.youtube.com https://www.youtube-nocookie.com + https://player.vimeo.com https://*.solidarity.tech; media-src ''self'' data: + https://api.twilio.com https://sdk.twilio.com https://mms.twiliocdn.com https://s3-external-1.amazonaws.com/media.twiliocdn.com/ + https://s3.amazonaws.com/solidarity.tech/ https://*.intercomcdn.com; worker-src + ''self'' blob:; frame-ancestors ''self''; object-src ''none''; base-uri ''self''; + report-uri /csp-violation-reports' + Content-Type: + - application/json; charset=utf-8 + Date: + - Sat, 05 Sep 2026 14:15:32 GMT + Referrer-Policy: + - strict-origin-when-cross-origin + Strict-Transport-Security: + - max-age=63072000; includeSubDomains + Transfer-Encoding: + - chunked + Vary: + - Accept-Encoding + Via: + - 1.1 7604250055c7fad5e0110aa89f9dbaf2.cloudfront.net (CloudFront) + X-Amz-Cf-Id: + - Vi6kTdkC3jxrPjA5US92QFVUx8LFO7sofFLj0PCn1DFQYdTwgMMTIQ== + X-Amz-Cf-Pop: + - BOS50-P6 + X-Cache: + - Miss from cloudfront + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Frame-Options: + - SAMEORIGIN + X-Permitted-Cross-Domain-Policies: + - none + X-Request-Id: + - 0b148fb5-72f3-4e18-afc5-0035041222eb + X-Xss-Protection: + - '0' + status: + code: 201 + message: Created +version: 1 diff --git a/test/test_solidarity-tech/cassettes/test_st_agent_assignments/TestGetAgentAssignment.test_get_agent_assignment_live.yaml b/test/test_solidarity-tech/cassettes/test_st_agent_assignments/TestGetAgentAssignment.test_get_agent_assignment_live.yaml new file mode 100644 index 0000000000..b051b93584 --- /dev/null +++ b/test/test_solidarity-tech/cassettes/test_st_agent_assignments/TestGetAgentAssignment.test_get_agent_assignment_live.yaml @@ -0,0 +1,111 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + User-Agent: + - python-requests/2.34.2 + authorization: + - Bearer 9y8eg37zy2viz3rxkjn9qbi829iz88n29cv0q8nfyequbk9j0n9vpo3iyv0hr7v3wxtmmdx58jj00l6b01nnuc1rzvancxaonsiuqm1ohqjh8xx15c6cjr3gxdokzsb5 + method: GET + uri: https://api.solidarity.tech/v1/agent_assignments/478171 + response: + body: + string: '{"data":{"id":478171,"agent_user_id":2189275,"user_id":2192958,"created_at":"2025-08-11T12:18:47.747-04:00","is_active":true},"meta":{"total_count":1,"limit":1,"offset":0}}' + headers: + Alt-Svc: + - h3=":443"; ma=86400 + Cache-Control: + - no-cache + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Security-Policy-Report-Only: + - 'default-src ''self''; script-src ''self'' ''unsafe-inline'' ''unsafe-eval'' + https://cdnjs.cloudflare.com https://www.googletagmanager.com https://www.google-analytics.com + https://maps.googleapis.com https://*.posthog.com https://*.i.posthog.com + https://b.solidarity.tech https://*.intercom.io https://*.intercomcdn.com + https://js.stripe.com https://maxcdn.bootstrapcdn.com https://unpkg.com https://oss.maxcdn.com + https://www.gstatic.com https://www.google.com https://cdn.tiny.cloud https://s3.amazonaws.com/solidarity.tech/ + https://js-agent.newrelic.com https://editor.unlayer.com https://code.jquery.com + https://api.mapbox.com https://platform.twitter.com https://cdn.jsdelivr.net + https://plausible.io https://googleads.g.doubleclick.net https://www.googleadservices.com + https://*.sentry-cdn.com https://connect.facebook.net https://sc.lfeeder.com + https://cdn.solidarity.tech; style-src ''self'' ''unsafe-inline'' https://fonts.googleapis.com + https://s3.amazonaws.com/solidarity.tech/ https://irdu.s3.amazonaws.com https://cdn.tiny.cloud + https://cdnjs.cloudflare.com https://maxcdn.bootstrapcdn.com https://unpkg.com + https://*.intercomcdn.com https://api.mapbox.com https://cdn.jsdelivr.net + https://cdn.solidarity.tech; img-src ''self'' data: blob: https:; font-src + ''self'' data: https://fonts.gstatic.com https://s3.amazonaws.com/solidarity.tech/ + https://irdu.s3.amazonaws.com https://cdnjs.cloudflare.com https://*.intercomcdn.com + https://maxcdn.bootstrapcdn.com https://cdn.solidarity.tech; connect-src ''self'' + https://api.stripe.com https://maps.googleapis.com https://*.posthog.com https://*.i.posthog.com + https://b.solidarity.tech https://*.intercom.io wss://*.intercom.io wss://*.intercom-messenger.com + https://*.intercom-messenger.com https://*.intercomcdn.com https://*.intercomusercontent.com + https://*.google-analytics.com https://www.googletagmanager.com wss://*.twilio.com + https://api.twilio.com https://mms.twiliocdn.com https://s3-external-1.amazonaws.com/media.twiliocdn.com/ + https://sdk.twilio.com https://eventgw.us1.twilio.com https://*.airbrake.io + https://api.mapbox.com https://events.mapbox.com https://api.maptiler.com + https://*.ingest.us.sentry.io https://*.nr-data.net https://tiles.openfreemap.org + https://*.basemaps.cartocdn.com https://basemaps.cartocdn.com https://tile.openstreetmap.org + https://server.arcgisonline.com https://demotiles.maplibre.org https://plausible.io + https://googleads.g.doubleclick.net https://www.googleadservices.com https://www.google.com + https://google.com https://pagead2.googlesyndication.com https://s3.amazonaws.com/solidarity.tech/ + https://fonts.googleapis.com https://irdu.s3.amazonaws.com https://fonts.gstatic.com + https://*.sentry-cdn.com https://cdn.jsdelivr.net https://unpkg.com https://sc.lfeeder.com + https://cdn.solidarity.tech; frame-src ''self'' https://js.stripe.com https://hooks.stripe.com + https://intercom-sheets.com https://www.recaptcha.net https://www.google.com + https://www.gstatic.com https://editor.unlayer.com https://platform.twitter.com + https://www.googletagmanager.com https://www.youtube.com https://www.youtube-nocookie.com + https://player.vimeo.com https://*.solidarity.tech; media-src ''self'' data: + https://api.twilio.com https://sdk.twilio.com https://mms.twiliocdn.com https://s3-external-1.amazonaws.com/media.twiliocdn.com/ + https://s3.amazonaws.com/solidarity.tech/ https://*.intercomcdn.com; worker-src + ''self'' blob:; frame-ancestors ''self''; object-src ''none''; base-uri ''self''; + report-uri /csp-violation-reports' + Content-Type: + - application/json; charset=utf-8 + Date: + - Sat, 05 Sep 2026 13:30:17 GMT + Referrer-Policy: + - strict-origin-when-cross-origin + Set-Cookie: + - ahoy_visitor=2cab9436-f74c-4ba1-96a3-6e51fd00909c; path=/; expires=Tue, 05 + Sep 2028 13:30:17 GMT; samesite=lax; secure + - ahoy_visit=8de5318e-358c-4b18-b656-57d10eb6ba9c; path=/; expires=Sat, 05 Sep + 2026 17:30:17 GMT; samesite=lax; secure + Strict-Transport-Security: + - max-age=63072000; includeSubDomains + Transfer-Encoding: + - chunked + Vary: + - Accept-Encoding + Via: + - 1.1 747aa0aabf4e8bc9807d0bc2f5a6d7c0.cloudfront.net (CloudFront) + X-Amz-Cf-Id: + - 88pSfo5rFan4w5KxnZEIz30myHxR94JTYv4G-1P-wLKrrU05XvdYgA== + X-Amz-Cf-Pop: + - BOS50-P2 + X-Cache: + - Miss from cloudfront + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Frame-Options: + - SAMEORIGIN + X-Permitted-Cross-Domain-Policies: + - none + X-Request-Id: + - 14a98846-5b3d-4e60-9296-a38d57ee0cf7 + X-Xss-Protection: + - '0' + status: + code: 200 + message: OK +version: 1 diff --git a/test/test_solidarity-tech/cassettes/test_st_agent_assignments/TestGetAgentAssignments.test_get_agent_assignments_live.yaml b/test/test_solidarity-tech/cassettes/test_st_agent_assignments/TestGetAgentAssignments.test_get_agent_assignments_live.yaml new file mode 100644 index 0000000000..b62593fe21 --- /dev/null +++ b/test/test_solidarity-tech/cassettes/test_st_agent_assignments/TestGetAgentAssignments.test_get_agent_assignments_live.yaml @@ -0,0 +1,109 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + User-Agent: + - python-requests/2.34.2 + authorization: + - Bearer 9y8eg37zy2viz3rxkjn9qbi829iz88n29cv0q8nfyequbk9j0n9vpo3iyv0hr7v3wxtmmdx58jj00l6b01nnuc1rzvancxaonsiuqm1ohqjh8xx15c6cjr3gxdokzsb5 + method: GET + uri: https://api.solidarity.tech/v1/agent_assignments?_limit=20&_offset=0&_since=0 + response: + body: + string: '{"data":[{"id":478171,"agent_user_id":2189275,"user_id":2192958,"created_at":"2025-08-12T02:18:47.747+10:00","is_active":true},{"id":478172,"agent_user_id":2189275,"user_id":2192959,"created_at":"2025-08-12T02:19:11.202+10:00","is_active":true},{"id":610872,"agent_user_id":7328098,"user_id":7328098,"created_at":"2026-04-24T04:46:02.345+10:00","is_active":true}],"meta":{"total_count":3,"limit":20,"offset":0}}' + headers: + Cache-Control: + - no-cache + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Security-Policy-Report-Only: + - 'default-src ''self''; script-src ''self'' ''unsafe-inline'' ''unsafe-eval'' + https://cdnjs.cloudflare.com https://www.googletagmanager.com https://www.google-analytics.com + https://maps.googleapis.com https://*.posthog.com https://*.i.posthog.com + https://b.solidarity.tech https://*.intercom.io https://*.intercomcdn.com + https://js.stripe.com https://maxcdn.bootstrapcdn.com https://unpkg.com https://oss.maxcdn.com + https://www.gstatic.com https://www.google.com https://cdn.tiny.cloud https://s3.amazonaws.com/solidarity.tech/ + https://js-agent.newrelic.com https://editor.unlayer.com https://code.jquery.com + https://api.mapbox.com https://platform.twitter.com https://cdn.jsdelivr.net + https://plausible.io https://googleads.g.doubleclick.net https://www.googleadservices.com + https://*.sentry-cdn.com https://connect.facebook.net https://sc.lfeeder.com + https://cdn.solidarity.tech; style-src ''self'' ''unsafe-inline'' https://fonts.googleapis.com + https://s3.amazonaws.com/solidarity.tech/ https://irdu.s3.amazonaws.com https://cdn.tiny.cloud + https://cdnjs.cloudflare.com https://maxcdn.bootstrapcdn.com https://unpkg.com + https://*.intercomcdn.com https://api.mapbox.com https://cdn.jsdelivr.net + https://cdn.solidarity.tech; img-src ''self'' data: blob: https:; font-src + ''self'' data: https://fonts.gstatic.com https://s3.amazonaws.com/solidarity.tech/ + https://irdu.s3.amazonaws.com https://cdnjs.cloudflare.com https://*.intercomcdn.com + https://maxcdn.bootstrapcdn.com https://cdn.solidarity.tech; connect-src ''self'' + https://api.stripe.com https://maps.googleapis.com https://*.posthog.com https://*.i.posthog.com + https://b.solidarity.tech https://*.intercom.io wss://*.intercom.io wss://*.intercom-messenger.com + https://*.intercom-messenger.com https://*.intercomcdn.com https://*.intercomusercontent.com + https://*.google-analytics.com https://www.googletagmanager.com wss://*.twilio.com + https://api.twilio.com https://mms.twiliocdn.com https://s3-external-1.amazonaws.com/media.twiliocdn.com/ + https://sdk.twilio.com https://eventgw.us1.twilio.com https://*.airbrake.io + https://api.mapbox.com https://events.mapbox.com https://api.maptiler.com + https://*.ingest.us.sentry.io https://*.nr-data.net https://tiles.openfreemap.org + https://*.basemaps.cartocdn.com https://basemaps.cartocdn.com https://tile.openstreetmap.org + https://server.arcgisonline.com https://demotiles.maplibre.org https://plausible.io + https://googleads.g.doubleclick.net https://www.googleadservices.com https://www.google.com + https://google.com https://pagead2.googlesyndication.com https://s3.amazonaws.com/solidarity.tech/ + https://fonts.googleapis.com https://irdu.s3.amazonaws.com https://fonts.gstatic.com + https://*.sentry-cdn.com https://cdn.jsdelivr.net https://unpkg.com https://sc.lfeeder.com + https://cdn.solidarity.tech; frame-src ''self'' https://js.stripe.com https://hooks.stripe.com + https://intercom-sheets.com https://www.recaptcha.net https://www.google.com + https://www.gstatic.com https://editor.unlayer.com https://platform.twitter.com + https://www.googletagmanager.com https://www.youtube.com https://www.youtube-nocookie.com + https://player.vimeo.com https://*.solidarity.tech; media-src ''self'' data: + https://api.twilio.com https://sdk.twilio.com https://mms.twiliocdn.com https://s3-external-1.amazonaws.com/media.twiliocdn.com/ + https://s3.amazonaws.com/solidarity.tech/ https://*.intercomcdn.com; worker-src + ''self'' blob:; frame-ancestors ''self''; object-src ''none''; base-uri ''self''; + report-uri /csp-violation-reports' + Content-Type: + - application/json; charset=utf-8 + Date: + - Sat, 05 Sep 2026 13:30:18 GMT + Referrer-Policy: + - strict-origin-when-cross-origin + Set-Cookie: + - ahoy_visitor=dae8e66f-c098-4058-86d6-317e79b09803; path=/; expires=Tue, 05 + Sep 2028 13:30:17 GMT; samesite=lax; secure + - ahoy_visit=f6241c0e-287f-4918-a772-6a06ba0cccb0; path=/; expires=Sat, 05 Sep + 2026 17:30:17 GMT; samesite=lax; secure + Strict-Transport-Security: + - max-age=63072000; includeSubDomains + Transfer-Encoding: + - chunked + Vary: + - Accept-Encoding + Via: + - 1.1 d0e1eddb352a668abc455b46657c9df8.cloudfront.net (CloudFront) + X-Amz-Cf-Id: + - gJBf_iI3CmhdxH4o0kVuWgP4I7p2ES8zNjfUeiqtQWaLc4pTBSbVaQ== + X-Amz-Cf-Pop: + - BOS50-P4 + X-Cache: + - Miss from cloudfront + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Frame-Options: + - SAMEORIGIN + X-Permitted-Cross-Domain-Policies: + - none + X-Request-Id: + - 04f920a4-39a7-48a3-ad07-079958493a18 + X-Xss-Protection: + - '0' + status: + code: 200 + message: OK +version: 1 diff --git a/test/test_solidarity-tech/cassettes/test_st_agent_assignments/TestUpdateAgentAssignment.test_update_agent_assignment_live_failure.yaml b/test/test_solidarity-tech/cassettes/test_st_agent_assignments/TestUpdateAgentAssignment.test_update_agent_assignment_live_failure.yaml new file mode 100644 index 0000000000..9b770c42ad --- /dev/null +++ b/test/test_solidarity-tech/cassettes/test_st_agent_assignments/TestUpdateAgentAssignment.test_update_agent_assignment_live_failure.yaml @@ -0,0 +1,108 @@ +interactions: +- request: + body: '{"user_id": 928642, "agent_user_id": 99}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + Content-Length: + - '40' + User-Agent: + - python-requests/2.34.2 + authorization: + - Bearer 9y8eg37zy2viz3rxkjn9qbi829iz88n29cv0q8nfyequbk9j0n9vpo3iyv0hr7v3wxtmmdx58jj00l6b01nnuc1rzvancxaonsiuqm1ohqjh8xx15c6cjr3gxdokzsb5 + content-type: + - application/json + method: PUT + uri: https://api.solidarity.tech/v1/agent_assignments/295862 + response: + body: + string: '{"error":"Agent assignment not found"}' + headers: + Cache-Control: + - no-cache + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Security-Policy-Report-Only: + - 'default-src ''self''; script-src ''self'' ''unsafe-inline'' ''unsafe-eval'' + https://cdnjs.cloudflare.com https://www.googletagmanager.com https://www.google-analytics.com + https://maps.googleapis.com https://*.posthog.com https://*.i.posthog.com + https://b.solidarity.tech https://*.intercom.io https://*.intercomcdn.com + https://js.stripe.com https://maxcdn.bootstrapcdn.com https://unpkg.com https://oss.maxcdn.com + https://www.gstatic.com https://www.google.com https://cdn.tiny.cloud https://s3.amazonaws.com/solidarity.tech/ + https://js-agent.newrelic.com https://editor.unlayer.com https://code.jquery.com + https://api.mapbox.com https://platform.twitter.com https://cdn.jsdelivr.net + https://plausible.io https://googleads.g.doubleclick.net https://www.googleadservices.com + https://*.sentry-cdn.com https://connect.facebook.net https://sc.lfeeder.com + https://cdn.solidarity.tech; style-src ''self'' ''unsafe-inline'' https://fonts.googleapis.com + https://s3.amazonaws.com/solidarity.tech/ https://irdu.s3.amazonaws.com https://cdn.tiny.cloud + https://cdnjs.cloudflare.com https://maxcdn.bootstrapcdn.com https://unpkg.com + https://*.intercomcdn.com https://api.mapbox.com https://cdn.jsdelivr.net + https://cdn.solidarity.tech; img-src ''self'' data: blob: https:; font-src + ''self'' data: https://fonts.gstatic.com https://s3.amazonaws.com/solidarity.tech/ + https://irdu.s3.amazonaws.com https://cdnjs.cloudflare.com https://*.intercomcdn.com + https://maxcdn.bootstrapcdn.com https://cdn.solidarity.tech; connect-src ''self'' + https://api.stripe.com https://maps.googleapis.com https://*.posthog.com https://*.i.posthog.com + https://b.solidarity.tech https://*.intercom.io wss://*.intercom.io wss://*.intercom-messenger.com + https://*.intercom-messenger.com https://*.intercomcdn.com https://*.intercomusercontent.com + https://*.google-analytics.com https://www.googletagmanager.com wss://*.twilio.com + https://api.twilio.com https://mms.twiliocdn.com https://s3-external-1.amazonaws.com/media.twiliocdn.com/ + https://sdk.twilio.com https://eventgw.us1.twilio.com https://*.airbrake.io + https://api.mapbox.com https://events.mapbox.com https://api.maptiler.com + https://*.ingest.us.sentry.io https://*.nr-data.net https://tiles.openfreemap.org + https://*.basemaps.cartocdn.com https://basemaps.cartocdn.com https://tile.openstreetmap.org + https://server.arcgisonline.com https://demotiles.maplibre.org https://plausible.io + https://googleads.g.doubleclick.net https://www.googleadservices.com https://www.google.com + https://google.com https://pagead2.googlesyndication.com https://s3.amazonaws.com/solidarity.tech/ + https://fonts.googleapis.com https://irdu.s3.amazonaws.com https://fonts.gstatic.com + https://*.sentry-cdn.com https://cdn.jsdelivr.net https://unpkg.com https://sc.lfeeder.com + https://cdn.solidarity.tech; frame-src ''self'' https://js.stripe.com https://hooks.stripe.com + https://intercom-sheets.com https://www.recaptcha.net https://www.google.com + https://www.gstatic.com https://editor.unlayer.com https://platform.twitter.com + https://www.googletagmanager.com https://www.youtube.com https://www.youtube-nocookie.com + https://player.vimeo.com https://*.solidarity.tech; media-src ''self'' data: + https://api.twilio.com https://sdk.twilio.com https://mms.twiliocdn.com https://s3-external-1.amazonaws.com/media.twiliocdn.com/ + https://s3.amazonaws.com/solidarity.tech/ https://*.intercomcdn.com; worker-src + ''self'' blob:; frame-ancestors ''self''; object-src ''none''; base-uri ''self''; + report-uri /csp-violation-reports' + Content-Type: + - application/json; charset=utf-8 + Date: + - Sat, 05 Sep 2026 20:42:07 GMT + Referrer-Policy: + - strict-origin-when-cross-origin + Strict-Transport-Security: + - max-age=63072000; includeSubDomains + Transfer-Encoding: + - chunked + Vary: + - Accept-Encoding + Via: + - 1.1 0ed6c0a840134dc973c096b8d8152e62.cloudfront.net (CloudFront) + X-Amz-Cf-Id: + - -PI5mbVQItrOOR11JshELMqrnPUJobt3ocQCmBmHw1MZmJffcYsoOA== + X-Amz-Cf-Pop: + - BOS50-P4 + X-Cache: + - Error from cloudfront + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Frame-Options: + - SAMEORIGIN + X-Permitted-Cross-Domain-Policies: + - none + X-Request-Id: + - 71b55f32-286e-470c-9616-059c076f2fd4 + X-Xss-Protection: + - '0' + status: + code: 404 + message: Not Found +version: 1 diff --git a/test/test_solidarity-tech/cassettes/test_st_agent_assignments/TestUpdateAgentAssignment.test_update_agent_assignment_live_success.yaml b/test/test_solidarity-tech/cassettes/test_st_agent_assignments/TestUpdateAgentAssignment.test_update_agent_assignment_live_success.yaml new file mode 100644 index 0000000000..5c0555abc0 --- /dev/null +++ b/test/test_solidarity-tech/cassettes/test_st_agent_assignments/TestUpdateAgentAssignment.test_update_agent_assignment_live_success.yaml @@ -0,0 +1,110 @@ +interactions: +- request: + body: '{"user_id": 1191722, "agent_user_id": 2192968}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + Content-Length: + - '46' + User-Agent: + - python-requests/2.34.2 + authorization: + - Bearer 9y8eg37zy2viz3rxkjn9qbi829iz88n29cv0q8nfyequbk9j0n9vpo3iyv0hr7v3wxtmmdx58jj00l6b01nnuc1rzvancxaonsiuqm1ohqjh8xx15c6cjr3gxdokzsb5 + content-type: + - application/json + method: PUT + uri: https://api.solidarity.tech/v1/agent_assignments/610872 + response: + body: + string: '{"data":{"id":610872,"agent_user_id":2192968,"user_id":1191722,"created_at":"2026-04-23T14:46:02.345-04:00","is_active":true}}' + headers: + Alt-Svc: + - h3=":443"; ma=86400 + Cache-Control: + - no-cache + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Security-Policy-Report-Only: + - 'default-src ''self''; script-src ''self'' ''unsafe-inline'' ''unsafe-eval'' + https://cdnjs.cloudflare.com https://www.googletagmanager.com https://www.google-analytics.com + https://maps.googleapis.com https://*.posthog.com https://*.i.posthog.com + https://b.solidarity.tech https://*.intercom.io https://*.intercomcdn.com + https://js.stripe.com https://maxcdn.bootstrapcdn.com https://unpkg.com https://oss.maxcdn.com + https://www.gstatic.com https://www.google.com https://cdn.tiny.cloud https://s3.amazonaws.com/solidarity.tech/ + https://js-agent.newrelic.com https://editor.unlayer.com https://code.jquery.com + https://api.mapbox.com https://platform.twitter.com https://cdn.jsdelivr.net + https://plausible.io https://googleads.g.doubleclick.net https://www.googleadservices.com + https://*.sentry-cdn.com https://connect.facebook.net https://sc.lfeeder.com + https://cdn.solidarity.tech; style-src ''self'' ''unsafe-inline'' https://fonts.googleapis.com + https://s3.amazonaws.com/solidarity.tech/ https://irdu.s3.amazonaws.com https://cdn.tiny.cloud + https://cdnjs.cloudflare.com https://maxcdn.bootstrapcdn.com https://unpkg.com + https://*.intercomcdn.com https://api.mapbox.com https://cdn.jsdelivr.net + https://cdn.solidarity.tech; img-src ''self'' data: blob: https:; font-src + ''self'' data: https://fonts.gstatic.com https://s3.amazonaws.com/solidarity.tech/ + https://irdu.s3.amazonaws.com https://cdnjs.cloudflare.com https://*.intercomcdn.com + https://maxcdn.bootstrapcdn.com https://cdn.solidarity.tech; connect-src ''self'' + https://api.stripe.com https://maps.googleapis.com https://*.posthog.com https://*.i.posthog.com + https://b.solidarity.tech https://*.intercom.io wss://*.intercom.io wss://*.intercom-messenger.com + https://*.intercom-messenger.com https://*.intercomcdn.com https://*.intercomusercontent.com + https://*.google-analytics.com https://www.googletagmanager.com wss://*.twilio.com + https://api.twilio.com https://mms.twiliocdn.com https://s3-external-1.amazonaws.com/media.twiliocdn.com/ + https://sdk.twilio.com https://eventgw.us1.twilio.com https://*.airbrake.io + https://api.mapbox.com https://events.mapbox.com https://api.maptiler.com + https://*.ingest.us.sentry.io https://*.nr-data.net https://tiles.openfreemap.org + https://*.basemaps.cartocdn.com https://basemaps.cartocdn.com https://tile.openstreetmap.org + https://server.arcgisonline.com https://demotiles.maplibre.org https://plausible.io + https://googleads.g.doubleclick.net https://www.googleadservices.com https://www.google.com + https://google.com https://pagead2.googlesyndication.com https://s3.amazonaws.com/solidarity.tech/ + https://fonts.googleapis.com https://irdu.s3.amazonaws.com https://fonts.gstatic.com + https://*.sentry-cdn.com https://cdn.jsdelivr.net https://unpkg.com https://sc.lfeeder.com + https://cdn.solidarity.tech; frame-src ''self'' https://js.stripe.com https://hooks.stripe.com + https://intercom-sheets.com https://www.recaptcha.net https://www.google.com + https://www.gstatic.com https://editor.unlayer.com https://platform.twitter.com + https://www.googletagmanager.com https://www.youtube.com https://www.youtube-nocookie.com + https://player.vimeo.com https://*.solidarity.tech; media-src ''self'' data: + https://api.twilio.com https://sdk.twilio.com https://mms.twiliocdn.com https://s3-external-1.amazonaws.com/media.twiliocdn.com/ + https://s3.amazonaws.com/solidarity.tech/ https://*.intercomcdn.com; worker-src + ''self'' blob:; frame-ancestors ''self''; object-src ''none''; base-uri ''self''; + report-uri /csp-violation-reports' + Content-Type: + - application/json; charset=utf-8 + Date: + - Sat, 05 Sep 2026 20:49:19 GMT + Referrer-Policy: + - strict-origin-when-cross-origin + Strict-Transport-Security: + - max-age=63072000; includeSubDomains + Transfer-Encoding: + - chunked + Vary: + - Accept-Encoding + Via: + - 1.1 0b6269442410aef6754183c357281644.cloudfront.net (CloudFront) + X-Amz-Cf-Id: + - vIlblmSDDiMw5RbKAu8b7rNgylvW6U_6---i-T2gKU3PIo5WYQTv1Q== + X-Amz-Cf-Pop: + - BOS50-P4 + X-Cache: + - Miss from cloudfront + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Frame-Options: + - SAMEORIGIN + X-Permitted-Cross-Domain-Policies: + - none + X-Request-Id: + - d5360cdc-f441-4c2e-a0af-e7c2cdcb09bf + X-Xss-Protection: + - '0' + status: + code: 200 + message: OK +version: 1 diff --git a/test/test_solidarity-tech/cassettes/test_st_calls/TestGetCalls.test_get_calls_live.yaml b/test/test_solidarity-tech/cassettes/test_st_calls/TestGetCalls.test_get_calls_live.yaml new file mode 100644 index 0000000000..019a2b48fa --- /dev/null +++ b/test/test_solidarity-tech/cassettes/test_st_calls/TestGetCalls.test_get_calls_live.yaml @@ -0,0 +1,112 @@ +interactions: +- request: + body: null + headers: + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + User-Agent: + - python-requests/2.34.2 + accept: + - application/json + authorization: + - Bearer 9y8eg37zy2viz3rxkjn9qbi829iz88n29cv0q8nfyequbk9j0n9vpo3iyv0hr7v3wxtmmdx58jj00l6b01nnuc1rzvancxaonsiuqm1ohqjh8xx15c6cjr3gxdokzsb5 + method: GET + uri: https://api.solidarity.tech/v1/calls?_limit=20&_offset=0&_since=0 + response: + body: + string: '{"data":[{"id":1032061,"user_id":7328098,"chapter_id":293,"direction":"out","from_number":"12892105005","to_number":"16048360839","phonebank_id":null,"agent_user_id":7328098,"notes":"Just + a test of how the call tool works.","picked_up":true,"duration":33,"left_voicemail":false,"twilio_call_sid":"CA630a35575ec4505588e2978cc6f7fe9a","created_at":"2026-04-23T13:47:18.430-05:00","ended_at":"2026-04-23T13:47:51.195-05:00","transcription":null},{"id":123702,"user_id":1186152,"chapter_id":293,"direction":"out","from_number":"13238265740","to_number":"17145848653","phonebank_id":1836,"agent_user_id":null,"notes":null,"picked_up":false,"duration":38,"left_voicemail":false,"twilio_call_sid":"CAe88aef1d47a3b2b327ddeca6c2d60ae7","created_at":"2023-08-25T18:13:02.886-05:00","ended_at":"2023-08-25T18:13:40.904-05:00","transcription":null},{"id":123681,"user_id":1186152,"chapter_id":220,"direction":"out","from_number":"13238265740","to_number":"17145848653","phonebank_id":null,"agent_user_id":1140156,"notes":null,"picked_up":false,"duration":3,"left_voicemail":false,"twilio_call_sid":"CA537b74c0cb32c4b2c5259939ec8ee8f6","created_at":"2023-08-23T22:37:43.027-05:00","ended_at":"2023-08-23T22:37:46.659-05:00","transcription":null}],"meta":{"total_count":3,"limit":20,"offset":0}}' + headers: + Alt-Svc: + - h3=":443"; ma=86400 + Cache-Control: + - no-cache + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Security-Policy-Report-Only: + - 'default-src ''self''; script-src ''self'' ''unsafe-inline'' ''unsafe-eval'' + https://cdnjs.cloudflare.com https://www.googletagmanager.com https://www.google-analytics.com + https://maps.googleapis.com https://*.posthog.com https://*.i.posthog.com + https://b.solidarity.tech https://*.intercom.io https://*.intercomcdn.com + https://js.stripe.com https://maxcdn.bootstrapcdn.com https://unpkg.com https://oss.maxcdn.com + https://www.gstatic.com https://www.google.com https://cdn.tiny.cloud https://s3.amazonaws.com/solidarity.tech/ + https://js-agent.newrelic.com https://editor.unlayer.com https://code.jquery.com + https://api.mapbox.com https://platform.twitter.com https://cdn.jsdelivr.net + https://plausible.io https://googleads.g.doubleclick.net https://www.googleadservices.com + https://*.sentry-cdn.com https://connect.facebook.net https://sc.lfeeder.com + https://cdn.solidarity.tech; style-src ''self'' ''unsafe-inline'' https://fonts.googleapis.com + https://s3.amazonaws.com/solidarity.tech/ https://irdu.s3.amazonaws.com https://cdn.tiny.cloud + https://cdnjs.cloudflare.com https://maxcdn.bootstrapcdn.com https://unpkg.com + https://*.intercomcdn.com https://api.mapbox.com https://cdn.jsdelivr.net + https://cdn.solidarity.tech; img-src ''self'' data: blob: https:; font-src + ''self'' data: https://fonts.gstatic.com https://s3.amazonaws.com/solidarity.tech/ + https://irdu.s3.amazonaws.com https://cdnjs.cloudflare.com https://*.intercomcdn.com + https://maxcdn.bootstrapcdn.com https://cdn.solidarity.tech; connect-src ''self'' + https://api.stripe.com https://maps.googleapis.com https://*.posthog.com https://*.i.posthog.com + https://b.solidarity.tech https://*.intercom.io wss://*.intercom.io wss://*.intercom-messenger.com + https://*.intercom-messenger.com https://*.intercomcdn.com https://*.intercomusercontent.com + https://*.google-analytics.com https://www.googletagmanager.com wss://*.twilio.com + https://api.twilio.com https://mms.twiliocdn.com https://s3-external-1.amazonaws.com/media.twiliocdn.com/ + https://sdk.twilio.com https://eventgw.us1.twilio.com https://*.airbrake.io + https://api.mapbox.com https://events.mapbox.com https://api.maptiler.com + https://*.ingest.us.sentry.io https://*.nr-data.net https://tiles.openfreemap.org + https://*.basemaps.cartocdn.com https://basemaps.cartocdn.com https://tile.openstreetmap.org + https://server.arcgisonline.com https://demotiles.maplibre.org https://plausible.io + https://googleads.g.doubleclick.net https://www.googleadservices.com https://www.google.com + https://google.com https://pagead2.googlesyndication.com https://s3.amazonaws.com/solidarity.tech/ + https://fonts.googleapis.com https://irdu.s3.amazonaws.com https://fonts.gstatic.com + https://*.sentry-cdn.com https://cdn.jsdelivr.net https://unpkg.com https://sc.lfeeder.com + https://cdn.solidarity.tech; frame-src ''self'' https://js.stripe.com https://hooks.stripe.com + https://intercom-sheets.com https://www.recaptcha.net https://www.google.com + https://www.gstatic.com https://editor.unlayer.com https://platform.twitter.com + https://www.googletagmanager.com https://www.youtube.com https://www.youtube-nocookie.com + https://player.vimeo.com https://*.solidarity.tech; media-src ''self'' data: + https://api.twilio.com https://sdk.twilio.com https://mms.twiliocdn.com https://s3-external-1.amazonaws.com/media.twiliocdn.com/ + https://s3.amazonaws.com/solidarity.tech/ https://*.intercomcdn.com; worker-src + ''self'' blob:; frame-ancestors ''self''; object-src ''none''; base-uri ''self''; + report-uri /csp-violation-reports' + Content-Type: + - application/json; charset=utf-8 + Date: + - Sun, 06 Sep 2026 00:15:03 GMT + Referrer-Policy: + - strict-origin-when-cross-origin + Set-Cookie: + - ahoy_visitor=2f66df1f-dda5-493b-a413-a7c29ed67e78; path=/; expires=Wed, 06 + Sep 2028 00:15:03 GMT; samesite=lax; secure + - ahoy_visit=84f0eb19-9a75-469d-a5eb-097b48d20b23; path=/; expires=Sun, 06 Sep + 2026 04:15:03 GMT; samesite=lax; secure + Strict-Transport-Security: + - max-age=63072000; includeSubDomains + Transfer-Encoding: + - chunked + Vary: + - Accept-Encoding + Via: + - 1.1 5c7ee37b1cd85c8e6f3e6b902d2491d6.cloudfront.net (CloudFront) + X-Amz-Cf-Id: + - hUlEnbt8qSTpPJuFxAWk4e9QC6O6pWOOHLhjvb3QFBD56r15wgFz2g== + X-Amz-Cf-Pop: + - BOS50-P6 + X-Cache: + - Miss from cloudfront + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Frame-Options: + - SAMEORIGIN + X-Permitted-Cross-Domain-Policies: + - none + X-Request-Id: + - 2c6f22ba-6ae7-4e2e-9b77-b53854f339ef + X-Xss-Protection: + - '0' + status: + code: 200 + message: OK +version: 1 diff --git a/test/test_solidarity-tech/cassettes/test_st_chapter_phone_numbers/TestGetChapterPhoneNumbers.test_get_chapter_phone_numbers_live.yaml b/test/test_solidarity-tech/cassettes/test_st_chapter_phone_numbers/TestGetChapterPhoneNumbers.test_get_chapter_phone_numbers_live.yaml new file mode 100644 index 0000000000..1cf8b5f478 --- /dev/null +++ b/test/test_solidarity-tech/cassettes/test_st_chapter_phone_numbers/TestGetChapterPhoneNumbers.test_get_chapter_phone_numbers_live.yaml @@ -0,0 +1,113 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + User-Agent: + - python-requests/2.34.2 + authorization: + - Bearer 9y8eg37zy2viz3rxkjn9qbi829iz88n29cv0q8nfyequbk9j0n9vpo3iyv0hr7v3wxtmmdx58jj00l6b01nnuc1rzvancxaonsiuqm1ohqjh8xx15c6cjr3gxdokzsb5 + method: GET + uri: https://api.solidarity.tech/v1/chapter_phone_numbers?_limit=20&_offset=0&_since=0 + response: + body: + string: '{"data":[{"id":2127,"phone_number":"12136822882","assigned_user_count":4,"chapters":[{"id":296,"name":"Chapter + 3 - test chapter 123","logo_url":"https://s3.amazonaws.com/solidarity.tech/image_uploads/images/000/022/835/original/RackMultipart20250624-100-gyu51b.png?1750743296","organization_id":80,"chapter_phone_number":"(213) + 682-2882","calendar_feed_url":null}],"created_at":"2023-07-01T13:23:52.768+10:00"}],"meta":{"total_count":1,"limit":20,"offset":0}}' + headers: + Alt-Svc: + - h3=":443"; ma=86400 + Cache-Control: + - no-cache + Connection: + - keep-alive + Content-Encoding: + - gzip + Content-Security-Policy-Report-Only: + - 'default-src ''self''; script-src ''self'' ''unsafe-inline'' ''unsafe-eval'' + https://cdnjs.cloudflare.com https://www.googletagmanager.com https://www.google-analytics.com + https://maps.googleapis.com https://*.posthog.com https://*.i.posthog.com + https://b.solidarity.tech https://*.intercom.io https://*.intercomcdn.com + https://js.stripe.com https://maxcdn.bootstrapcdn.com https://unpkg.com https://oss.maxcdn.com + https://www.gstatic.com https://www.google.com https://cdn.tiny.cloud https://s3.amazonaws.com/solidarity.tech/ + https://js-agent.newrelic.com https://editor.unlayer.com https://code.jquery.com + https://api.mapbox.com https://platform.twitter.com https://cdn.jsdelivr.net + https://plausible.io https://googleads.g.doubleclick.net https://www.googleadservices.com + https://*.sentry-cdn.com https://connect.facebook.net https://sc.lfeeder.com + https://cdn.solidarity.tech; style-src ''self'' ''unsafe-inline'' https://fonts.googleapis.com + https://s3.amazonaws.com/solidarity.tech/ https://irdu.s3.amazonaws.com https://cdn.tiny.cloud + https://cdnjs.cloudflare.com https://maxcdn.bootstrapcdn.com https://unpkg.com + https://*.intercomcdn.com https://api.mapbox.com https://cdn.jsdelivr.net + https://cdn.solidarity.tech; img-src ''self'' data: blob: https:; font-src + ''self'' data: https://fonts.gstatic.com https://s3.amazonaws.com/solidarity.tech/ + https://irdu.s3.amazonaws.com https://cdnjs.cloudflare.com https://*.intercomcdn.com + https://maxcdn.bootstrapcdn.com https://cdn.solidarity.tech; connect-src ''self'' + https://api.stripe.com https://maps.googleapis.com https://*.posthog.com https://*.i.posthog.com + https://b.solidarity.tech https://*.intercom.io wss://*.intercom.io wss://*.intercom-messenger.com + https://*.intercom-messenger.com https://*.intercomcdn.com https://*.intercomusercontent.com + https://*.google-analytics.com https://www.googletagmanager.com wss://*.twilio.com + https://api.twilio.com https://mms.twiliocdn.com https://s3-external-1.amazonaws.com/media.twiliocdn.com/ + https://sdk.twilio.com https://eventgw.us1.twilio.com https://*.airbrake.io + https://api.mapbox.com https://events.mapbox.com https://api.maptiler.com + https://*.ingest.us.sentry.io https://*.nr-data.net https://tiles.openfreemap.org + https://*.basemaps.cartocdn.com https://basemaps.cartocdn.com https://tile.openstreetmap.org + https://server.arcgisonline.com https://demotiles.maplibre.org https://plausible.io + https://googleads.g.doubleclick.net https://www.googleadservices.com https://www.google.com + https://google.com https://pagead2.googlesyndication.com https://s3.amazonaws.com/solidarity.tech/ + https://fonts.googleapis.com https://irdu.s3.amazonaws.com https://fonts.gstatic.com + https://*.sentry-cdn.com https://cdn.jsdelivr.net https://unpkg.com https://sc.lfeeder.com + https://cdn.solidarity.tech; frame-src ''self'' https://js.stripe.com https://hooks.stripe.com + https://intercom-sheets.com https://www.recaptcha.net https://www.google.com + https://www.gstatic.com https://editor.unlayer.com https://platform.twitter.com + https://www.googletagmanager.com https://www.youtube.com https://www.youtube-nocookie.com + https://player.vimeo.com https://*.solidarity.tech; media-src ''self'' data: + https://api.twilio.com https://sdk.twilio.com https://mms.twiliocdn.com https://s3-external-1.amazonaws.com/media.twiliocdn.com/ + https://s3.amazonaws.com/solidarity.tech/ https://*.intercomcdn.com; worker-src + ''self'' blob:; frame-ancestors ''self''; object-src ''none''; base-uri ''self''; + report-uri /csp-violation-reports' + Content-Type: + - application/json; charset=utf-8 + Date: + - Sun, 06 Sep 2026 01:44:23 GMT + Referrer-Policy: + - strict-origin-when-cross-origin + Set-Cookie: + - ahoy_visitor=ffc21610-926c-4a87-86ba-6aa2af69307e; path=/; expires=Wed, 06 + Sep 2028 01:44:23 GMT; samesite=lax; secure + - ahoy_visit=10416a6e-86ce-4a14-b121-59d31ac9b6f6; path=/; expires=Sun, 06 Sep + 2026 05:44:23 GMT; samesite=lax; secure + Strict-Transport-Security: + - max-age=63072000; includeSubDomains + Transfer-Encoding: + - chunked + Vary: + - Accept-Encoding + Via: + - 1.1 b787d00b53c710066f0d489ebc435a74.cloudfront.net (CloudFront) + X-Amz-Cf-Id: + - IT9lksPZyM3S1MOkTMy9matk29HXM8R292OeF0pdkfu7S4ErR6nf-w== + X-Amz-Cf-Pop: + - BOS50-P3 + X-Cache: + - Miss from cloudfront + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Frame-Options: + - SAMEORIGIN + X-Permitted-Cross-Domain-Policies: + - none + X-Request-Id: + - 5cd33c9d-d247-40a6-a370-331a3725a4cc + X-Xss-Protection: + - '0' + status: + code: 200 + message: OK +version: 1 diff --git a/test/test_solidarity-tech/conftest.py b/test/test_solidarity-tech/conftest.py new file mode 100644 index 0000000000..c8b708b044 --- /dev/null +++ b/test/test_solidarity-tech/conftest.py @@ -0,0 +1,20 @@ +"""Test fixtures for the SolidarityTech client.""" + +from __future__ import annotations + +import os + +import pytest + +from parsons.solidarity_tech import SolidarityTech + + +@pytest.fixture +def st() -> SolidarityTech: + """Create a SolidarityTech instance with a placeholder api key.""" + api_key = ( + os.environ.get("SOLIDARITY_TECH_BEARER_KEY") + if "SOLIDARITY_TECH_BEARER_KEY" in os.environ + else "SOME_BEARER_KEY" + ) + return SolidarityTech(api_token=api_key) diff --git a/test/test_solidarity-tech/test_auth.py b/test/test_solidarity-tech/test_auth.py new file mode 100644 index 0000000000..9638f467df --- /dev/null +++ b/test/test_solidarity-tech/test_auth.py @@ -0,0 +1,59 @@ +"""Tests for :class:`~parsons.solidarity_tech.auth.SolidarityTechAuth`.""" + +from __future__ import annotations + +import secrets + +import requests + +from parsons.solidarity_tech.auth import SolidarityTechAuth + + +def test_auth_init() -> None: + """Test that the auth object is initialized with the supplied API key.""" + api_token = secrets.token_hex(64) + auth = SolidarityTechAuth(api_token) + assert auth.api_key == api_token + + +def test_auth_eq() -> None: + """Test that instances of auth objects with the same API key can be compared for equality.""" + api_token = secrets.token_hex(64) + auth1 = SolidarityTechAuth(api_token) + auth2 = SolidarityTechAuth(api_token) + assert auth1 == auth2 + + +def test_auth_hash() -> None: + """Test that auth objects are hashable based on their API key and can be used as dictionary keys.""" + api_token = secrets.token_hex(64) + auth = SolidarityTechAuth(api_token) + assert hash(auth) == hash(api_token) + + +def test_auth_repr() -> None: + """Test that auth objects include the API key in their repr.""" + api_token = secrets.token_hex(64) + auth = SolidarityTechAuth(api_token) + assert api_token in repr(auth) + + +def test_auth_call() -> None: + """Test that calling an auth object with a request adds the authorization header.""" + api_token = secrets.token_hex(64) + auth = SolidarityTechAuth(api_token) + req = requests.Request(url="https://example.com") + req = req.prepare() + auth(req) + assert req.headers["authorization"] == f"Bearer {api_token}" + + +def test_auth_call_does_not_clear_headers() -> None: + """Test that calling an auth object with a request adds the authorization header to existing headers.""" + api_token = secrets.token_hex(64) + auth = SolidarityTechAuth(api_token) + req = requests.Request(url="https://example.com", headers={"X-Test": "test"}) + req = req.prepare() + auth(req) + assert req.headers["authorization"] == f"Bearer {api_token}" + assert req.headers["X-Test"] == "test" diff --git a/test/test_solidarity-tech/test_base.py b/test/test_solidarity-tech/test_base.py new file mode 100644 index 0000000000..37d172aa16 --- /dev/null +++ b/test/test_solidarity-tech/test_base.py @@ -0,0 +1,364 @@ +"""Tests for :class:`~parsons.solidarity_tech.base.SolidarityTechBase`.""" + +from __future__ import annotations + +import re +from datetime import datetime, timezone +from typing import TYPE_CHECKING +from urllib.parse import urlencode, urlsplit + +import pytest +import requests + +from parsons.solidarity_tech.exceptions import ( + STFailedAuthenticationError, + STFailedResponseError, + STUnexpectedResponseError, +) + +if TYPE_CHECKING: + from requests_mock import Mocker + + from parsons.solidarity_tech import SolidarityTech + from parsons.utilities.api_connector import _JsonType, _ParamsType + + +@pytest.fixture +def known_status_codes() -> dict[int, tuple[bool, str]]: + """Known status codes and their expected outcomes.""" + return { + 200: (True, "OK"), + 201: (True, "updated resource"), + 404: (False, "could not find resource"), + 422: (False, "could not process request"), + } + + +class TestDeleteRequest: + """Tests for :meth:`parsons.solidarity_tech.SolidarityTech._delete_request`.""" + + def test_delete_request(self, st: SolidarityTech, requests_mock: Mocker) -> None: + """Make a DELETE request with an ID.""" + resource_id = 42 + endpoint = "users" + endpoint_url = f"{st.api_url}{endpoint}/{resource_id}" + _ = requests_mock.delete(endpoint_url, json={"id": resource_id}) + + _ = st._delete_request(endpoint, resource_id) + + assert requests_mock.call_count == 1 + assert requests_mock.last_request is not None + assert requests_mock.last_request.method == "DELETE" + assert requests_mock.last_request.url == endpoint_url + + +class TestPutRequest: + """Tests for :meth:`parsons.solidarity_tech.SolidarityTech._put_request`.""" + + def test_put_request(self, st: SolidarityTech, requests_mock: Mocker) -> None: + """Make a GET request with an ID.""" + resource_id = 42 + endpoint = "users" + endpoint_url = f"{st.api_url}{endpoint}/{resource_id}" + _ = requests_mock.put(endpoint_url, json={"id": resource_id}) + + _ = st._put_request(endpoint, resource_id) + + assert requests_mock.call_count == 1 + assert requests_mock.last_request is not None + assert requests_mock.last_request.method == "PUT" + assert requests_mock.last_request.url == endpoint_url + + +class TestPostRequest: + """Tests for :meth:`parsons.solidarity_tech.SolidarityTech._post_request`.""" + + @pytest.mark.parametrize( + "endpoint", ["custom_user_properties", "event_sessions/295876/hosts", "field_survey_urls"] + ) + def test_post_request_handles_varied_endpoints( + self, st: SolidarityTech, requests_mock: Mocker, endpoint: str + ) -> None: + """Make a POST request to varied endpoints.""" + endpoint_url = f"{st.api_url}{endpoint}" + _ = requests_mock.post(endpoint_url) + + _ = st._post_request(endpoint) + + assert requests_mock.call_count == 1 + assert requests_mock.last_request is not None + assert requests_mock.last_request.method == "POST" + assert requests_mock.last_request.url == endpoint_url + + def test_post_request_makes_request_with_payload( + self, st: SolidarityTech, requests_mock: Mocker + ) -> None: + """Makes a POST request with payload.""" + payload: _JsonType = {"user_id": 654123} + _ = requests_mock.post(st.api_url) + + _ = st._post_request(st.api_url, payload=payload) + + assert requests_mock.call_count == 1 + assert requests_mock.last_request is not None + assert requests_mock.last_request.method == "POST" + assert requests_mock.last_request.url == st.api_url + assert requests_mock.last_request.json() == payload + + def test_post_request_makes_request_with_params( + self, st: SolidarityTech, requests_mock: Mocker + ) -> None: + """Make a POST request with params.""" + params: _ParamsType = {"automation_id": 35876} + _ = requests_mock.post(st.api_url) + + _ = st._post_request(st.api_url, params=params) + + assert requests_mock.call_count == 1 + assert requests_mock.last_request is not None + assert requests_mock.last_request.method == "POST" + + last_url = urlsplit(requests_mock.last_request.url) + assert f"{last_url.scheme}://{last_url.netloc}{last_url.path}" == st.api_url + assert last_url.query == urlencode(params) + + +class TestGetSingleResource: + """Tests for :meth:`parsons.solidarity_tech.SolidarityTech._get_single_resource`.""" + + def test_get_single_resource_makes_request_with_id( + self, st: SolidarityTech, requests_mock: Mocker + ) -> None: + """Make a GET request with an ID.""" + resource_id = 42 + endpoint = "users" + endpoint_url = f"{st.api_url}{endpoint}/{resource_id}" + _ = requests_mock.get(endpoint_url, json={"id": resource_id}) + + _ = st._get_single_resource(endpoint, resource_id) + + assert requests_mock.call_count == 1 + assert requests_mock.last_request is not None + assert requests_mock.last_request.method == "GET" + assert requests_mock.last_request.url == endpoint_url + + +class TestGetResources: + """Tests for :meth:`parsons.solidarity_tech.SolidarityTech._get_resources`.""" + + @pytest.mark.parametrize("endpoint", ["activities", "agent_assignments", "users/124876"]) + def test_get_resources_makes_request( + self, st: SolidarityTech, requests_mock: Mocker, endpoint: str + ) -> None: + """Make a GET request to varied endpoints.""" + endpoint_url = f"{st.api_url}{endpoint}" + _ = requests_mock.get(endpoint_url) + + _ = st._get_resources(endpoint) + + assert requests_mock.call_count == 1 + assert requests_mock.last_request is not None + assert requests_mock.last_request.method == "GET" + assert requests_mock.last_request.url == endpoint_url + + def test_get_resources_datetime( + self, + st: SolidarityTech, + requests_mock: Mocker, + ) -> None: + """Convert datetime-typed ``since``.""" + now_datetime = datetime.now(tz=timezone.utc) + now_timestamp = int(now_datetime.timestamp()) + _ = requests_mock.get(st.api_url) + + _ = st._get_resources( + st.api_url, + since=now_datetime, + ) + + assert requests_mock.call_count == 1 + assert requests_mock.last_request is not None + assert requests_mock.last_request.method == "GET" + + last_url = urlsplit(requests_mock.last_request.url) + assert f"{last_url.scheme}://{last_url.netloc}{last_url.path}" == st.api_url + assert last_url.query == urlencode({"_since": now_timestamp}) + + def test_get_resources_remaps_special_query_strings( + self, + st: SolidarityTech, + requests_mock: Mocker, + ) -> None: + """Integrate special query names provided as keyword arguments.""" + _ = requests_mock.get(st.api_url) + + _ = st._get_resources( + st.api_url, + limit=123456, + cursor=654321, + offset=321456, + since=456321, + include_count=123654, + ) + + assert requests_mock.call_count == 1 + assert requests_mock.last_request is not None + assert requests_mock.last_request.method == "GET" + + last_url = urlsplit(requests_mock.last_request.url) + assert f"{last_url.scheme}://{last_url.netloc}{last_url.path}" == st.api_url + assert last_url.query == urlencode( + { + "_limit": 123456, + "_cursor": 654321, + "_offset": 321456, + "_since": 456321, + "_include_count": 123654, + } + ) + + def test_get_resources_param_collision_error( + self, + st: SolidarityTech, + requests_mock: Mocker, + ) -> None: + """Raise a :class:`KeyError` when a query passed in keyword arguments collides with one passed in params.""" + _ = requests_mock.get(st.api_url) + + with pytest.raises(KeyError, match="Request param '_limit' already exists"): + _ = st._get_resources(st.api_url, limit=15, params={"_limit": 30}) + + def test_get_resources_does_not_include_params_with_none_value( + self, + st: SolidarityTech, + requests_mock: Mocker, + ) -> None: + """Skip queries passed in params that have the value of None.""" + _ = requests_mock.get(st.api_url) + + _ = st._get_resources(st.api_url, params={"_limit": None}) + + assert requests_mock.call_count == 1 + assert requests_mock.last_request is not None + assert requests_mock.last_request.method == "GET" + assert requests_mock.last_request.url == st.api_url + + +class TestAddIfFieldNotEmpty: + """Test the :meth:`parsons.solidarity_tech.SolidarityTech._add_if_field_not_empty` method.""" + + @pytest.mark.parametrize( + ("key", "value", "expected"), + [ + ("test_key_string", "test_value", {"test_key_string": "test_value"}), + ("test_key_int", 123456, {"test_key_int": 123456}), + ("test_key_true", True, {"test_key_true": True}), + ("test_key_false", False, {"test_key_false": False}), + ("test_key_none", None, {}), + ], + ) + def test_add_if_field_not_empty( + self, + st: SolidarityTech, + key: str, + value: str | int | None, + expected: dict[str, str | int], + ) -> None: + """Add only items with a value.""" + init_dict = {} + result = st._add_if_field_not_empty(init_dict, key, value) + assert result == expected + + def test_add_if_field_not_empty_overwrite(self, st: SolidarityTech) -> None: + """Overwrite existing keys when ``overwrite`` is ``True``.""" + init_dict = {"test_key": "original_value"} + result = st._add_if_field_not_empty( + init_dict, "test_key", "overwrite_value", overwrite=True + ) + assert result["test_key"] == "overwrite_value" + + def test_add_if_field_not_empty_no_overwrite_default(self, st: SolidarityTech) -> None: + """Don't overwrite existing keys when ``overwrite`` is not provided.""" + init_dict = {"test_key": "original_value"} + with pytest.raises(KeyError, match="'test_key' already exists"): + _ = st._add_if_field_not_empty(init_dict, "test_key", "overwrite_value") + + def test_add_if_field_not_empty_no_overwrite(self, st: SolidarityTech) -> None: + """Raise a :class`KeyError` when ``overwrite`` is ``False`` and the key already exists.""" + init_dict = {"test_key": "original_value"} + with pytest.raises(KeyError, match="'test_key' already exists"): + _ = st._add_if_field_not_empty( + init_dict, "test_key", "overwrite_value", overwrite=False + ) + + +class TestHandleStatusCodes: + """Test :meth:`parsons.solidarity_tech.SolidarityTech._handle_status_codes`.""" + + @pytest.mark.parametrize( + "status_code", + [200, 201, 404, 422], + ) + def test_handle_status_codes( + self, + st: SolidarityTech, + requests_mock: Mocker, + known_status_codes: dict[int, tuple[bool, str]], + status_code: int, + ) -> None: + """ + Handle known status codes. + + Raise a :class:`STFailedResponseError` if parsing a known failure status code, + return ``True`` if parsing a known success status code. + + """ + _ = requests_mock.get("https://api.example.com", status_code=status_code) + res = requests.get("https://api.example.com") + + success_expected = known_status_codes[status_code][0] + if success_expected: + assert st._handle_status_codes(res, known_status_codes) + else: + failure_description = known_status_codes[status_code][1] + err_msg = re.escape( + f"Request Failed (Status Code {status_code}) -- {failure_description}" + ) + with pytest.raises(STFailedResponseError, match=err_msg): + _ = st._handle_status_codes(res, known_status_codes) + + def test_handle_status_codes_unauthorized( + self, + st: SolidarityTech, + requests_mock: Mocker, + known_status_codes: dict[int, tuple[bool, str]], + ) -> None: + """Raise a :class:`STUnexpectedResponseError` if parsing an unrecognized status code.""" + status_code = 401 + + _ = requests_mock.get("https://api.example.com", status_code=status_code) + res = requests.get("https://api.example.com") + + with pytest.raises( + STFailedAuthenticationError, + match=re.escape(f"Authentication failed or not provided (Status Code {status_code})"), + ): + _ = st._handle_status_codes(res, known_status_codes) + + def test_handle_status_codes_unrecognized( + self, + st: SolidarityTech, + requests_mock: Mocker, + known_status_codes: dict[int, tuple[bool, str]], + ) -> None: + """Raise a :class:`STUnexpectedResponseError` if parsing an unrecognized status code.""" + status_code = 500 + + _ = requests_mock.get("https://api.example.com", status_code=status_code) + res = requests.get("https://api.example.com") + + with pytest.raises( + STUnexpectedResponseError, + match=re.escape(f"Unexpected Response (Status Code {status_code})"), + ): + _ = st._handle_status_codes(res, known_status_codes) diff --git a/test/test_solidarity-tech/test_init.py b/test/test_solidarity-tech/test_init.py new file mode 100644 index 0000000000..4f374edbf6 --- /dev/null +++ b/test/test_solidarity-tech/test_init.py @@ -0,0 +1,47 @@ +"""Tests for initializing :class:`~parsons.solidarity_tech.SolidarityTech`.""" + +from __future__ import annotations + +import pytest +import requests + +from parsons.solidarity_tech import SolidarityTech +from parsons.solidarity_tech.auth import SolidarityTechAuth + +TOKEN_ENV_NAME = "SOLIDARITY_TECH_BEARER_KEY" +TOKEN_PLACEHOLDER = "SOME_BEARER_KEY" + + +def test_init_with_arg() -> None: + """Set api_token property and header when initialized via an argument.""" + st = SolidarityTech(api_token=TOKEN_PLACEHOLDER) + assert isinstance(st.api.auth, SolidarityTechAuth) + + req = requests.Request("GET", url="https://api.example.com", auth=st.api.auth) + req = req.prepare() + assert req.headers.get("authorization") == f"Bearer {TOKEN_PLACEHOLDER}" + + +def test_init_with_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Set api_token property and header when initialized via environment variable.""" + with monkeypatch.context() as m: + m.setenv(TOKEN_ENV_NAME, TOKEN_PLACEHOLDER) + st = SolidarityTech() + assert isinstance(st.api.auth, SolidarityTechAuth) + + req = requests.Request("GET", url="https://api.example.com", auth=st.api.auth) + req = req.prepare() + assert req.headers.get("authorization") == f"Bearer {TOKEN_PLACEHOLDER}" + + +def test_init_with_no_api_token(monkeypatch: pytest.MonkeyPatch) -> None: + """Raise :class:`KeyError` when no API token is provided and the environment variable is not set.""" + with monkeypatch.context() as m: + m.delenv(TOKEN_ENV_NAME, raising=False) + with pytest.raises(KeyError, match=f"No '{TOKEN_ENV_NAME}' found."): + SolidarityTech() + + +def test_init_api_url(st: SolidarityTech) -> None: + """Set api_url property.""" + assert st.api_url == "https://api.solidarity.tech/v1/" diff --git a/test/test_solidarity-tech/test_st_activities.py b/test/test_solidarity-tech/test_st_activities.py new file mode 100644 index 0000000000..879e96115e --- /dev/null +++ b/test/test_solidarity-tech/test_st_activities.py @@ -0,0 +1,69 @@ +"""Tests for the Activities methods of :class:`~parsons.solidarity_tech.SolidarityTech`.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from parsons import Table + +if TYPE_CHECKING: + from requests_mock import Mocker + + from parsons.solidarity_tech import SolidarityTech + +ENDPOINT = "activities" + + +class TestGetActivities: + @pytest.mark.vcr + def test_get_activities_live(self, st: SolidarityTech) -> None: + """Verify that :meth:`~parsons.solidarity_tech.SolidarityTech.get_activities` returns both a Table of results and the associated metadata.""" + activities, activities_meta = st.get_activities() + + assert isinstance(activities, Table) + assert len(activities) > 0 + assert isinstance(activities[0], dict) + + assert isinstance(activities_meta, dict) + assert activities_meta["total_count"] is None + assert activities_meta["limit"] == 20 + assert activities_meta["offset"] == 0 + assert activities_meta["cursor"] is None + assert activities_meta["next_cursor"] == 16640151 + + def test_get_activities_minimal(self, st: SolidarityTech, requests_mock: Mocker) -> None: + """Verify that :meth:`~parsons.solidarity_tech.SolidarityTech.get_activities` makes the appropriate calls.""" + endpoint_url = f"{st.api_url}{ENDPOINT}?_limit=20&_since=0" + _ = requests_mock.get(f"{st.api_url}{ENDPOINT}", json={"data": [{}], "meta": {}}) + + _, _ = st.get_activities() + + assert requests_mock.call_count == 1 + assert requests_mock.last_request is not None + assert requests_mock.last_request.method == "GET" + assert requests_mock.last_request.url == endpoint_url + + def test_get_activities_maximal(self, st: SolidarityTech, requests_mock: Mocker) -> None: + """Verify that :meth:`~parsons.solidarity_tech.SolidarityTech.get_activities` makes the appropriate calls.""" + limit = 30 + cursor = 5 + since = 1788075104 + include_count = True + user_id = 8758764 + endpoint_url = f"{st.api_url}{ENDPOINT}?_limit={limit}&_cursor={cursor}&_since={since}&_include_count={include_count}&user_id={user_id}" + _ = requests_mock.get(f"{st.api_url}{ENDPOINT}", json={"data": [{}], "meta": {}}) + + _, _ = st.get_activities( + limit=limit, + cursor=cursor, + since=since, + include_count=include_count, + user_id=user_id, + ) + + assert requests_mock.call_count == 1 + assert requests_mock.last_request is not None + assert requests_mock.last_request.method == "GET" + assert requests_mock.last_request.url == endpoint_url diff --git a/test/test_solidarity-tech/test_st_agent_assignments.py b/test/test_solidarity-tech/test_st_agent_assignments.py new file mode 100644 index 0000000000..e902c746d2 --- /dev/null +++ b/test/test_solidarity-tech/test_st_agent_assignments.py @@ -0,0 +1,201 @@ +"""Tests for the Agent Assignments methods of :class:`~parsons.solidarity_tech.SolidarityTech`.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from parsons import Table +from parsons.solidarity_tech.exceptions import STFailedResponseError + +if TYPE_CHECKING: + from requests_mock import Mocker + + from parsons.solidarity_tech import SolidarityTech + +ENDPOINT = "agent_assignments" + + +class TestGetAgentAssignments: + @pytest.mark.vcr + def test_get_agent_assignments_live(self, st: SolidarityTech) -> None: + """Verify that :meth:`~parsons.solidarity_tech.SolidarityTech.get_agent_assignments` returns both a Table of results and the associated metadata.""" + agent_assignments, agent_assignments_meta = st.get_agent_assignments() + + assert isinstance(agent_assignments, Table) + assert len(agent_assignments) > 0 + assert isinstance(agent_assignments[0], dict) + + assert isinstance(agent_assignments_meta, dict) + assert agent_assignments_meta["total_count"] > 0 + assert agent_assignments_meta["limit"] == 20 + assert agent_assignments_meta["offset"] == 0 + + def test_get_agent_assignments_minimal(self, st: SolidarityTech, requests_mock: Mocker) -> None: + """Verify that :meth:`~parsons.solidarity_tech.SolidarityTech.get_agent_assignments` makes the appropriate calls.""" + endpoint_url = f"{st.api_url}{ENDPOINT}" + _ = requests_mock.get(endpoint_url, json={"data": [{}], "meta": {}}) + + _, _ = st.get_agent_assignments() + + assert requests_mock.call_count == 1 + assert requests_mock.last_request is not None + assert requests_mock.last_request.method == "GET" + assert requests_mock.last_request.url == f"{endpoint_url}?_limit=20&_offset=0&_since=0" + + def test_get_agent_assignments_maximal(self, st: SolidarityTech, requests_mock: Mocker) -> None: + """Verify that :meth:`~parsons.solidarity_tech.SolidarityTech.get_agent_assignments` makes the appropriate calls.""" + limit = 30 + offset = 5 + since = 1788075104 + user_id = 3295823659 + agent_user_id = 12350912375 + endpoint_url = f"{st.api_url}{ENDPOINT}" + _ = requests_mock.get(endpoint_url, json={"data": [{}], "meta": {}}) + + _, _ = st.get_agent_assignments( + limit=limit, + offset=offset, + since=since, + user_id=user_id, + agent_user_id=agent_user_id, + ) + + assert requests_mock.call_count == 1 + assert requests_mock.last_request is not None + assert requests_mock.last_request.method == "GET" + assert ( + requests_mock.last_request.url + == f"{endpoint_url}?_limit={limit}&_offset={offset}&_since={since}&user_id={user_id}&agent_user_id={agent_user_id}" + ) + + +class TestGetAgentAssignment: + @pytest.mark.vcr + def test_get_agent_assignment_live(self, st: SolidarityTech) -> None: + """Verify that :meth:`~parsons.solidarity_tech.SolidarityTech.get_agent_assignment` returns both a Table of results and the associated metadata.""" + resource_id = 478171 + agent_assignment, agent_assignment_meta = st.get_agent_assignment(resource_id=resource_id) + + assert isinstance(agent_assignment, dict) + assert agent_assignment["id"] == resource_id + + assert isinstance(agent_assignment_meta, dict) + assert agent_assignment_meta["total_count"] == 1 + assert agent_assignment_meta["limit"] == 1 + assert agent_assignment_meta["offset"] == 0 + + def test_get_agent_assignment(self, st: SolidarityTech, requests_mock: Mocker) -> None: + """Verify that :meth:`~parsons.solidarity_tech.SolidarityTech.get_agent_assignment` makes the appropriate calls.""" + resource_id = 3598327 + endpoint_url = f"{st.api_url}{ENDPOINT}/{resource_id}" + _ = requests_mock.get(endpoint_url, json={"data": [{}], "meta": {}}) + + _, _ = st.get_agent_assignment(resource_id=resource_id) + + assert requests_mock.call_count == 1 + assert requests_mock.last_request is not None + assert requests_mock.last_request.method == "GET" + assert requests_mock.last_request.url == endpoint_url + + def test_get_agent_assignment_not_found( + self, st: SolidarityTech, requests_mock: Mocker + ) -> None: + """Verify that :meth:`~parsons.solidarity_tech.SolidarityTech.get_agent_assignment` makes the appropriate calls.""" + endpoint_url = f"{st.api_url}{ENDPOINT}/99" + _ = requests_mock.get(endpoint_url, status_code=404, reason="Agent assignment not found") + + with pytest.raises(STFailedResponseError, match="Agent assignment not found"): + _, _ = st.get_agent_assignment(resource_id=99) + + assert requests_mock.call_count == 1 + assert requests_mock.last_request is not None + assert requests_mock.last_request.method == "GET" + assert requests_mock.last_request.url == endpoint_url + + +class TestCreateAgentAssignment: + @pytest.mark.vcr + def test_create_agent_assignment_live_success(self, st: SolidarityTech) -> None: + """Verify that :meth:`~parsons.solidarity_tech.SolidarityTech.create_agent_assignment` returns the data for the created agent assignment.""" + agent_assignment = st.create_agent_assignment(user_id=1191722, agent_user_id=2192958) + + assert isinstance(agent_assignment["id"], int) + assert agent_assignment["user_id"] == 1191722 + assert agent_assignment["agent_user_id"] == 2192958 + + @pytest.mark.vcr + def test_create_agent_assignment_live_failure(self, st: SolidarityTech) -> None: + """Verify that :meth:`~parsons.solidarity_tech.SolidarityTech.create_agent_assignment` fails if either user_id or agent_user_id is invalid.""" + with pytest.raises(STFailedResponseError, match="Not Found"): + _ = st.create_agent_assignment(user_id=1191722, agent_user_id=99) + + def test_create_agent_assignment(self, st: SolidarityTech, requests_mock: Mocker) -> None: + """Verify that :meth:`~parsons.solidarity_tech.SolidarityTech.create_agent_assignment` makes the appropriate calls.""" + user_id = 1191722 + agent_user_id = 2192958 + is_active = False + endpoint_url = f"{st.api_url}{ENDPOINT}" + _ = requests_mock.post(endpoint_url, status_code=201, json={"data": {}}) + + _ = st.create_agent_assignment( + user_id=user_id, agent_user_id=agent_user_id, is_active=is_active + ) + + assert requests_mock.call_count == 1 + assert requests_mock.last_request is not None + assert requests_mock.last_request.method == "POST" + assert requests_mock.last_request.url == endpoint_url + assert requests_mock.last_request.json()["user_id"] == user_id + assert requests_mock.last_request.json()["agent_user_id"] == agent_user_id + assert requests_mock.last_request.json()["is_active"] == is_active + + +class TestUpdateAgentAssignment: + @pytest.mark.vcr + def test_update_agent_assignment_live_success(self, st: SolidarityTech) -> None: + """Verify that :meth:`~parsons.solidarity_tech.SolidarityTech.update_agent_assignment` returns the data for the updated agent assignment.""" + resource_id = 610872 + user_id = 1191722 + agent_user_id = 2192968 + + agent_assignment = st.update_agent_assignment( + resource_id=resource_id, + user_id=user_id, + agent_user_id=agent_user_id, + ) + + assert agent_assignment["id"] == resource_id + assert agent_assignment["user_id"] == user_id + assert agent_assignment["agent_user_id"] == agent_user_id + + @pytest.mark.vcr + def test_update_agent_assignment_live_failure(self, st: SolidarityTech) -> None: + """Verify that :meth:`~parsons.solidarity_tech.SolidarityTech.update_agent_assignment` fails if either user_id or agent_user_id is invalid.""" + with pytest.raises(STFailedResponseError, match="Not Found"): + _ = st.update_agent_assignment(resource_id=295862, user_id=928642, agent_user_id=99) + + def test_update_agent_assignment(self, st: SolidarityTech, requests_mock: Mocker) -> None: + """Verify that :meth:`~parsons.solidarity_tech.SolidarityTech.update_agent_assignment` makes the appropriate calls.""" + resource_id = 295862 + user_id = 1191722 + agent_user_id = 2192958 + is_active = False + endpoint_url = f"{st.api_url}{ENDPOINT}/{resource_id}" + _ = requests_mock.put(endpoint_url, status_code=200, json={"data": {}}) + + _ = st.update_agent_assignment( + resource_id=resource_id, + user_id=user_id, + agent_user_id=agent_user_id, + is_active=is_active, + ) + + assert requests_mock.call_count == 1 + assert requests_mock.last_request is not None + assert requests_mock.last_request.method == "PUT" + assert requests_mock.last_request.url == endpoint_url + assert requests_mock.last_request.json()["user_id"] == user_id + assert requests_mock.last_request.json()["agent_user_id"] == agent_user_id + assert requests_mock.last_request.json()["is_active"] == is_active diff --git a/test/test_solidarity-tech/test_st_automation_enrollments.py b/test/test_solidarity-tech/test_st_automation_enrollments.py new file mode 100644 index 0000000000..261a08d1e4 --- /dev/null +++ b/test/test_solidarity-tech/test_st_automation_enrollments.py @@ -0,0 +1,30 @@ +"""Tests for the Automation Enrollments methods of :class:`~parsons.solidarity_tech.SolidarityTech`.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from requests_mock import Mocker + + from parsons.solidarity_tech import SolidarityTech + +ENDPOINT = "automation_enrollments" + + +class TestEnrollUserInAutomation: + def test_enroll_user_in_automation(self, st: SolidarityTech, requests_mock: Mocker) -> None: + """Verify that :meth:`~parsons.solidarity_tech.SolidarityTech.enroll_user_in_automation` makes the appropriate calls.""" + automation_id = 2598823 + user_id = 1191722 + endpoint_url = f"{st.api_url}{ENDPOINT}" + _ = requests_mock.post(endpoint_url, status_code=201, json={"data": {}}) + + _ = st.enroll_user_in_automation(automation_id=automation_id, user_id=user_id) + + assert requests_mock.call_count == 1 + assert requests_mock.last_request is not None + assert requests_mock.last_request.method == "POST" + assert requests_mock.last_request.url == endpoint_url + assert requests_mock.last_request.json()["automation_id"] == automation_id + assert requests_mock.last_request.json()["user_id"] == user_id diff --git a/test/test_solidarity-tech/test_st_calls.py b/test/test_solidarity-tech/test_st_calls.py new file mode 100644 index 0000000000..92619497d9 --- /dev/null +++ b/test/test_solidarity-tech/test_st_calls.py @@ -0,0 +1,60 @@ +"""Tests for the Calls methods of :class:`~parsons.solidarity_tech.SolidarityTech`.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from parsons import Table + +if TYPE_CHECKING: + from requests_mock import Mocker + + from parsons.solidarity_tech import SolidarityTech + +ENDPOINT = "calls" + + +class TestGetCalls: + @pytest.mark.vcr + def test_get_calls_live(self, st: SolidarityTech) -> None: + """Verify that :meth:`~parsons.solidarity_tech.SolidarityTech.get_calls` returns both a Table of results and the associated metadata.""" + calls, calls_meta = st.get_calls() + + assert isinstance(calls, Table) + assert len(calls) > 0 + assert isinstance(calls[0], dict) + + assert isinstance(calls_meta, dict) + assert calls_meta["total_count"] > 0 + assert calls_meta["limit"] == 20 + assert calls_meta["offset"] == 0 + + def test_get_calls_minimal(self, st: SolidarityTech, requests_mock: Mocker) -> None: + """Verify that :meth:`~parsons.solidarity_tech.SolidarityTech.get_calls` makes the appropriate calls.""" + endpoint_url = f"{st.api_url}{ENDPOINT}?_limit=20&_offset=0&_since=0" + _ = requests_mock.get(endpoint_url, status_code=200, json={"data": [{}], "meta": {}}) + + _ = st.get_calls() + + assert requests_mock.call_count == 1 + assert requests_mock.last_request is not None + assert requests_mock.last_request.method == "GET" + assert requests_mock.last_request.url == endpoint_url + + def test_get_calls_maximal(self, st: SolidarityTech, requests_mock: Mocker) -> None: + """Verify that :meth:`~parsons.solidarity_tech.SolidarityTech.get_calls` makes the appropriate calls.""" + user_id = 1191722 + limit = 30 + offset = 5 + since = 1788075104 + endpoint_url = f"{st.api_url}{ENDPOINT}?_limit={limit}&_offset={offset}&_since={since}&user_id={user_id}" + _ = requests_mock.get(endpoint_url, status_code=200, json={"data": [{}], "meta": {}}) + + _ = st.get_calls(user_id=user_id, limit=limit, offset=offset, since=since) + + assert requests_mock.call_count == 1 + assert requests_mock.last_request is not None + assert requests_mock.last_request.method == "GET" + assert requests_mock.last_request.url == endpoint_url diff --git a/test/test_solidarity-tech/test_st_chapter_phone_numbers.py b/test/test_solidarity-tech/test_st_chapter_phone_numbers.py new file mode 100644 index 0000000000..76f7a61bbb --- /dev/null +++ b/test/test_solidarity-tech/test_st_chapter_phone_numbers.py @@ -0,0 +1,66 @@ +"""Tests for the Chapter Phone Numbers methods of :class:`~parsons.solidarity_tech.SolidarityTech`.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from parsons import Table + +if TYPE_CHECKING: + from requests_mock import Mocker + + from parsons.solidarity_tech import SolidarityTech + +ENDPOINT = "chapter_phone_numbers" + + +class TestGetChapterPhoneNumbers: + @pytest.mark.vcr + def test_get_chapter_phone_numbers_live(self, st: SolidarityTech) -> None: + """Verify that :meth:`~parsons.solidarity_tech.SolidarityTech.get_chapter_phone_numbers` returns both a Table of results and the associated metadata.""" + calls, calls_meta = st.get_chapter_phone_numbers() + + assert isinstance(calls, Table) + assert len(calls) > 0 + assert isinstance(calls[0], dict) + + assert isinstance(calls_meta, dict) + assert calls_meta["total_count"] > 0 + assert calls_meta["limit"] == 20 + assert calls_meta["offset"] == 0 + + def test_get_chapter_phone_numbers_minimal( + self, st: SolidarityTech, requests_mock: Mocker + ) -> None: + """Verify that :meth:`~parsons.solidarity_tech.SolidarityTech.get_chapter_phone_numbers` makes the appropriate calls.""" + endpoint_url = f"{st.api_url}{ENDPOINT}?_limit=20&_offset=0&_since=0" + _ = requests_mock.get(endpoint_url, status_code=200, json={"data": [{}], "meta": {}}) + + _ = st.get_chapter_phone_numbers() + + assert requests_mock.call_count == 1 + assert requests_mock.last_request is not None + assert requests_mock.last_request.method == "GET" + assert requests_mock.last_request.url == endpoint_url + + def test_get_chapter_phone_numbers_maximal( + self, st: SolidarityTech, requests_mock: Mocker + ) -> None: + """Verify that :meth:`~parsons.solidarity_tech.SolidarityTech.get_chapter_phone_numbers` makes the appropriate calls.""" + chapter_id = 982 + limit = 30 + offset = 5 + since = 1788075104 + endpoint_url = f"{st.api_url}{ENDPOINT}?_limit={limit}&_offset={offset}&_since={since}&chapter_id={chapter_id}" + _ = requests_mock.get(endpoint_url, status_code=200, json={"data": [{}], "meta": {}}) + + _ = st.get_chapter_phone_numbers( + chapter_id=chapter_id, limit=limit, offset=offset, since=since + ) + + assert requests_mock.call_count == 1 + assert requests_mock.last_request is not None + assert requests_mock.last_request.method == "GET" + assert requests_mock.last_request.url == endpoint_url diff --git a/test/test_utilities/test_api_connector.py b/test/test_utilities/test_api_connector.py index ff0857d7aa..46d34439ee 100644 --- a/test/test_utilities/test_api_connector.py +++ b/test/test_utilities/test_api_connector.py @@ -1,17 +1,27 @@ +import pyrate_limiter import pytest +import requests +import requests_ratelimiter +from requests.auth import HTTPBasicAuth +from requests.structures import CaseInsensitiveDict from requests_mock import Mocker from parsons.utilities.api_connector import APIConnector +EXAMPLE_URL = "https://api.example.com" +EXAMPLE_ENDPOINT = f"{EXAMPLE_URL}/test-endpoint" + @pytest.fixture def connector() -> APIConnector: + """Fixture that provides an APIConnector instance with a base URL and headers.""" return APIConnector( uri="https://api.example.com/v1", headers={"content-type": "application/json"} ) def test_init_adds_headers(connector: APIConnector, requests_mock: Mocker) -> None: + """Test that base headers are added to the session during initialization.""" requests_mock.get( "https://api.example.com/v1/data", json={"status": "authorized"}, status_code=200 ) @@ -23,7 +33,8 @@ def test_init_adds_headers(connector: APIConnector, requests_mock: Mocker) -> No assert req.headers["content-type"] == "application/json" -def test_request_with_additional_headers(connector: APIConnector, requests_mock) -> None: +def test_request_with_additional_headers(connector: APIConnector, requests_mock: Mocker) -> None: + """Test that additional headers and base headers are included in a request.""" requests_mock.get( "https://api.example.com/v1/data", json={"status": "authorized"}, status_code=200 ) @@ -36,23 +47,88 @@ def test_request_with_additional_headers(connector: APIConnector, requests_mock) req = requests_mock.last_request assert req is not None + assert req.headers["content-type"] == "application/json" assert req.headers["Authorization"] == "Bearer token123" assert req.headers["X-Custom-Header"] == "value" -def test_request_merges_base_and_additional_headers( - connector: APIConnector, requests_mock: Mocker -) -> None: - requests_mock.get("https://api.example.com/v1/data", json={}, status_code=200) +def test_init_loads_headers() -> None: + """Test that providing headers sets the base headers on the session.""" + headers = CaseInsensitiveDict({"authorization": "Bearer cz8on37ogn37vn9wg3n7gy29"}) + conn = APIConnector(uri=EXAMPLE_URL, headers=headers) + assert conn.session.headers == headers - connector.request( - "data", - "GET", - additional_headers={"Authorization": "Bearer token123", "X-Custom-Header": "value"}, + +def test_property_loads_headers() -> None: + """Test that providing headers via deprecated property sets the base headers on the session.""" + headers = CaseInsensitiveDict({"authorization": "Bearer n8hn9e4hme4h4"}) + conn = APIConnector(uri=EXAMPLE_URL) + conn.headers = headers + assert conn.session.headers == headers + + +def test_init_loads_auth() -> None: + """Test that providing auth object sets the auth on the session.""" + auth = HTTPBasicAuth("user_name", "user_pass") + conn = APIConnector(uri=EXAMPLE_URL, auth=auth) + assert conn.session.auth == auth + + +def test_property_loads_auth() -> None: + """Test that providing auth via deprecated property sets the base auth on the session.""" + auth = HTTPBasicAuth("user_name2", "user_pass2") + conn = APIConnector(uri=EXAMPLE_URL) + conn.auth = auth + assert conn.session.auth == auth + + +def test_init_accepts_ratelimit_as_limiter() -> None: + """Test that providing a :class:`requests_ratelimiter.Limiter` ratelimit creates a properly-configured :class:`requests_ratelimiter.LimiterSession`.""" + rate = pyrate_limiter.Rate(1, pyrate_limiter.Duration.MINUTE) + limiter = pyrate_limiter.Limiter(rate) + conn = APIConnector(uri=EXAMPLE_URL, ratelimit=limiter) + assert isinstance(conn.session, requests_ratelimiter.LimiterSession) + assert conn.session.limiter == limiter + + +def test_init_accepts_ratelimit_as_rate() -> None: + """Test that providing a :class:`requests_ratelimiter.Rate` ratelimit creates a properly-configured :class:`requests_ratelimiter.LimiterSession`.""" + rate = pyrate_limiter.Rate(1, pyrate_limiter.Duration.MINUTE) + conn = APIConnector(uri=EXAMPLE_URL, ratelimit=rate) + assert isinstance(conn.session, requests_ratelimiter.LimiterSession) + assert conn.session.limiter.buckets()[0]._rates[0] == rate + + +def test_init_accepts_ratelimit_as_int() -> None: + """Test that providing an integer ratelimit creates a properly-configured :class:`requests_ratelimiter.LimiterSession`.""" + rate_limit = 60 + conn = APIConnector(uri=EXAMPLE_URL, ratelimit=rate_limit) + assert isinstance(conn.session, requests_ratelimiter.LimiterSession) + assert isinstance(conn.session.limiter, pyrate_limiter.Limiter) + assert str(conn.session.limiter.buckets()[0]._rates[0]) == str( + pyrate_limiter.Rate(rate_limit, pyrate_limiter.Duration.SECOND) ) - req = requests_mock.last_request - assert req is not None - assert req.headers["content-type"] == "application/json" - assert req.headers["Authorization"] == "Bearer token123" - assert req.headers["X-Custom-Header"] == "value" + +def test_init_accepts_session() -> None: + """Test that providing a session overrides the default session.""" + session = requests.Session() + conn = APIConnector(uri=EXAMPLE_URL, session=session) + assert isinstance(conn.session, requests.Session) + assert conn.session == session + + +def test_init_does_not_accept_ratelimiter_and_session() -> None: + """Test that providing both a ratelimit and a session raises a ValueError.""" + rate = pyrate_limiter.Rate(1, pyrate_limiter.Duration.MINUTE) + limiter = pyrate_limiter.Limiter(rate) + session = requests.Session() + with pytest.raises(ValueError, match="session and ratelimit cannot both be provided"): + APIConnector(uri=EXAMPLE_URL, ratelimit=limiter, session=session) + + +def test_init_creates_regular_session() -> None: + """Test that the default session is a regular (non-limited) requests session.""" + conn = APIConnector(uri=EXAMPLE_URL) + assert isinstance(conn.session, requests.Session) + assert not isinstance(conn.session, requests_ratelimiter.LimiterSession)