diff --git a/.changes/unreleased/Added-20240208-002559.yaml b/.changes/unreleased/Added-20240208-002559.yaml new file mode 100644 index 00000000..a3826e3e --- /dev/null +++ b/.changes/unreleased/Added-20240208-002559.yaml @@ -0,0 +1,6 @@ +kind: Added +body: Added a `Session.call` method to allow calling RPC methods without any error + handling to get the raw response +time: 2024-02-08T00:25:59.164455-06:00 +custom: + Issue: "1092" diff --git a/.changes/unreleased/Documentation-20240208-002649.yaml b/.changes/unreleased/Documentation-20240208-002649.yaml new file mode 100644 index 00000000..f3387a59 --- /dev/null +++ b/.changes/unreleased/Documentation-20240208-002649.yaml @@ -0,0 +1,5 @@ +kind: Documentation +body: Linked unimplemented methods to `Session.call` examples +time: 2024-02-08T00:26:49.465352-06:00 +custom: + Issue: "1092" diff --git a/code_samples/session_attr.py b/code_samples/session_attr.py index c87c979d..5b82125d 100644 --- a/code_samples/session_attr.py +++ b/code_samples/session_attr.py @@ -11,6 +11,9 @@ "secret", ) -# Call the not_available_in_client method, not available in the Client -new_survey_id = client.session.not_available_in_client(35239, "copied_survey") +# Get the raw response from mail_registered_participants +result = client.session.call("mail_registered_participants", 35239) + +# Get the raw response from remind_participants +result = client.session.call("remind_participants", 35239) # end example diff --git a/docs/notebooks/duckdb.ipynb b/docs/notebooks/duckdb.ipynb index dbc53edf..919b3fb2 100644 --- a/docs/notebooks/duckdb.ipynb +++ b/docs/notebooks/duckdb.ipynb @@ -611,7 +611,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.4" + "version": "3.12.1" } }, "nbformat": 4, diff --git a/docs/rpc_coverage.md b/docs/rpc_coverage.md index e2de1a22..64d72c86 100644 --- a/docs/rpc_coverage.md +++ b/docs/rpc_coverage.md @@ -2,61 +2,61 @@ Full list of methods is available at [the Remote Control documentation](https://api.limesurvey.org/classes/remotecontrol_handle.html). -| Name | Implemented | Description | -| :----------------------------- | :---------------------------------------------- | :-------------------------------------------------------------------------------- | -| `activate_survey` | [Yes](citric.Client.activate_survey) | Activate survey (RPC function) | -| `activate_tokens` | [Yes](citric.Client.activate_tokens) | Activate survey participants (RPC function) | -| `add_group` | [Yes](citric.Client.add_group) | Add empty group with minimum details (RPC function) | -| `add_language` | [Yes](citric.Client.add_language) | Add a survey language (RPC function) | -| `add_participants` | [Yes](citric.Client.add_participants) | Add participants to the survey. | -| `add_quota` | [Yes](citric.Client.add_quota) | Add a new quota with minimum details | -| `add_response` | [Yes](citric.Client.add_response) | Add a response to the survey responses collection. | -| `add_survey` | [Yes](citric.Client.add_survey) | Add an empty survey with minimum details | -| `copy_survey` | [Yes](citric.Client.copy_survey) | Copy survey (RPC function) | -| `cpd_importParticipants` | [Yes](citric.Client.import_cpdb_participants) | Import a participant into the LimeSurvey CPDB | -| `delete_group` | [Yes](citric.Client.delete_group) | Delete a group from a chosen survey (RPC function) | -| `delete_language` | [Yes](citric.Client.delete_language) | Delete a language from a survey (RPC function) | -| `delete_participants` | [Yes](citric.Client.delete_participants) | Delete multiple participants from the survey participants table (RPC function) | -| `delete_question` | [Yes](citric.Client.delete_question) | Delete question from a survey (RPC function) | -| `delete_quota` | [Yes](citric.Client.delete_quota) | Delete a quota | -| `delete_response` | [Yes](citric.Client.delete_response) | Delete a response in a given survey using its Id | -| `delete_survey` | [Yes](citric.Client.delete_survey) | Delete a survey. | -| `export_responses` | [Yes](citric.Client.export_responses) | Export responses in base64 encoded string | -| `export_responses_by_token` | [Yes](citric.Client.export_responses) | Export token response in a survey. | -| `export_statistics` | [Yes](citric.Client.export_statistics) | Export survey statistics (RPC function) | -| `export_timeline` | [Yes](citric.Client.export_timeline) | Export submission timeline (RPC function) | -| `get_available_site_settings` | [Yes](citric.Client.get_available_site_settings) | Get the available site settings | -| `get_fieldmap` | [Yes](citric.Client.get_fieldmap) | Returns the requested survey's fieldmap in an array | -| `get_group_properties` | [Yes](citric.Client.get_group_properties) | Get the properties of a group of a survey . | -| `get_language_properties` | [Yes](citric.Client.get_language_properties) | Get survey language properties (RPC function) | -| `get_participant_properties` | [Yes](citric.Client.get_participant_properties) | Get settings of a survey participant (RPC function) | -| `get_question_properties` | [Yes](citric.Client.get_question_properties) | Get properties of a question in a survey. | -| `get_quota_properties` | [Yes](citric.Client.get_quota_properties) | Get quota attributes (RPC function) | -| `get_response_ids` | [Yes](citric.Client.get_response_ids) | Find response IDs given a survey ID and a token (RPC function) | -| `get_session_key` | [Yes](Session) | Create and return a session key. | -| `get_site_settings` | [Yes](citric.Client.get_default_theme) | Get a global setting | -| `get_summary` | [Yes](citric.Client.get_summary) | Get survey summary, regarding token usage and survey participation (RPC function) | -| `get_survey_properties` | [Yes](citric.Client.get_survey_properties) | Get survey properties (RPC function) | -| `get_uploaded_files` | [Yes](citric.Client.get_uploaded_files) | Obtain all uploaded files for all responses | -| `import_group` | [Yes](citric.Client.import_group) | Import a group and add to a survey (RPC function) | -| `import_question` | [Yes](citric.Client.import_question) | Import question (RPC function) | -| `import_survey` | [Yes](citric.Client.import_survey) | Import survey in a known format (RPC function) | -| `invite_participants` | [Yes](citric.Client.invite_participants) | Invite participants in a survey (RPC function) | -| `list_groups` | [Yes](citric.Client.list_groups) | Get survey groups (RPC function) | -| `list_participants` | [Yes](citric.Client.list_participants) | Return the IDs and properties of survey participants (RPC function) | -| `list_questions` | [Yes](citric.Client.list_questions) | Return the ids and info of (sub-)questions of a survey/group (RPC function) | -| `list_quotas` | [Yes](citric.Client.list_quotas) | List the quotas in a survey | -| `list_survey_groups` | [Yes](citric.Client.list_survey_groups) | List the survey groups belonging to a user | -| `list_surveys` | [Yes](citric.Client.list_surveys) | List the survey belonging to a user (RPC function) | -| `list_users` | [Yes](citric.Client.list_users) | Get list the ids and info of administration user(s) (RPC function) | -| `mail_registered_participants` | No | Send e-mails to registered participants in a survey (RPC function) | -| `release_session_key` | [Yes](Session.close) | Close the RPC session | -| `remind_participants` | No | Send a reminder to participants in a survey (RPC function) | -| `set_group_properties` | [Yes](citric.Client.set_group_properties) | Set group properties (RPC function) | -| `set_language_properties` | [Yes](citric.Client.set_language_properties) | Set survey language properties (RPC function) | -| `set_participant_properties` | [Yes](citric.Client.set_participant_properties) | Set properties of a survey participant (RPC function) | -| `set_question_properties` | [Yes](citric.Client.set_question_properties) | Set question properties. | -| `set_quota_properties` | [Yes](citric.Client.set_quota_properties) | Set quota attributes (RPC function) | -| `set_survey_properties` | [Yes](citric.Client.set_survey_properties) | Set survey properties (RPC function) | -| `update_response` | [Yes](citric.Client.update_response) | Update a response in a given survey. | -| `upload_file` | [Yes](citric.Client.upload_file) | Uploads one file to be used later. | +| Name | Implemented | Description | +| :----------------------------- | :------------------------------------------------------------------ | :-------------------------------------------------------------------------------- | +| `activate_survey` | [Yes](citric.Client.activate_survey) | Activate survey (RPC function) | +| `activate_tokens` | [Yes](citric.Client.activate_tokens) | Activate survey participants (RPC function) | +| `add_group` | [Yes](citric.Client.add_group) | Add empty group with minimum details (RPC function) | +| `add_language` | [Yes](citric.Client.add_language) | Add a survey language (RPC function) | +| `add_participants` | [Yes](citric.Client.add_participants) | Add participants to the survey. | +| `add_quota` | [Yes](citric.Client.add_quota) | Add a new quota with minimum details | +| `add_response` | [Yes](citric.Client.add_response) | Add a response to the survey responses collection. | +| `add_survey` | [Yes](citric.Client.add_survey) | Add an empty survey with minimum details | +| `copy_survey` | [Yes](citric.Client.copy_survey) | Copy survey (RPC function) | +| `cpd_importParticipants` | [Yes](citric.Client.import_cpdb_participants) | Import a participant into the LimeSurvey CPDB | +| `delete_group` | [Yes](citric.Client.delete_group) | Delete a group from a chosen survey (RPC function) | +| `delete_language` | [Yes](citric.Client.delete_language) | Delete a language from a survey (RPC function) | +| `delete_participants` | [Yes](citric.Client.delete_participants) | Delete multiple participants from the survey participants table (RPC function) | +| `delete_question` | [Yes](citric.Client.delete_question) | Delete question from a survey (RPC function) | +| `delete_quota` | [Yes](citric.Client.delete_quota) | Delete a quota | +| `delete_response` | [Yes](citric.Client.delete_response) | Delete a response in a given survey using its Id | +| `delete_survey` | [Yes](citric.Client.delete_survey) | Delete a survey. | +| `export_responses` | [Yes](citric.Client.export_responses) | Export responses in base64 encoded string | +| `export_responses_by_token` | [Yes](citric.Client.export_responses) | Export token response in a survey. | +| `export_statistics` | [Yes](citric.Client.export_statistics) | Export survey statistics (RPC function) | +| `export_timeline` | [Yes](citric.Client.export_timeline) | Export submission timeline (RPC function) | +| `get_available_site_settings` | [Yes](citric.Client.get_available_site_settings) | Get the available site settings | +| `get_fieldmap` | [Yes](citric.Client.get_fieldmap) | Returns the requested survey's fieldmap in an array | +| `get_group_properties` | [Yes](citric.Client.get_group_properties) | Get the properties of a group of a survey . | +| `get_language_properties` | [Yes](citric.Client.get_language_properties) | Get survey language properties (RPC function) | +| `get_participant_properties` | [Yes](citric.Client.get_participant_properties) | Get settings of a survey participant (RPC function) | +| `get_question_properties` | [Yes](citric.Client.get_question_properties) | Get properties of a question in a survey. | +| `get_quota_properties` | [Yes](citric.Client.get_quota_properties) | Get quota attributes (RPC function) | +| `get_response_ids` | [Yes](citric.Client.get_response_ids) | Find response IDs given a survey ID and a token (RPC function) | +| `get_session_key` | [Yes](Session) | Create and return a session key. | +| `get_site_settings` | [Yes](citric.Client.get_default_theme) | Get a global setting | +| `get_summary` | [Yes](citric.Client.get_summary) | Get survey summary, regarding token usage and survey participation (RPC function) | +| `get_survey_properties` | [Yes](citric.Client.get_survey_properties) | Get survey properties (RPC function) | +| `get_uploaded_files` | [Yes](citric.Client.get_uploaded_files) | Obtain all uploaded files for all responses | +| `import_group` | [Yes](citric.Client.import_group) | Import a group and add to a survey (RPC function) | +| `import_question` | [Yes](citric.Client.import_question) | Import question (RPC function) | +| `import_survey` | [Yes](citric.Client.import_survey) | Import survey in a known format (RPC function) | +| `invite_participants` | [Yes](citric.Client.invite_participants) | Invite participants in a survey (RPC function) | +| `list_groups` | [Yes](citric.Client.list_groups) | Get survey groups (RPC function) | +| `list_participants` | [Yes](citric.Client.list_participants) | Return the IDs and properties of survey participants (RPC function) | +| `list_questions` | [Yes](citric.Client.list_questions) | Return the ids and info of (sub-)questions of a survey/group (RPC function) | +| `list_quotas` | [Yes](citric.Client.list_quotas) | List the quotas in a survey | +| `list_survey_groups` | [Yes](citric.Client.list_survey_groups) | List the survey groups belonging to a user | +| `list_surveys` | [Yes](citric.Client.list_surveys) | List the survey belonging to a user (RPC function) | +| `list_users` | [Yes](citric.Client.list_users) | Get list the ids and info of administration user(s) (RPC function) | +| `mail_registered_participants` | [No](how-to.md#use-the-session-attribute-for-low-level-interaction) | Send e-mails to registered participants in a survey (RPC function) | +| `release_session_key` | [Yes](Session.close) | Close the RPC session | +| `remind_participants` | [No](how-to.md#use-the-session-attribute-for-low-level-interaction) | Send a reminder to participants in a survey (RPC function) | +| `set_group_properties` | [Yes](citric.Client.set_group_properties) | Set group properties (RPC function) | +| `set_language_properties` | [Yes](citric.Client.set_language_properties) | Set survey language properties (RPC function) | +| `set_participant_properties` | [Yes](citric.Client.set_participant_properties) | Set properties of a survey participant (RPC function) | +| `set_question_properties` | [Yes](citric.Client.set_question_properties) | Set question properties. | +| `set_quota_properties` | [Yes](citric.Client.set_quota_properties) | Set quota attributes (RPC function) | +| `set_survey_properties` | [Yes](citric.Client.set_survey_properties) | Set survey properties (RPC function) | +| `update_response` | [Yes](citric.Client.update_response) | Update a response in a given survey. | +| `upload_file` | [Yes](citric.Client.upload_file) | Uploads one file to be used later. | diff --git a/src/citric/client.py b/src/citric/client.py index a56d3c84..a6ce0993 100644 --- a/src/citric/client.py +++ b/src/citric/client.py @@ -1682,6 +1682,8 @@ def invite_participants( ) -> int: """Invite participants to a survey. + Calls :rpc_method:`invite_participants`. + Args: survey_id: ID of the survey to invite participants to. token_ids: IDs of the participants to invite. diff --git a/src/citric/session.py b/src/citric/session.py index 3a410e1d..e9e81f1f 100644 --- a/src/citric/session.py +++ b/src/citric/session.py @@ -49,12 +49,15 @@ def handle_rpc_errors(result: Result, error: str | None) -> None: a non-null status. LimeSurveyApiError: The response payload has a non-null error key. """ - if isinstance(result, dict) and result.get("status") not in {"OK", None}: - raise LimeSurveyStatusError(result["status"]) - if error is not None: raise LimeSurveyApiError(error) + if not isinstance(result, dict): + return + + if result.get("status") not in {"OK", None}: + raise LimeSurveyStatusError(result["status"]) + class Session: """LimeSurvey RemoteControl 2 session. @@ -134,10 +137,8 @@ def __getattr__(self, name: str) -> Method[Result]: """Magic method dispatcher.""" return Method(self.rpc, name) - def rpc(self, method: str, *params: t.Any) -> Result: - """Execute RPC method on LimeSurvey, with optional token authentication. - - Any method, except for `get_session_key`. + def call(self, method: str, *params: t.Any) -> RPCResponse: + """Get the raw response from an RPC method. Args: method: Name of the method to call. @@ -152,11 +153,21 @@ def rpc(self, method: str, *params: t.Any) -> Result: # Methods requiring authentication return self._invoke(method, self.key, *params) - def _invoke( - self, - method: str, - *params: t.Any, - ) -> Result: + def rpc(self, method: str, *params: t.Any) -> Result: + """Execute a LimeSurvey RPC call with error handling. + + Args: + method: Name of the method to call. + params: Positional arguments of the RPC method. + + Returns: + An RPC result. + """ + response = self.call(method, *params) + handle_rpc_errors(response["result"], response["error"]) + return response["result"] + + def _invoke(self, method: str, *params: t.Any) -> RPCResponse: """Execute a LimeSurvey RPC with a JSON payload. Args: @@ -199,18 +210,13 @@ def _invoke( except json.JSONDecodeError as e: raise InvalidJSONResponseError from e - result = data["result"] - error = data["error"] - response_id = data["id"] logger.info("Invoked RPC method %s with ID %d", method, request_id) - handle_rpc_errors(result, error) - - if response_id != request_id: + if (response_id := data["id"]) != request_id: msg = f"Response ID {response_id} does not match request ID {request_id}" raise ResponseMismatchError(msg) - return result + return data def close(self) -> None: """Close RPC session. diff --git a/tests/conftest.py b/tests/conftest.py index 08c7ad39..88120dbb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -66,6 +66,13 @@ def pytest_addoption(parser: pytest.Parser): default=_from_env_var("LS_PASSWORD"), ) + parser.addoption( + "--mailhog-url", + action="store", + help="URL of the MailHog instance to test against.", + default=_from_env_var("MAILHOG_URL", "http://localhost:8025"), + ) + def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]): """Modify test collection.""" @@ -109,6 +116,12 @@ def integration_password(request: pytest.FixtureRequest) -> str: return request.config.getoption("--limesurvey-password") +@pytest.fixture(scope="session") +def integration_mailhog_url(request: pytest.FixtureRequest) -> str: + """MailHog URL.""" + return request.config.getoption("--mailhog-url") + + class LimeSurveyMockAdapter(BaseAdapter): """Requests adapter that mocks LSRC2 API calls.""" diff --git a/tests/fixtures.py b/tests/fixtures.py new file mode 100644 index 00000000..91a0faf3 --- /dev/null +++ b/tests/fixtures.py @@ -0,0 +1,20 @@ +"""MailHog API client.""" + +from __future__ import annotations + +import requests + + +class MailHogClient: + """MailHog API client.""" + + def __init__(self, base_url: str) -> None: + self.base_url = base_url + + def get_all(self) -> dict: + """Get all messages.""" + return requests.get(f"{self.base_url}/api/v2/messages", timeout=10).json() + + def delete(self) -> None: + """Delete all messages.""" + requests.delete(f"{self.base_url}/api/v1/messages", timeout=10) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 95b3e9d5..3ad91721 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -11,6 +11,7 @@ import citric from citric.exceptions import LimeSurveyStatusError +from tests.fixtures import MailHogClient @pytest.fixture(scope="session") @@ -53,3 +54,11 @@ def server_version(client: citric.Client) -> semver.Version: def database_version(client: citric.Client) -> int: """Get the LimeSurvey database schema version.""" return client.get_db_version() + + +@pytest.fixture +def mailhog(integration_mailhog_url: str) -> MailHogClient: + """Get the LimeSurvey database schema version.""" + client = MailHogClient(integration_mailhog_url) + client.delete() + return client diff --git a/tests/integration/test_rpc_client.py b/tests/integration/test_rpc_client.py index a5dedeaa..288bed5e 100644 --- a/tests/integration/test_rpc_client.py +++ b/tests/integration/test_rpc_client.py @@ -24,6 +24,8 @@ from faker import Faker from pytest_subtests import SubTests + from tests.fixtures import MailHogClient + NEW_SURVEY_NAME = "New Survey" @@ -752,3 +754,85 @@ def test_users(client: citric.Client): def test_survey_groups(client: citric.Client): """Test survey group methods.""" assert len(client.list_survey_groups()) == 1 + + +@pytest.mark.integration_test +def test_mail_registered_participants( + client: citric.Client, + survey_id: int, + participants: list[dict[str, str]], + mailhog: MailHogClient, + subtests: SubTests, +): + """Test mail_registered_participants.""" + client.activate_survey(survey_id) + client.activate_tokens(survey_id, [1, 2]) + client.add_participants( + survey_id, + participant_data=participants, + create_tokens=False, + ) + + with subtests.test(msg="No initial emails"): + assert mailhog.get_all()["total"] == 0 + + # `mail_registered_participants` returns a non-error status messages even when + # emails are sent successfully and that violates assumptions made by this + # library about the meaning of `status` messages + with pytest.raises( + LimeSurveyStatusError, + match="0 left to send", + ): + client.session.mail_registered_participants(survey_id) + + with subtests.test(msg="2 emails sent"): + assert mailhog.get_all()["total"] == 2 + + mailhog.delete() + + with pytest.raises( + LimeSurveyStatusError, + match="Error: No candidate tokens", + ): + client.session.mail_registered_participants(survey_id) + + with subtests.test(msg="No more emails sent"): + assert mailhog.get_all()["total"] == 0 + + +@pytest.mark.integration_test +def test_remind_participants( + client: citric.Client, + survey_id: int, + participants: list[dict[str, str]], + mailhog: MailHogClient, + subtests: SubTests, +): + """Test remind_participants.""" + client.activate_survey(survey_id) + client.activate_tokens(survey_id, [1, 2]) + client.add_participants( + survey_id, + participant_data=participants, + create_tokens=False, + ) + + with subtests.test(msg="No initial emails"): + assert mailhog.get_all()["total"] == 0 + + # Use `call` to avoid error handling + client.session.call("mail_registered_participants", survey_id) + + with subtests.test(msg="2 emails sent"): + assert mailhog.get_all()["total"] == 2 + + mailhog.delete() + + # `remind_participants` returns a non-error status messages even when emails are + # sent successfully and that violates assumptions made by this library about the + # meaning of `status` messages" + with pytest.raises(LimeSurveyStatusError, match="0 left to send"): + client.session.remind_participants(survey_id) + + with subtests.test(msg="2 reminders sent"): + assert mailhog.get_all()["total"] == 2