diff --git a/_infra/helm/party/Chart.yaml b/_infra/helm/party/Chart.yaml index bc747f22..73060d41 100644 --- a/_infra/helm/party/Chart.yaml +++ b/_infra/helm/party/Chart.yaml @@ -14,8 +14,8 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. -version: 2.5.9 +version: 2.5.10 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. -appVersion: 2.5.9 +appVersion: 2.5.10 diff --git a/openapi.yaml b/openapi.yaml index fdd94e6e..92f74b69 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -11,6 +11,8 @@ tags: description: Respondent endpoints - name: businesses description: Business endpoints + - name: enrolments + description: enrolments endpoints - name: info description: Information endpoints - name: misc @@ -821,6 +823,43 @@ paths: description: The password reset counter has been reset 404: description: The respondent does not exist + /enrolments/respondent/{party_uuid}: + get: + tags: + - enrolments + summary: get enrolment details + description: returns a list of all enrolments that match given parameters + parameters: + - name: party_uuid + in: path + required: true + description: The UUID of the respondent + schema: + type: string + format: uuid + responses: + 200: + description: list of dict enrolments + content: + application/json: + schema: + type: array + items: + type: object + properties: + business_id: + type: string + format: uuid + status: + type: string + example: ENABLED + survey_id: + type: string + format: uuid + 400: + description: Missing of malformed parameters + 404: + description: Respondent doesn't exist /batch/respondents: delete: tags: diff --git a/ras_party/controllers/enrolments_controller.py b/ras_party/controllers/enrolments_controller.py new file mode 100644 index 00000000..2f2d21d6 --- /dev/null +++ b/ras_party/controllers/enrolments_controller.py @@ -0,0 +1,30 @@ +import logging +from uuid import UUID + +import structlog +from flask import session +from sqlalchemy.orm.exc import NoResultFound + +from ras_party.controllers.queries import ( + query_respondent_by_party_uuid, + query_respondent_enrolments, +) +from ras_party.models.models import Enrolment +from ras_party.support.session_decorator import with_query_only_db_session + +logger = structlog.wrap_logger(logging.getLogger(__name__)) + + +@with_query_only_db_session +def respondent_enrolments( + session: session, party_uuid: UUID, business_id: UUID = None, survey_id: UUID = None, status: int = None +) -> list[Enrolment]: + """ + returns a list of respondent Enrolments. Business_id, survey_id and status can also be added as conditions + """ + + respondent = query_respondent_by_party_uuid(party_uuid, session) + if not respondent: + raise NoResultFound + + return query_respondent_enrolments(session, respondent.id, business_id, survey_id, status) diff --git a/ras_party/controllers/queries.py b/ras_party/controllers/queries.py index 4e05315b..c58c9c21 100644 --- a/ras_party/controllers/queries.py +++ b/ras_party/controllers/queries.py @@ -643,3 +643,21 @@ def count_enrolment_by_survey_business(business_id, survey_id, session): .count() ) return response + + +def query_respondent_enrolments( + session: session, respondent_id: int, business_id: UUID = None, survey_id: UUID = None, status: int = None +) -> list[Enrolment]: + """ + Query to return a list of respondent Enrolments. Business_id, survey_id and status can also be added as conditions + """ + additional_conditions = [] + + if business_id: + additional_conditions.append(Enrolment.business_id == business_id) + if survey_id: + additional_conditions.append(Enrolment.survey_id == survey_id) + if status: + additional_conditions.append(Enrolment.status == status) + + return session.query(Enrolment).filter(and_(Enrolment.respondent_id == respondent_id, *additional_conditions)).all() diff --git a/ras_party/models/models.py b/ras_party/models/models.py index 325d9e03..08ffaa78 100644 --- a/ras_party/models/models.py +++ b/ras_party/models/models.py @@ -390,6 +390,13 @@ class Enrolment(Base): ), ) + def to_dict(self) -> dict: + return { + "business_id": self.business_id, + "survey_id": self.survey_id, + "status": self.status.name, + } + class PendingSurveys(Base): __tablename__ = "pending_surveys" diff --git a/ras_party/views/enrolments_view.py b/ras_party/views/enrolments_view.py new file mode 100644 index 00000000..b3223510 --- /dev/null +++ b/ras_party/views/enrolments_view.py @@ -0,0 +1,61 @@ +import logging +from uuid import UUID + +import structlog +from flask import Blueprint, Response, current_app, make_response, request +from flask_httpauth import HTTPBasicAuth +from sqlalchemy.exc import DataError +from sqlalchemy.orm.exc import NoResultFound +from werkzeug.exceptions import BadRequest, NotFound + +from ras_party.controllers.enrolments_controller import respondent_enrolments +from ras_party.uuid_helper import is_valid_uuid4 + +logger = structlog.wrap_logger(logging.getLogger(__name__)) +enrolments_view = Blueprint("enrolments_view", __name__) +auth = HTTPBasicAuth() + + +@enrolments_view.before_request +@auth.login_required +def before_respondent_view(): + pass + + +@auth.get_password +def get_pw(username): + config_username = current_app.config["SECURITY_USER_NAME"] + config_password = current_app.config["SECURITY_USER_PASSWORD"] + if username == config_username: + return config_password + + +@enrolments_view.route("/respondent/", methods=["GET"]) +def get_respondent_enrolments(party_uuid: UUID) -> Response: + json = request.get_json() + business_id = json.get("business_id") + survey_id = json.get("survey_id") + status = json.get("status") + + if not is_valid_uuid4(party_uuid): + logger.error(f"party_id not a valid uuid {party_uuid}") + return BadRequest() + + try: + enrolments = respondent_enrolments( + party_uuid=party_uuid, business_id=business_id, survey_id=survey_id, status=status + ) + except NoResultFound: + logger.error(f"Respondent not found for party_uuid {party_uuid}") + return NotFound() + except DataError: + logger.error( + "Data error, enrolment search parameters are not valid", + party_uuid=party_uuid, + business_id=business_id, + survey_id=survey_id, + status=status, + ) + return BadRequest() + + return make_response([enrolment.to_dict() for enrolment in enrolments], 200) diff --git a/run.py b/run.py index 9fe34d56..8bdb8a1e 100644 --- a/run.py +++ b/run.py @@ -29,6 +29,7 @@ def create_app(config=None): from ras_party.views.account_view import account_view from ras_party.views.batch_request import batch_request from ras_party.views.business_view import business_view + from ras_party.views.enrolments_view import enrolments_view from ras_party.views.info_view import info_view from ras_party.views.party_view import party_view from ras_party.views.pending_survey_view import pending_survey_view @@ -40,6 +41,7 @@ def create_app(config=None): app.register_blueprint(respondent_view, url_prefix="/party-api/v1") app.register_blueprint(batch_request, url_prefix="/party-api/v1") app.register_blueprint(pending_survey_view, url_prefix="/party-api/v1") + app.register_blueprint(enrolments_view, url_prefix="/party-api/v1/enrolments") app.register_blueprint(info_view) app.register_blueprint(error_handlers.blueprint) diff --git a/test/party_client.py b/test/party_client.py index c18fcb5b..7a008ce0 100644 --- a/test/party_client.py +++ b/test/party_client.py @@ -444,3 +444,9 @@ def get_respondents_by_survey_and_business_id(self, survey_id, business_id): def get_respondents_by_party_id(self, party_id): response = self.client.get(f"/party-api/v1/respondents/party_id/{party_id}", headers=self.auth_headers) return response + + def get_respondent_enrolments(self, party_id, payload={}): + response = self.client.get( + f"/party-api/v1/enrolments/respondent/{party_id}", json=payload, headers=self.auth_headers + ) + return response diff --git a/test/test_enrolments_controller.py b/test/test_enrolments_controller.py new file mode 100644 index 00000000..94665af9 --- /dev/null +++ b/test/test_enrolments_controller.py @@ -0,0 +1,123 @@ +from test.party_client import PartyTestClient + +from sqlalchemy.exc import DataError +from sqlalchemy.orm.exc import NoResultFound + +from ras_party.controllers.enrolments_controller import respondent_enrolments +from ras_party.models.models import ( + Business, + BusinessRespondent, + Enrolment, + EnrolmentStatus, + Respondent, +) +from ras_party.support.session_decorator import with_db_session + +respondents_enrolments = [ + { + "respondent": "b6f9d6e8-b840-4c95-a6ce-9ef145dd1f85", + "enrolment_details": [ + { + "business": "75d9af56-1225-4d43-b41d-1199f5f89daa", + "survey_id": "9200d295-9d6e-41fe-b541-747ae67a279f", + "status": EnrolmentStatus.ENABLED, + }, + { + "business": "98e2c9dd-a760-47dd-ba18-439fd5fb93a3", + "survey_id": "c641f6ad-a5eb-4d82-a647-7cd586549bbc", + "status": EnrolmentStatus.ENABLED, + }, + ], + }, + { + "respondent": "5718649e-30bf-4c25-a2c0-aaa733e54ed6", + "enrolment_details": [ + { + "business": "af25c9d5-6893-4342-9d24-4b88509e965f", + "survey_id": "9200d295-9d6e-41fe-b541-747ae67a279f", + "status": EnrolmentStatus.ENABLED, + }, + { + "business": "75d9af56-1225-4d43-b41d-1199f5f89daa", + "survey_id": "9200d295-9d6e-41fe-b541-747ae67a279f", + "status": EnrolmentStatus.DISABLED, + }, + ], + }, +] + + +class TestEnrolments(PartyTestClient): + + def setUp(self): + self._add_enrolments() + + def test_get_enrolments_party_id(self): + enrolments = respondent_enrolments(party_uuid="b6f9d6e8-b840-4c95-a6ce-9ef145dd1f85") + + self.assertEqual(len(enrolments), 2) + self.assertEqual(str(enrolments[0].business_id), "75d9af56-1225-4d43-b41d-1199f5f89daa") + self.assertEqual(str(enrolments[1].business_id), "98e2c9dd-a760-47dd-ba18-439fd5fb93a3") + + def test_get_enrolments_party_id_and_business_id_and_survey_id(self): + enrolments = respondent_enrolments( + party_uuid="b6f9d6e8-b840-4c95-a6ce-9ef145dd1f85", + business_id="75d9af56-1225-4d43-b41d-1199f5f89daa", + survey_id="9200d295-9d6e-41fe-b541-747ae67a279f", + ) + + self.assertEqual(len(enrolments), 1) + self.assertEqual(str(enrolments[0].respondent_id), "1") + self.assertEqual(str(enrolments[0].business_id), "75d9af56-1225-4d43-b41d-1199f5f89daa") + self.assertEqual(str(enrolments[0].survey_id), "9200d295-9d6e-41fe-b541-747ae67a279f") + + def test_get_enrolments_party_id_enabled(self): + enrolments = respondent_enrolments( + party_uuid="5718649e-30bf-4c25-a2c0-aaa733e54ed6", status=EnrolmentStatus.ENABLED + ) + + self.assertEqual(len(enrolments), 1) + self.assertEqual(str(enrolments[0].business_id), "af25c9d5-6893-4342-9d24-4b88509e965f") + self.assertEqual(str(enrolments[0].survey_id), "9200d295-9d6e-41fe-b541-747ae67a279f") + + def test_get_enrolments_party_id_disabled(self): + enrolments = respondent_enrolments( + party_uuid="5718649e-30bf-4c25-a2c0-aaa733e54ed6", status=EnrolmentStatus.DISABLED + ) + + self.assertEqual(len(enrolments), 1) + self.assertEqual(str(enrolments[0].business_id), "75d9af56-1225-4d43-b41d-1199f5f89daa") + self.assertEqual(str(enrolments[0].survey_id), "9200d295-9d6e-41fe-b541-747ae67a279f") + + def test_get_enrolments_party_id_not_found_respondent(self): + with self.assertRaises(NoResultFound): + respondent_enrolments(party_uuid="e6a016da-f7e8-4cb0-88da-9d34a7c1382a") + + def test_get_enrolments_party_id_data_error(self): + with self.assertRaises(DataError): + respondent_enrolments(party_uuid="malformed_id") + + @with_db_session + def _add_enrolments(self, session): + businesses = {} + + for respondent_enrolment in respondents_enrolments: + respondent = Respondent(party_uuid=respondent_enrolment["respondent"]) + session.add(respondent) + + for enrolment in respondent_enrolment["enrolment_details"]: + if not (business := businesses.get(enrolment["business"])): + business = Business(party_uuid=enrolment["business"]) + session.add(business) + businesses[enrolment["business"]] = business + + business_respondent = BusinessRespondent(business=business, respondent=respondent) + session.add(business_respondent) + session.flush() + enrolment = Enrolment( + business_id=business.party_uuid, + survey_id=enrolment["survey_id"], + respondent_id=respondent.id, + status=enrolment["status"], + ) + session.add(enrolment) diff --git a/test/test_enrolments_view.py b/test/test_enrolments_view.py new file mode 100644 index 00000000..40764bef --- /dev/null +++ b/test/test_enrolments_view.py @@ -0,0 +1,52 @@ +import json +from test.party_client import PartyTestClient +from unittest.mock import patch + +from sqlalchemy.exc import DataError +from sqlalchemy.orm.exc import NoResultFound + +from ras_party.models.models import Enrolment, EnrolmentStatus + + +class TestEnrolmentsView(PartyTestClient): + + @patch("ras_party.views.enrolments_view.respondent_enrolments") + def test_get_enrolments(self, respondent_enrolments): + respondent_enrolments.return_value = [ + Enrolment( + business_id="79af714a-ee1d-446c-9f39-763296ec1f05", + survey_id="38553552-7d08-42e4-b86b-06f158c4b95e", + respondent_id=1, + status=EnrolmentStatus.ENABLED, + ) + ] + response = self.get_respondent_enrolments("b146f595-62a0-4d6d-ba88-ef40cffdf8a7") + + expected_response = [ + { + "business_id": "79af714a-ee1d-446c-9f39-763296ec1f05", + "survey_id": "38553552-7d08-42e4-b86b-06f158c4b95e", + "status": "ENABLED", + } + ] + + self.assertEqual(expected_response, json.loads(response.data)) + + @patch("ras_party.views.enrolments_view.respondent_enrolments") + def test_get_enrolments_not_found_respondent(self, respondent_enrolments): + respondent_enrolments.side_effect = NoResultFound + response = self.get_respondent_enrolments("707778b9-cdb0-467a-9585-ee06bca47e2c") + + self.assertEqual(404, response.status_code) + + @patch("ras_party.views.enrolments_view.respondent_enrolments") + def test_get_enrolments_data_error(self, respondent_enrolments): + respondent_enrolments.side_effect = DataError("InvalidTextRepresentation", "party_uuid", "orig") + response = self.get_respondent_enrolments("malformed_id") + + self.assertEqual(400, response.status_code) + + def test_get_enrolments_no_params(self): + response = self.get_respondent_enrolments({}) + + self.assertEqual(400, response.status_code)