From ce34d48b7ef386bd240e60570ac0d4da8451067e Mon Sep 17 00:00:00 2001 From: Ratheesh kumar R <108045773+ratheesh-aot@users.noreply.github.com> Date: Thu, 25 Jan 2024 12:51:52 -0800 Subject: [PATCH] Deseng-463:Poll Widget: Back-end (#2363) * DESENG-463: Created models for Poll widget * DESENG-463: Resources, Services and Schema * DESENG-463: Wrapping up API changes * DESENG-463: Refactoring * DESENG-463: Poll API updates * DESENG-463 : Updated logics * DESENG-463 : Written unit test cases * Updated Change log * Solved pylint issues * fixing flake8 comments * Fixing lint issues * Fixing sonarcloud suggestions * DESENG-463: Fixing Review comments * Fixing lint --- CHANGELOG.MD | 4 + .../08f69642b7ae_adding_widget_poll.py | 83 ++++++++ met-api/src/met_api/models/__init__.py | 3 + met-api/src/met_api/models/poll_answers.py | 56 +++++ met-api/src/met_api/models/poll_responses.py | 46 +++++ met-api/src/met_api/models/widget_poll.py | 65 ++++++ met-api/src/met_api/resources/__init__.py | 2 + met-api/src/met_api/resources/widget_poll.py | 151 ++++++++++++++ .../schemas/schemas/poll_response.json | 22 ++ .../met_api/schemas/schemas/poll_widget.json | 80 ++++++++ .../schemas/schemas/poll_widget_update.json | 80 ++++++++ met-api/src/met_api/schemas/widget_poll.py | 42 ++++ .../met_api/services/poll_answers_service.py | 30 +++ .../met_api/services/poll_response_service.py | 50 +++++ .../met_api/services/widget_poll_service.py | 105 ++++++++++ met-api/src/met_api/utils/ip_util.py | 33 +++ met-api/tests/unit/api/test_widget_poll.py | 192 ++++++++++++++++++ met-api/tests/unit/models/test_engagement.py | 3 +- met-api/tests/unit/models/test_poll_answer.py | 75 +++++++ .../tests/unit/models/test_poll_response.py | 73 +++++++ met-api/tests/unit/models/test_widget_poll.py | 62 ++++++ .../services/test_poll_answers_service.py | 72 +++++++ .../unit/services/test_widget_poll_service.py | 164 +++++++++++++++ met-api/tests/utilities/factory_scenarios.py | 59 ++++++ met-api/tests/utilities/factory_utils.py | 46 ++++- 25 files changed, 1594 insertions(+), 4 deletions(-) create mode 100644 met-api/migrations/versions/08f69642b7ae_adding_widget_poll.py create mode 100644 met-api/src/met_api/models/poll_answers.py create mode 100644 met-api/src/met_api/models/poll_responses.py create mode 100644 met-api/src/met_api/models/widget_poll.py create mode 100644 met-api/src/met_api/resources/widget_poll.py create mode 100644 met-api/src/met_api/schemas/schemas/poll_response.json create mode 100644 met-api/src/met_api/schemas/schemas/poll_widget.json create mode 100644 met-api/src/met_api/schemas/schemas/poll_widget_update.json create mode 100644 met-api/src/met_api/schemas/widget_poll.py create mode 100644 met-api/src/met_api/services/poll_answers_service.py create mode 100644 met-api/src/met_api/services/poll_response_service.py create mode 100644 met-api/src/met_api/services/widget_poll_service.py create mode 100644 met-api/src/met_api/utils/ip_util.py create mode 100644 met-api/tests/unit/api/test_widget_poll.py create mode 100644 met-api/tests/unit/models/test_poll_answer.py create mode 100644 met-api/tests/unit/models/test_poll_response.py create mode 100644 met-api/tests/unit/models/test_widget_poll.py create mode 100644 met-api/tests/unit/services/test_poll_answers_service.py create mode 100644 met-api/tests/unit/services/test_widget_poll_service.py diff --git a/CHANGELOG.MD b/CHANGELOG.MD index cd126d663..0c7dcb001 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -4,6 +4,10 @@ - Removed the option to deploy to EAO. ## January 22, 2024 +- **Task** Poll Widget: Back-end [🎟️DESENG-463](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-463) + - Created Database models for Widget Poll, Poll Answers, Poll Response. + - Created API to manage Widget Poll, Poll Answers, Poll Response. + - Created Unit tests to test the code. - **Task** Add missing unit tests for met api [🎟️DESENG-481](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-481) - Added missing unit tests for met api - Added unit tests for error handling for met api diff --git a/met-api/migrations/versions/08f69642b7ae_adding_widget_poll.py b/met-api/migrations/versions/08f69642b7ae_adding_widget_poll.py new file mode 100644 index 000000000..4f87cbe95 --- /dev/null +++ b/met-api/migrations/versions/08f69642b7ae_adding_widget_poll.py @@ -0,0 +1,83 @@ +"""adding_widget_poll + +Revision ID: 08f69642b7ae +Revises: bd0eb0d25caf +Create Date: 2024-01-16 14:25:07.611485 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '08f69642b7ae' +down_revision = 'bd0eb0d25caf' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('widget_polls', + sa.Column('created_date', sa.DateTime(), nullable=False), + sa.Column('updated_date', sa.DateTime(), nullable=True), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('description', sa.String(length=2048), nullable=True), + sa.Column('status', sa.Enum('active', 'inactive', name='poll_status'), nullable=True), + sa.Column('widget_id', sa.Integer(), nullable=False), + sa.Column('engagement_id', sa.Integer(), nullable=False), + sa.Column('created_by', sa.String(length=50), nullable=True), + sa.Column('updated_by', sa.String(length=50), nullable=True), + sa.ForeignKeyConstraint(['engagement_id'], ['engagement.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['widget_id'], ['widget.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('poll_answers', + sa.Column('created_date', sa.DateTime(), nullable=False), + sa.Column('updated_date', sa.DateTime(), nullable=True), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('answer_text', sa.String(length=255), nullable=False), + sa.Column('poll_id', sa.Integer(), nullable=False), + sa.Column('created_by', sa.String(length=50), nullable=True), + sa.Column('updated_by', sa.String(length=50), nullable=True), + sa.ForeignKeyConstraint(['poll_id'], ['widget_polls.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('poll_responses', + sa.Column('created_date', sa.DateTime(), nullable=False), + sa.Column('updated_date', sa.DateTime(), nullable=True), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('participant_id', sa.String(length=255), nullable=False), + sa.Column('selected_answer_id', sa.Integer(), nullable=False), + sa.Column('poll_id', sa.Integer(), nullable=False), + sa.Column('widget_id', sa.Integer(), nullable=False), + sa.Column('is_deleted', sa.Boolean(), nullable=True), + sa.Column('created_by', sa.String(length=50), nullable=True), + sa.Column('updated_by', sa.String(length=50), nullable=True), + sa.ForeignKeyConstraint(['poll_id'], ['widget_polls.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['selected_answer_id'], ['poll_answers.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['widget_id'], ['widget.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + widget_type_table = sa.table('widget_type', + sa.Column('id', sa.Integer), + sa.Column('name', sa.String), + sa.Column('description', sa.String)) + + op.bulk_insert(widget_type_table, [ + {'id': 10, 'name': 'Poll', 'description': 'The Poll Widget enables real-time polling and feedback collection from public.'} + ]) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('poll_responses') + op.drop_table('poll_answers') + op.drop_table('widget_polls') + + conn = op.get_bind() + + conn.execute('DELETE FROM widget_type WHERE id=10') + # ### end Alembic commands ### diff --git a/met-api/src/met_api/models/__init__.py b/met-api/src/met_api/models/__init__.py index b61d5a5af..951c99073 100644 --- a/met-api/src/met_api/models/__init__.py +++ b/met-api/src/met_api/models/__init__.py @@ -51,3 +51,6 @@ from .cac_form import CACForm from .widget_timeline import WidgetTimeline from .timeline_event import TimelineEvent +from .widget_poll import Poll +from .poll_answers import PollAnswer +from .poll_responses import PollResponse diff --git a/met-api/src/met_api/models/poll_answers.py b/met-api/src/met_api/models/poll_answers.py new file mode 100644 index 000000000..0dcf71c8b --- /dev/null +++ b/met-api/src/met_api/models/poll_answers.py @@ -0,0 +1,56 @@ +""" +PollAnswers model class. + +Manages the Poll answers +""" +from __future__ import annotations + +from sqlalchemy.sql.schema import ForeignKey + +from .base_model import BaseModel +from .db import db + + +class PollAnswer(BaseModel): + """Definition of the PollAnswer entity.""" + + __tablename__ = 'poll_answers' + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + answer_text = db.Column(db.String(255), nullable=False) + poll_id = db.Column(db.Integer, ForeignKey('widget_polls.id', + ondelete='CASCADE'), nullable=False) + + @classmethod + def get_answers(cls, poll_id) -> list[PollAnswer]: + """Get answers for a poll.""" + session = db.session.query(PollAnswer) + return session.filter(PollAnswer.poll_id == poll_id).all() + + @classmethod + def update_answer(cls, answer_id, answer_data: dict) -> PollAnswer: + """Update an answer.""" + answer = PollAnswer.query.get(answer_id) + if answer: + for key, value in answer_data.items(): + setattr(answer, key, value) + answer.save() + return answer + + @classmethod + def delete_answers_by_poll_id(cls, poll_id): + """Delete answers.""" + poll_answers = db.session.query(PollAnswer).filter( + PollAnswer.poll_id == poll_id + ) + poll_answers.delete() + db.session.commit() + + @classmethod + def bulk_insert_answers(cls, poll_id, answers): + """Bulk insert answers for a poll.""" + answer_data = [ + {'poll_id': poll_id, 'answer_text': answer['answer_text']} + for answer in answers + ] + db.session.bulk_insert_mappings(PollAnswer, answer_data) + db.session.commit() diff --git a/met-api/src/met_api/models/poll_responses.py b/met-api/src/met_api/models/poll_responses.py new file mode 100644 index 000000000..08af03bb7 --- /dev/null +++ b/met-api/src/met_api/models/poll_responses.py @@ -0,0 +1,46 @@ +""" +PollResponse model class. + +Manages the Poll Responses +""" +from __future__ import annotations + +from sqlalchemy.sql.expression import false +from sqlalchemy.sql.schema import ForeignKey + +from .base_model import BaseModel +from .db import db + + +class PollResponse(BaseModel): + """Definition of the PollResponse entity.""" + + __tablename__ = 'poll_responses' + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + participant_id = db.Column(db.String(255), nullable=False) + selected_answer_id = db.Column(db.Integer, ForeignKey('poll_answers.id', ondelete='CASCADE'), nullable=False) + poll_id = db.Column(db.Integer, ForeignKey('widget_polls.id', ondelete='CASCADE'), nullable=False) + widget_id = db.Column(db.Integer, ForeignKey('widget.id', ondelete='CASCADE'), nullable=False) + is_deleted = db.Column(db.Boolean, default=False) + + @classmethod + def get_responses(cls, poll_id) -> list[PollResponse]: + """Get responses for a poll.""" + return db.session.query(PollResponse).filter(PollResponse.poll_id == poll_id).all() + + @classmethod + def get_responses_by_participant_id(cls, poll_id, participant_id) -> list[PollResponse]: + """Get responses for a poll.""" + return db.session.query(PollResponse).filter(PollResponse.poll_id == poll_id, + PollResponse.participant_id == participant_id, + PollResponse.is_deleted == false()).all() + + @classmethod + def update_response(cls, response_id, response_data: dict) -> PollResponse: + """Update a poll response.""" + response = PollResponse.query.get(response_id) + if response: + for key, value in response_data.items(): + setattr(response, key, value) + response.save() + return response diff --git a/met-api/src/met_api/models/widget_poll.py b/met-api/src/met_api/models/widget_poll.py new file mode 100644 index 000000000..bbc5e0b57 --- /dev/null +++ b/met-api/src/met_api/models/widget_poll.py @@ -0,0 +1,65 @@ +""" +WidgetPoll model class. + +Manages the Poll widget +""" +from __future__ import annotations + +from sqlalchemy import Enum +from sqlalchemy.sql.schema import ForeignKey + +from met_api.models.poll_answers import PollAnswer + +from .base_model import BaseModel +from .db import db + + +class Poll(BaseModel): + """Definition of the Poll entity.""" + + __tablename__ = 'widget_polls' + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + title = db.Column(db.String(255), nullable=False) + description = db.Column(db.String(2048), nullable=True) + status = db.Column( + Enum('active', 'inactive', name='poll_status'), default='inactive') + widget_id = db.Column(db.Integer, ForeignKey( + 'widget.id', ondelete='CASCADE'), nullable=False) + engagement_id = db.Column(db.Integer, ForeignKey( + 'engagement.id', ondelete='CASCADE'), nullable=False) + + # Relationship to timeline_event + answers = db.relationship(PollAnswer, backref='widget_poll', lazy=True) + + @classmethod + def create_poll(cls, widget_id: int, poll_data: dict) -> Poll: + """Create a new poll.""" + poll = cls() + poll.widget_id = widget_id + poll.title = poll_data.get('title') + poll.description = poll_data.get('description') + poll.status = poll_data.get('status', 'inactive') + poll.engagement_id = poll_data.get('engagement_id') + db.session.add(poll) + db.session.commit() + return poll + + @classmethod + def get_polls(cls, widget_id) -> list[Poll]: + """Get polls for a widget.""" + return db.session.query(Poll).filter(Poll.widget_id == widget_id).all() + + @classmethod + def update_poll(cls, poll_id, poll_data: dict) -> Poll: + """Update a poll and its answers.""" + poll: Poll = Poll.query.get(poll_id) + if poll: + # Update poll fields + for key in ['title', 'description', 'status', 'widget_id', + 'engagement_id']: + if key in poll_data: + setattr(poll, key, poll_data[key]) + + db.session.commit() + + return poll diff --git a/met-api/src/met_api/resources/__init__.py b/met-api/src/met_api/resources/__init__.py index 57db720bc..c40463a03 100644 --- a/met-api/src/met_api/resources/__init__.py +++ b/met-api/src/met_api/resources/__init__.py @@ -50,6 +50,7 @@ from .engagement_settings import API as ENGAGEMENT_SETTINGS_API from .cac_form import API as CAC_FORM_API from .widget_timeline import API as WIDGET_TIMELINE_API +from .widget_poll import API as WIDGET_POLL_API __all__ = ('API_BLUEPRINT',) @@ -91,3 +92,4 @@ API.add_namespace(ENGAGEMENT_SETTINGS_API) API.add_namespace(CAC_FORM_API, path='/engagements//cacform') API.add_namespace(WIDGET_TIMELINE_API, path='/widgets//timelines') +API.add_namespace(WIDGET_POLL_API, path='/widgets//polls') diff --git a/met-api/src/met_api/resources/widget_poll.py b/met-api/src/met_api/resources/widget_poll.py new file mode 100644 index 000000000..02dab70ed --- /dev/null +++ b/met-api/src/met_api/resources/widget_poll.py @@ -0,0 +1,151 @@ +"""API endpoints for managing a poll widget resource.""" +from http import HTTPStatus + +from flask import request +from flask_cors import cross_origin +from flask_restx import Namespace, Resource + +from met_api.auth import jwt as _jwt +from met_api.exceptions.business_exception import BusinessException +from met_api.schemas import utils as schema_utils +from met_api.schemas.widget_poll import WidgetPollSchema +from met_api.services.widget_poll_service import WidgetPollService +from met_api.utils.util import allowedorigins, cors_preflight +from met_api.utils.ip_util import hash_ip + +API = Namespace('widget_polls', description='Endpoints for Poll Widget Management') +INVALID_REQUEST_MESSAGE = 'Invalid request format' + + +@cors_preflight('GET, POST') +@API.route('') +class Polls(Resource): + """Resource for managing poll widgets.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + def get(widget_id): + """Get poll widgets.""" + try: + widget_poll = WidgetPollService().get_polls_by_widget_id(widget_id) + return WidgetPollSchema().dump(widget_poll, many=True), HTTPStatus.OK + except BusinessException as err: + return str(err), err.status_code + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + def post(widget_id): + """Create poll widget.""" + try: + request_json = request.get_json() + valid_format, errors = Polls.validate_request_format(request_json) + if not valid_format: + return {'message': INVALID_REQUEST_MESSAGE, 'errors': errors}, HTTPStatus.BAD_REQUEST + widget_poll = WidgetPollService().create_poll(widget_id, request_json) + return WidgetPollSchema().dump(widget_poll), HTTPStatus.OK + except BusinessException as err: + return str(err), err.status_code + + @staticmethod + def validate_request_format(data): + """Validate response format.""" + valid_format, errors = schema_utils.validate(data, 'poll_widget') + if not valid_format: + errors = schema_utils.serialize(errors) + return valid_format, errors + + +@cors_preflight('PATCH') +@API.route('/') +class Poll(Resource): + """Resource for managing poll widgets.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + def patch(widget_id, poll_widget_id): + """Update poll widget.""" + try: + request_json = request.get_json() + valid_format, errors = Poll.validate_request_format(request_json) + if not valid_format: + return {'message': INVALID_REQUEST_MESSAGE, 'errors': errors}, HTTPStatus.BAD_REQUEST + + widget_poll = WidgetPollService().update_poll(widget_id, poll_widget_id, request_json) + return WidgetPollSchema().dump(widget_poll), HTTPStatus.OK + except BusinessException as err: + return str(err), err.status_code + + @staticmethod + def validate_request_format(data): + """Validate request format.""" + valid_format, errors = schema_utils.validate(data, 'poll_widget_update') + if not valid_format: + errors = schema_utils.serialize(errors) + return valid_format, errors + + +@cors_preflight('POST') +@API.route('//responses') +class PollResponseRecord(Resource): + """Resource for recording responses for a poll widget. Does not require authentication.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + def post(widget_id, poll_widget_id): + """Record a response for a given poll widget.""" + try: + response_data = request.get_json() + valid_format, errors = PollResponseRecord.validate_request_format(response_data) + if not valid_format: + return {'message': INVALID_REQUEST_MESSAGE, 'errors': errors}, HTTPStatus.BAD_REQUEST + + response_dict = PollResponseRecord.prepare_response_data(response_data, widget_id, poll_widget_id) + + if not PollResponseRecord.is_poll_active(poll_widget_id): + return {'message': 'Poll is not active'}, HTTPStatus.BAD_REQUEST + + if PollResponseRecord.is_poll_limit_exceeded(poll_widget_id, response_dict['participant_id']): + return {'message': 'Limit exceeded for this poll'}, HTTPStatus.FORBIDDEN + + return PollResponseRecord.record_poll_response(response_dict) + + except BusinessException as err: + return err.error, err.status_code + + @staticmethod + def validate_request_format(data): + """Validate Request format.""" + valid_format, errors = schema_utils.validate(data, 'poll_response') + if not valid_format: + errors = schema_utils.serialize(errors) + return valid_format, errors + + @staticmethod + def prepare_response_data(data, widget_id, poll_widget_id): + """Prepare poll response object.""" + response_dict = dict(data) + response_dict['poll_id'] = poll_widget_id + response_dict['widget_id'] = widget_id + response_dict['participant_id'] = hash_ip(request.remote_addr) + return response_dict + + @staticmethod + def is_poll_active(poll_id): + """Check if poll active or not.""" + return WidgetPollService.is_poll_active(poll_id) + + @staticmethod + def is_poll_limit_exceeded(poll_id, participant_id): + """Check poll limit execeeded or not.""" + return WidgetPollService.check_already_polled(poll_id, participant_id, 10) + + @staticmethod + def record_poll_response(response_dict): + """Record poll respinse in database.""" + poll_response = WidgetPollService.record_response(response_dict) + if poll_response.id: + return {'message': 'Response recorded successfully'}, HTTPStatus.CREATED + + return {'message': 'Response failed to record'}, HTTPStatus.INTERNAL_SERVER_ERROR diff --git a/met-api/src/met_api/schemas/schemas/poll_response.json b/met-api/src/met_api/schemas/schemas/poll_response.json new file mode 100644 index 000000000..b2633720a --- /dev/null +++ b/met-api/src/met_api/schemas/schemas/poll_response.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://met.gov.bc.ca/.well_known/schemas/poll_response", + "type": "object", + "title": "The Poll Response Schema", + "description": "Schema for a poll response.", + "default": {}, + "examples": [ + { + "selected_answer_id": "20" + } + ], + "required": ["selected_answer_id"], + "properties": { + "selected_answer_id": { + "$id": "#/properties/selected_answer_id", + "type": "integer", + "title": "Selected Answer ID", + "description": "The ID of the selected answer in the poll." + } + } +} diff --git a/met-api/src/met_api/schemas/schemas/poll_widget.json b/met-api/src/met_api/schemas/schemas/poll_widget.json new file mode 100644 index 000000000..de94b9987 --- /dev/null +++ b/met-api/src/met_api/schemas/schemas/poll_widget.json @@ -0,0 +1,80 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://met.gov.bc.ca/.well_known/schemas/poll_widget", + "type": "object", + "title": "The root schema", + "description": "The root schema comprises the entire JSON document for a WidgetPoll.", + "default": {}, + "examples": [ + { + "title": "Favorite Programming Language", + "description": "A poll to determine the most popular programming language among our users.", + "status": "active", + "widget_id": 6, + "engagement_id": 7, + "answers": [ + { + "answer_text": "Python" + }, + { + "answer_text": "Java" + }, + { + "answer_text": "JavaScript" + }, + { + "answer_text": "C#" + } + ] + } + ], + "required": ["title", "description", "status", "widget_id", "engagement_id", "answers"], + "properties": { + "title": { + "$id": "#/properties/title", + "type": "string", + "title": "Poll title", + "description": "The title of the poll." + }, + "description": { + "$id": "#/properties/description", + "type": "string", + "title": "Poll description", + "description": "The description of the poll." + }, + "status": { + "$id": "#/properties/status", + "type": "string", + "title": "Poll status", + "description": "The status of the poll (e.g., active, closed)." + }, + "widget_id": { + "$id": "#/properties/widget_id", + "type": "integer", + "title": "Widget ID", + "description": "The unique identifier for the widget." + }, + "engagement_id": { + "$id": "#/properties/engagement_id", + "type": "integer", + "title": "Engagement ID", + "description": "The unique identifier for the engagement." + }, + "answers": { + "$id": "#/properties/answers", + "type": "array", + "title": "Poll answers", + "description": "The list of answers for the poll.", + "items": { + "type": "object", + "required": ["answer_text"], + "properties": { + "answer_text": { + "type": "string", + "description": "The text of the poll answer." + } + } + } + } + } +} diff --git a/met-api/src/met_api/schemas/schemas/poll_widget_update.json b/met-api/src/met_api/schemas/schemas/poll_widget_update.json new file mode 100644 index 000000000..780223bde --- /dev/null +++ b/met-api/src/met_api/schemas/schemas/poll_widget_update.json @@ -0,0 +1,80 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://met.gov.bc.ca/.well_known/schemas/poll_widget_update", + "type": "object", + "title": "The root schema", + "description": "The root schema comprises the entire JSON document for a WidgetPoll.", + "default": {}, + "examples": [ + { + "title": "Favorite Programming Language", + "description": "A poll to determine the most popular programming language among our users.", + "status": "active", + "widget_id": 6, + "engagement_id": 7, + "answers": [ + { + "answer_text": "Python" + }, + { + "answer_text": "Java" + }, + { + "answer_text": "JavaScript" + }, + { + "answer_text": "C#" + } + ] + } + ], + "required": ["widget_id", "engagement_id"], + "properties": { + "title": { + "$id": "#/properties/title", + "type": "string", + "title": "Poll title", + "description": "The title of the poll." + }, + "description": { + "$id": "#/properties/description", + "type": "string", + "title": "Poll description", + "description": "The description of the poll." + }, + "status": { + "$id": "#/properties/status", + "type": "string", + "title": "Poll status", + "description": "The status of the poll (e.g., active, closed)." + }, + "widget_id": { + "$id": "#/properties/widget_id", + "type": "integer", + "title": "Widget ID", + "description": "The unique identifier for the widget." + }, + "engagement_id": { + "$id": "#/properties/engagement_id", + "type": "integer", + "title": "Engagement ID", + "description": "The unique identifier for the engagement." + }, + "answers": { + "$id": "#/properties/answers", + "type": "array", + "title": "Poll answers", + "description": "The list of answers for the poll.", + "items": { + "type": "object", + "required": ["answer_text"], + "properties": { + "answer_text": { + "type": "string", + "description": "The text of the poll answer." + } + } + } + } + } +} diff --git a/met-api/src/met_api/schemas/widget_poll.py b/met-api/src/met_api/schemas/widget_poll.py new file mode 100644 index 000000000..f3067519c --- /dev/null +++ b/met-api/src/met_api/schemas/widget_poll.py @@ -0,0 +1,42 @@ +"""Schema for Widget Poll.""" +from met_api.models.widget_poll import Poll as PollModel +from met_api.models.poll_answers import PollAnswer as PollAnswerModel +from marshmallow import Schema +from marshmallow_sqlalchemy.fields import Nested + + +class PollAnswerSchema(Schema): + """ + Schema for serializing and deserializing Poll Answer data. + + This schema is used to represent poll answers in a structured format, + facilitating operations like loading from and dumping to JSON. + """ + + class Meta: + """Meta class for PollAnswerSchema options.""" + + model = PollAnswerModel # The model representing Poll Answer. + fields = ('id', 'answer_text', 'poll_id') # Fields to include in the schema. + + +class WidgetPollSchema(Schema): + """ + Schema for serializing and deserializing Widget Poll data. + + This schema is designed to handle Widget Poll data, enabling easy conversion + between Python objects and JSON representation, specifically for Widget Polls. + """ + + class Meta: + """Meta class for WidgetPollSchema options.""" + + model = PollModel # The model representing Widget Poll. + fields = ('id', 'title', 'description', 'status', 'widget_id', 'engagement_id', 'answers') + + answers = Nested(PollAnswerSchema, many=True) + """Nested field for Poll Answers. + + This field represents a collection of Poll Answers associated with a Widget Poll, + allowing for the inclusion of related Poll Answer data within a Widget Poll's serialized form. + """ diff --git a/met-api/src/met_api/services/poll_answers_service.py b/met-api/src/met_api/services/poll_answers_service.py new file mode 100644 index 000000000..0eb0e4a1e --- /dev/null +++ b/met-api/src/met_api/services/poll_answers_service.py @@ -0,0 +1,30 @@ +"""Service for PollAnswer management.""" +from http import HTTPStatus + +from sqlalchemy.exc import SQLAlchemyError +from met_api.exceptions.business_exception import BusinessException +from met_api.models.poll_answers import PollAnswer as PollAnswerModel + + +class PollAnswerService: + """PollAnswer management service.""" + + @staticmethod + def get_poll_answer(poll_id): + """Get poll answer by poll id.""" + poll_answer = PollAnswerModel.get_answers(poll_id) + return poll_answer + + @staticmethod + def create_bulk_poll_answers(poll_id: int, answers_data: list): + """Bulk insert of poll answers.""" + try: + if len(answers_data) > 0: + PollAnswerModel.bulk_insert_answers(poll_id, answers_data) + except SQLAlchemyError as e: + raise BusinessException(str(e), HTTPStatus.INTERNAL_SERVER_ERROR) from e + + @staticmethod + def delete_poll_answers(poll_id: int): + """Delete poll answers for a given poll ID.""" + PollAnswerModel.delete_answers_by_poll_id(poll_id) diff --git a/met-api/src/met_api/services/poll_response_service.py b/met-api/src/met_api/services/poll_response_service.py new file mode 100644 index 000000000..4dcdaf554 --- /dev/null +++ b/met-api/src/met_api/services/poll_response_service.py @@ -0,0 +1,50 @@ +"""Service for Poll Response management.""" +from http import HTTPStatus +from sqlalchemy.exc import SQLAlchemyError +from met_api.exceptions.business_exception import BusinessException +from met_api.models.poll_responses import PollResponse as PollResponseModel +from met_api.services.poll_answers_service import PollAnswerService + + +class PollResponseService: + """Service for managing PollResponses.""" + + @staticmethod + def create_response(response_data: dict) -> PollResponseModel: + """ + Create a poll response. + + Raises ValueError if the selected answer is not valid for the poll. + """ + try: + poll_id = response_data.get('poll_id') + selected_answer_id = response_data.get('selected_answer_id') + + # Validate if the poll and answer are valid + valid_answers = PollAnswerService.get_poll_answer(poll_id) + if not any(answer.id == selected_answer_id for answer in valid_answers): + raise BusinessException('Invalid selected answer for the poll.', HTTPStatus.BAD_REQUEST) + + # Create and save the poll response + poll_response = PollResponseModel(**response_data) + poll_response.save() + return poll_response + except SQLAlchemyError as e: + # Log the exception or handle it as needed + raise BusinessException(f'Error creating poll response: {e}', + HTTPStatus.INTERNAL_SERVER_ERROR) from e + + @staticmethod + def get_poll_count(poll_id: int, ip_addr: str = None) -> int: + """ + Get the count of responses for a given poll. + + Optionally filters by participant IP. + """ + try: + responses = PollResponseModel.get_responses_by_participant_id(poll_id, ip_addr) + return len(responses) + except SQLAlchemyError as e: + # Log the exception or handle it as needed + raise BusinessException(f'Error creating poll response: {e}', + HTTPStatus.INTERNAL_SERVER_ERROR) from e diff --git a/met-api/src/met_api/services/widget_poll_service.py b/met-api/src/met_api/services/widget_poll_service.py new file mode 100644 index 000000000..36161daf5 --- /dev/null +++ b/met-api/src/met_api/services/widget_poll_service.py @@ -0,0 +1,105 @@ +"""Service for Widget Poll management.""" +from http import HTTPStatus + +from sqlalchemy.exc import SQLAlchemyError +from met_api.constants.membership_type import MembershipType +from met_api.exceptions.business_exception import BusinessException +from met_api.models.widget_poll import Poll as PollModel +from met_api.services import authorization +from met_api.services.poll_answers_service import PollAnswerService +from met_api.services.poll_response_service import PollResponseService +from met_api.utils.roles import Role + + +class WidgetPollService: + """Service for managing WidgetPolls.""" + + @staticmethod + def get_polls_by_widget_id(widget_id: int): + """Get polls by widget ID.""" + return PollModel.get_polls(widget_id) + + @staticmethod + def get_poll_by_id(poll_id: int): + """Get poll by poll ID.""" + poll = PollModel.query.get(poll_id) + if not poll: + raise BusinessException('Poll widget not found', HTTPStatus.NOT_FOUND) + return poll + + @staticmethod + def create_poll(widget_id: int, poll_details: dict): + """Create poll for the widget.""" + try: + eng_id = poll_details.get('engagement_id') + WidgetPollService._check_authorization(eng_id) + return WidgetPollService._create_poll_model(widget_id, poll_details) + except SQLAlchemyError as exc: + raise BusinessException(str(exc), HTTPStatus.BAD_REQUEST) from exc + + @staticmethod + def update_poll(widget_id: int, poll_widget_id: int, poll_data: dict): + """Update poll widget.""" + try: + widget_poll = WidgetPollService.get_poll_by_id(poll_widget_id) + WidgetPollService._check_authorization(widget_poll.engagement_id) + + if widget_poll.widget_id != widget_id: + raise BusinessException('Invalid widget ID', HTTPStatus.BAD_REQUEST) + + return WidgetPollService._update_poll_model(poll_widget_id, poll_data) + except SQLAlchemyError as exc: + raise BusinessException(str(exc), HTTPStatus.BAD_REQUEST) from exc + + @staticmethod + def record_response(response_data: dict): + """Record a response for a poll.""" + try: + return PollResponseService.create_response(response_data) + except SQLAlchemyError as exc: + raise BusinessException(str(exc), HTTPStatus.BAD_REQUEST) from exc + + @staticmethod + def check_already_polled(poll_id: int, ip_addr: str, count: int) -> bool: + """Check if an IP has already polled more than the given count.""" + try: + poll_count = PollResponseService.get_poll_count(poll_id, ip_addr) + return poll_count >= count + except SQLAlchemyError as exc: + raise BusinessException(str(exc), HTTPStatus.BAD_REQUEST) from exc + + @staticmethod + def is_poll_active(poll_id: int) -> bool: + """Check if the poll is active.""" + try: + poll = WidgetPollService.get_poll_by_id(poll_id) + return poll.status == 'active' + except SQLAlchemyError as exc: + raise BusinessException(str(exc), HTTPStatus.BAD_REQUEST) from exc + + @staticmethod + def _create_poll_model(widget_id: int, poll_data: dict): + """Private method to create poll model.""" + poll_model = PollModel.create_poll(widget_id, poll_data) + WidgetPollService._handle_poll_answers(poll_model.id, poll_data) + return poll_model + + @staticmethod + def _update_poll_model(poll_id: int, poll_data: dict): + """Private method to update poll model.""" + PollModel.update_poll(poll_id, poll_data) + WidgetPollService._handle_poll_answers(poll_id, poll_data) + return WidgetPollService.get_poll_by_id(poll_id) + + @staticmethod + def _check_authorization(engagement_id): + """Check user authorization.""" + authorization.check_auth(one_of_roles=(MembershipType.TEAM_MEMBER.name, Role.EDIT_ENGAGEMENT.value), + engagement_id=engagement_id) + + @staticmethod + def _handle_poll_answers(poll_id: int, poll_data: dict): + """Handle poll answers creation and deletion.""" + PollAnswerService.delete_poll_answers(poll_id) + answers_data = poll_data.get('answers', []) + PollAnswerService.create_bulk_poll_answers(poll_id, answers_data) diff --git a/met-api/src/met_api/utils/ip_util.py b/met-api/src/met_api/utils/ip_util.py new file mode 100644 index 000000000..38d9481da --- /dev/null +++ b/met-api/src/met_api/utils/ip_util.py @@ -0,0 +1,33 @@ +""" +This module provides utility functions for handling IP addresses in a Flask application. + +It includes a function for hashing IP addresses using SHA256, ensuring secure and consistent +hashing by combining each IP address with the Flask application's secret key. This approach +is typically used for anonymizing IP addresses while maintaining the ability to identify +sessions or users without storing their actual IP addresses. + +Functions: + hash_ip(ip_address): Hashes an IP address with the Flask secret key. +""" +from hashlib import sha256 +from flask import current_app + + +def hash_ip(ip_address): + """ + Hashes the given IP address concatenated with the Flask secret key. + + Args: + ip_address (str): The IP address to be hashed. + + Returns: + str: The resulting SHA256 hash as a hexadecimal string. + """ + # Retrieve the secret key from Flask configuration with a fallback empty string + secret_key = current_app.config.get('SECRET_KEY', '') + + # Extract the fragment (e.g., first three octets of an IPv4 address) + ip_fragment = '.'.join(ip_address.split('.')[:3]) + + # Concatenate the IP address and secret key, and hash the resulting string + return sha256(f'{ip_fragment}{secret_key}'.encode()).hexdigest() diff --git a/met-api/tests/unit/api/test_widget_poll.py b/met-api/tests/unit/api/test_widget_poll.py new file mode 100644 index 000000000..13ae08479 --- /dev/null +++ b/met-api/tests/unit/api/test_widget_poll.py @@ -0,0 +1,192 @@ +# Copyright © 2019 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests to verify the Widget Subscribe API end-point. + +Test-Suite to ensure that the Widget Subscribe API endpoint +is working as expected. +""" +import json +from http import HTTPStatus + +from faker import Faker + +from met_api.utils.enums import ContentType +from tests.utilities.factory_scenarios import TestJwtClaims, TestPollAnswerInfo, TestWidgetPollInfo +from tests.utilities.factory_utils import ( + factory_auth_header, factory_engagement_model, factory_poll_answer_model, factory_poll_model, factory_widget_model) + + +fake = Faker() + + +def test_get_widget(client, jwt, session): + """Assert that a get API endpoint is working as expected.""" + # Test setup: create a poll widget and a response model + headers = factory_auth_header(jwt=jwt, claims=TestJwtClaims.no_role) + engagement = factory_engagement_model() + widget = factory_widget_model({'engagement_id': engagement.id}) + poll = factory_poll_model(widget, TestWidgetPollInfo.poll1) + answer = factory_poll_answer_model(poll, TestPollAnswerInfo.answer1) + + # Sending POST request + rv = client.get( + f'/api/widgets/{widget.id}/polls', + headers=headers, + content_type=ContentType.JSON.value, + ) + + # Checking response + assert rv.status_code == HTTPStatus.OK + json_data = rv.json + assert len(json_data) > 0 + assert json_data[0]['title'] == poll.title + assert json_data[0]['answers'][0]['answer_text'] == answer.answer_text + + +def test_create_poll_widget(client, jwt, session, setup_admin_user_and_claims): + """Assert that a poll widget can be POSTed.""" + # Test setup: create a poll widget model + + _, claims = setup_admin_user_and_claims + headers = factory_auth_header(jwt=jwt, claims=claims) + + engagement = factory_engagement_model() + widget = factory_widget_model({'engagement_id': engagement.id}) + + poll_info = { + 'title': TestWidgetPollInfo.poll1.get('title'), + 'description': TestWidgetPollInfo.poll1.get('description'), + 'engagement_id': engagement.id, + 'status': TestWidgetPollInfo.poll1.get('status'), + 'widget_id': widget.id, + 'answers': [ + TestPollAnswerInfo.answer1.value, + TestPollAnswerInfo.answer2.value, + TestPollAnswerInfo.answer3.value + ] + } + + # Preparing data for POST request + data = { + **poll_info, + } + + # Sending POST request + rv = client.post( + f'/api/widgets/{widget.id}/polls', + data=json.dumps(data), + headers=headers, + content_type=ContentType.JSON.value, + ) + + # Checking response + assert rv.status_code == HTTPStatus.OK + assert rv.json.get('title') == poll_info.get('title') + + # testing Exceptions with wrong widget_id + + # Sending POST request + rv = client.post( + '/api/widgets/100/polls', + data=json.dumps(data), + headers=headers, + content_type=ContentType.JSON.value, + ) + + assert rv.status_code == HTTPStatus.BAD_REQUEST + + +def test_update_poll_widget(client, jwt, session, setup_admin_user_and_claims): + """Assert that a poll widget can be PATCHed.""" + # Test setup: create and post a poll widget model + _, claims = setup_admin_user_and_claims + headers = factory_auth_header(jwt=jwt, claims=claims) + + engagement = factory_engagement_model() + widget = factory_widget_model({'engagement_id': engagement.id}) + poll = factory_poll_model(widget, TestWidgetPollInfo.poll1) + + # Preparing data for PATCH request + data = { + 'title': 'Updated Title', + 'engagement_id': engagement.id, + 'widget_id': widget.id, + 'answers': [TestPollAnswerInfo.answer3.value] + } + + # Sending PATCH request + rv = client.patch( + f'/api/widgets/{widget.id}/polls/{poll.id}', + data=json.dumps(data), + headers=headers, + content_type=ContentType.JSON.value, + ) + + # Checking response + assert rv.status_code == HTTPStatus.OK + assert rv.json.get('title') == data.get('title') + assert len(rv.json.get('answers')) == 1 + + # testing Exceptions with wrong data + # Sending patch request + rv = client.patch( + f'/api/widgets/{widget.id}/polls/{poll.id}', + data=json.dumps({'title_wrong_key': 'Updated'}), + headers=headers, + content_type=ContentType.JSON.value, + ) + + assert rv.status_code == HTTPStatus.BAD_REQUEST + + +def test_record_poll_response(client, session, jwt): + """Assert that a response for a poll widget can be POSTed.""" + # Test setup: create a poll widget and a response model + headers = factory_auth_header(jwt=jwt, claims=TestJwtClaims.no_role) + engagement = factory_engagement_model() + widget = factory_widget_model({'engagement_id': engagement.id}) + poll = factory_poll_model(widget, TestWidgetPollInfo.poll1) + answer = factory_poll_answer_model(poll, TestPollAnswerInfo.answer1) + + # Preparing data for poll response + data = { + 'selected_answer_id': answer.id, + } + + # Sending POST request + rv = client.post( + f'/api/widgets/{widget.id}/polls/{poll.id}/responses', + data=json.dumps(data), + headers=headers, + content_type=ContentType.JSON.value, + ) + + # Checking response + assert rv.status_code == HTTPStatus.CREATED + assert rv.json.get('message') == 'Response recorded successfully' + + data = { + 'selected_answer_wrong_key': answer.id, + } + + # Sending POST request + rv = client.post( + f'/api/widgets/{widget.id}/polls/{poll.id}/responses', + data=json.dumps(data), + headers=headers, + content_type=ContentType.JSON.value, + ) + + assert rv.status_code == HTTPStatus.BAD_REQUEST diff --git a/met-api/tests/unit/models/test_engagement.py b/met-api/tests/unit/models/test_engagement.py index c6a1f41c2..157f13695 100644 --- a/met-api/tests/unit/models/test_engagement.py +++ b/met-api/tests/unit/models/test_engagement.py @@ -20,10 +20,11 @@ from met_api.constants.engagement_status import Status from met_api.models.engagement import Engagement as EngagementModel -from met_api.models.pagination_options import PaginationOptions from met_api.models.engagement_scope_options import EngagementScopeOptions +from met_api.models.pagination_options import PaginationOptions from tests.utilities.factory_utils import factory_engagement_model + fake = Faker() diff --git a/met-api/tests/unit/models/test_poll_answer.py b/met-api/tests/unit/models/test_poll_answer.py new file mode 100644 index 000000000..92a0a11d9 --- /dev/null +++ b/met-api/tests/unit/models/test_poll_answer.py @@ -0,0 +1,75 @@ +# Copyright © 2019 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for the Org model. + +Test suite to ensure that the Engagement model routines are working as expected. +""" + +from met_api.models.poll_answers import PollAnswer +from tests.utilities.factory_scenarios import TestPollAnswerInfo +from tests.utilities.factory_utils import ( + factory_engagement_model, factory_poll_answer_model, factory_poll_model, factory_widget_model) + + +def test_get_answers(session): + """Assert that answers for a poll can be fetched.""" + poll = _create_poll() + factory_poll_answer_model(poll, TestPollAnswerInfo.answer1) + factory_poll_answer_model(poll, TestPollAnswerInfo.answer2) + session.commit() + answers = PollAnswer.get_answers(poll.id) + assert len(answers) == 2 + + +def test_update_answer(session): + """Assert that an answer can be updated.""" + poll = _create_poll() + answer = factory_poll_answer_model(poll, TestPollAnswerInfo.answer1) + session.commit() + updated_text = 'Updated Answer' + updated_answer = PollAnswer.update_answer(answer.id, {'answer_text': updated_text}) + assert updated_answer.answer_text == updated_text + + +def test_delete_answers_by_poll_id(session): + """Assert that answers for a poll can be deleted.""" + poll = _create_poll() + factory_poll_answer_model(poll, TestPollAnswerInfo.answer1) + factory_poll_answer_model(poll, TestPollAnswerInfo.answer2) + session.commit() + PollAnswer.delete_answers_by_poll_id(poll.id) + answers = PollAnswer.get_answers(poll.id) + assert len(answers) == 0 + + +def test_bulk_insert_answers(session): + """Assert that answers can be bulk inserted for a poll.""" + poll = _create_poll() + answers_data = [{'answer_text': 'Answer 1'}, {'answer_text': 'Answer 2'}] + PollAnswer.bulk_insert_answers(poll.id, answers_data) + answers = PollAnswer.get_answers(poll.id) + assert len(answers) == 2 + + +def _create_poll(): + """Create and return a sample poll for testing.""" + widget = _create_widget() + return factory_poll_model(widget, {'title': 'Sample Poll', 'engagement_id': widget.engagement_id}) + + +def _create_widget(): + """Create and return a sample widget for testing.""" + engagement = factory_engagement_model() + widget = factory_widget_model({'engagement_id': engagement.id}) + return widget diff --git a/met-api/tests/unit/models/test_poll_response.py b/met-api/tests/unit/models/test_poll_response.py new file mode 100644 index 000000000..79e6319e4 --- /dev/null +++ b/met-api/tests/unit/models/test_poll_response.py @@ -0,0 +1,73 @@ +# Copyright © 2019 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for the Org model. + +Test suite to ensure that the Engagement model routines are working as expected. +""" + +from met_api.models.poll_responses import PollResponse +from tests.utilities.factory_scenarios import TestPollAnswerInfo, TestPollResponseInfo +from tests.utilities.factory_utils import ( + factory_engagement_model, factory_poll_answer_model, factory_poll_model, factory_poll_response_model, + factory_widget_model) + + +def test_get_responses(session): + """Assert that responses for a poll can be fetched.""" + poll, answer = _create_poll_answer() + factory_poll_response_model(poll, answer, TestPollResponseInfo.response1) + factory_poll_response_model(poll, answer, TestPollResponseInfo.response2) + session.commit() + responses = PollResponse.get_responses(poll.id) + assert len(responses) == 2 + + +def test_get_responses_by_participant_id(session): + """Assert that responses for a poll by a specific participant can be fetched.""" + poll, answer = _create_poll_answer() + poll_response1 = factory_poll_response_model(poll, answer, TestPollResponseInfo.response1) + + session.commit() + responses = PollResponse.get_responses_by_participant_id(poll.id, poll_response1.participant_id) + assert len(responses) > 0 + + +def test_update_or_delete_response(session): + """Assert that a poll response can be updated.""" + poll, answer = _create_poll_answer() + poll_response1 = factory_poll_response_model(poll, answer, TestPollResponseInfo.response1) + session.commit() + updated_data = {'is_deleted': True} + updated_response = PollResponse.update_response(poll_response1.id, updated_data) + assert updated_response.is_deleted is True + + +def _create_poll(): + """Create sample poll for testing.""" + widget = _create_widget() + return factory_poll_model(widget, {'title': 'Sample Poll', 'engagement_id': widget.engagement_id}) + + +def _create_widget(): + """Create sample widget for testing.""" + engagement = factory_engagement_model() + widget = factory_widget_model({'engagement_id': engagement.id}) + return widget + + +def _create_poll_answer(): + """Create sample poll answer for testing.""" + poll = _create_poll() + answer = factory_poll_answer_model(poll, TestPollAnswerInfo.answer1) + return poll, answer diff --git a/met-api/tests/unit/models/test_widget_poll.py b/met-api/tests/unit/models/test_widget_poll.py new file mode 100644 index 000000000..43d4bceff --- /dev/null +++ b/met-api/tests/unit/models/test_widget_poll.py @@ -0,0 +1,62 @@ +# Copyright © 2019 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for the Org model. + +Test suite to ensure that the Engagement model routines are working as expected. +""" + +from faker import Faker + +from met_api.models.widget_poll import Poll +from tests.utilities.factory_scenarios import TestWidgetPollInfo +from tests.utilities.factory_utils import factory_engagement_model, factory_poll_model, factory_widget_model + + +fake = Faker() + + +def test_create_poll(session): + """Assert that a poll can be created.""" + widget = _create_widget() + poll = factory_poll_model(widget, TestWidgetPollInfo.poll1) + session.commit() + assert poll.id is not None + assert poll.title == TestWidgetPollInfo.poll1['title'] + + +def test_get_polls_by_widget_id(session): + """Assert that polls for a widget can be fetched.""" + widget = _create_widget() + factory_poll_model(widget, TestWidgetPollInfo.poll1) + factory_poll_model(widget, TestWidgetPollInfo.poll2) + session.commit() + polls = Poll.get_polls(widget.id) + assert len(polls) == 2 + + +def test_update_poll(session): + """Assert that a poll can be updated.""" + widget = _create_widget() + poll = factory_poll_model(widget, TestWidgetPollInfo.poll1) + session.commit() + updated_title = 'Updated Title' + updated_poll = Poll.update_poll(poll.id, {'title': updated_title}) + assert updated_poll.title == updated_title + + +def _create_widget(): + """Create sample widget for testing.""" + engagement = factory_engagement_model() + widget = factory_widget_model({'engagement_id': engagement.id}) + return widget diff --git a/met-api/tests/unit/services/test_poll_answers_service.py b/met-api/tests/unit/services/test_poll_answers_service.py new file mode 100644 index 000000000..db43c82c8 --- /dev/null +++ b/met-api/tests/unit/services/test_poll_answers_service.py @@ -0,0 +1,72 @@ +# Copyright © 2019 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for the document widget service. + +Test suite to ensure that the document widget service routines are working as expected. +""" + +from http import HTTPStatus + +import pytest + +from met_api.exceptions.business_exception import BusinessException +from met_api.services.poll_answers_service import PollAnswerService +from tests.utilities.factory_scenarios import TestPollAnswerInfo, TestWidgetPollInfo +from tests.utilities.factory_utils import ( + factory_engagement_model, factory_poll_answer_model, factory_poll_model, factory_widget_model) + + +def test_get_poll_answer(session): + """Assert that poll answers can be fetched by poll ID.""" + widget = _create_widget() + poll = factory_poll_model(widget, TestWidgetPollInfo.poll1) + factory_poll_answer_model(poll, TestPollAnswerInfo.answer1) + session.commit() + poll_answers = PollAnswerService.get_poll_answer(poll.id) + assert len(poll_answers) > 0 + + +def test_delete_poll_answers(session): + """Assert that poll answers can be deleted for a given poll ID.""" + widget = _create_widget() + poll = factory_poll_model(widget, TestWidgetPollInfo.poll1) + factory_poll_answer_model(poll, TestPollAnswerInfo.answer1) + session.commit() + PollAnswerService.delete_poll_answers(poll.id) + poll_answers = PollAnswerService.get_poll_answer(poll.id) + assert len(poll_answers) == 0 + + +def test_create_bulk_poll_answers(session): + """Assert that poll answers can be deleted for a given poll ID.""" + widget = _create_widget() + poll = factory_poll_model(widget, TestWidgetPollInfo.poll1) + session.commit() + answers_data = [TestPollAnswerInfo.answer1, TestPollAnswerInfo.answer2, TestPollAnswerInfo.answer3] + PollAnswerService.create_bulk_poll_answers(poll.id, answers_data) + poll_answers = PollAnswerService.get_poll_answer(poll.id) + assert len(poll_answers) == 3 + + # Testing Exception + with pytest.raises(BusinessException) as exc_info: + _ = PollAnswerService.create_bulk_poll_answers(100, answers_data) + + assert exc_info.value.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + + +def _create_widget(): + """Create sample widget for testing.""" + engagement = factory_engagement_model() + widget = factory_widget_model({'engagement_id': engagement.id}) + return widget diff --git a/met-api/tests/unit/services/test_widget_poll_service.py b/met-api/tests/unit/services/test_widget_poll_service.py new file mode 100644 index 000000000..053ecf3a8 --- /dev/null +++ b/met-api/tests/unit/services/test_widget_poll_service.py @@ -0,0 +1,164 @@ +# Copyright © 2019 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for the document widget service. + +Test suite to ensure that the document widget service routines are working as expected. +""" +from http import HTTPStatus +from unittest.mock import patch + +import pytest + +from met_api.exceptions.business_exception import BusinessException +from met_api.services import authorization +from met_api.services.poll_response_service import PollResponseService +from met_api.services.widget_poll_service import WidgetPollService +from tests.utilities.factory_scenarios import TestPollAnswerInfo, TestPollResponseInfo, TestWidgetPollInfo +from tests.utilities.factory_utils import ( + factory_engagement_model, factory_poll_answer_model, factory_poll_model, factory_widget_model) + + +def test_get_polls_by_widget_id(session): + """Assert that polls can be fetched by widget ID.""" + widget = _create_widget() + factory_poll_model(widget, TestWidgetPollInfo.poll1) + factory_poll_model(widget, TestWidgetPollInfo.poll2) + session.commit() + polls = WidgetPollService.get_polls_by_widget_id(widget.id) + assert len(polls) == 2 + + +def test_get_poll_by_id(session): + """Assert that polls can be fetched by poll ID.""" + widget = _create_widget() + poll = factory_poll_model(widget, TestWidgetPollInfo.poll1) + session.commit() + response = WidgetPollService.get_poll_by_id(poll.id) + assert response.id == poll.id + + # Test invalid poll ID + with pytest.raises(BusinessException) as exc_info: + _ = WidgetPollService.get_poll_by_id(100) + + assert exc_info.value.status_code == HTTPStatus.NOT_FOUND + + +def test_create_poll(session, monkeypatch): + """Assert that a poll can be created.""" + with patch.object(authorization, 'check_auth', return_value=True): + widget = _create_widget() + poll_data = TestWidgetPollInfo.poll1 + poll_data['engagement_id'] = widget.engagement_id + poll = WidgetPollService.create_poll(widget.id, poll_data) + assert poll.id is not None + assert poll.title == poll_data['title'] + + # Test invalid widget ID + with pytest.raises(BusinessException) as exc_info: + _ = WidgetPollService.create_poll(100, poll_data) + + assert exc_info.value.status_code == HTTPStatus.BAD_REQUEST + + +def test_update_poll(session): + """Assert that a poll can be updated.""" + with patch.object(authorization, 'check_auth', return_value=True): + widget = _create_widget() + poll = factory_poll_model(widget, TestWidgetPollInfo.poll1) + session.commit() + updated_data = { + 'title': 'Updated Title', + 'answers': [ + { + 'answer_text': 'Python' + } + ] + } + updated_poll = WidgetPollService.update_poll(widget.id, poll.id, updated_data) + assert updated_poll.title == updated_data['title'] + assert updated_poll.answers[0].answer_text == updated_data['answers'][0]['answer_text'] + + # Test invalid poll ID + with pytest.raises(BusinessException) as exc_info: + _ = WidgetPollService.update_poll(widget.id, 150, updated_data) + + assert exc_info.value.status_code == HTTPStatus.NOT_FOUND + + # Test invalid widget ID + with pytest.raises(BusinessException) as exc_info: + _ = WidgetPollService.update_poll(150, poll.id, updated_data) + + assert exc_info.value.status_code == HTTPStatus.BAD_REQUEST + + +def test_record_response(session): + """Assert that a poll can be created.""" + widget = _create_widget() + poll = factory_poll_model(widget, TestWidgetPollInfo.poll1) + answer = factory_poll_answer_model(poll, TestPollAnswerInfo.answer1) + response_data = TestPollResponseInfo.response1 + response_data['poll_id'] = poll.id + response_data['widget_id'] = widget.id + response_data['selected_answer_id'] = answer.id + + response = WidgetPollService.record_response(response_data) + assert response.id is not None + assert response.selected_answer_id == answer.id + + # Test creating response with invalid selected_answer_id + response_data['selected_answer_id'] = 100 + with pytest.raises(BusinessException) as exc_info: + _ = WidgetPollService.record_response(response_data) + + assert exc_info.value.status_code == HTTPStatus.BAD_REQUEST + + +def test_check_already_polled(session): + """Checking whether the poll is already polled the specfied limit with same ip.""" + response_data = TestPollResponseInfo.response1 + widget = _create_widget() + poll = factory_poll_model(widget, TestWidgetPollInfo.poll1) + already_polled = WidgetPollService.check_already_polled(poll.id, response_data['participant_id'], 1) + assert already_polled is False + + # Check already polled or not after poll response is created + answer = factory_poll_answer_model(poll, TestPollAnswerInfo.answer1) + response_data['poll_id'] = poll.id + response_data['widget_id'] = widget.id + response_data['selected_answer_id'] = answer.id + PollResponseService.create_response(response_data) + + already_polled = WidgetPollService.check_already_polled(poll.id, response_data['participant_id'], 1) + assert already_polled is True + + +def test_is_poll_active(session): + """Check if poll is active or not.""" + widget = _create_widget() + poll = factory_poll_model(widget, TestWidgetPollInfo.poll1) + is_active = WidgetPollService.is_poll_active(poll.id) + assert is_active is True + + # Test wrong poll id + with pytest.raises(BusinessException) as exc_info: + _ = WidgetPollService.is_poll_active(100) + + assert exc_info.value.status_code == HTTPStatus.NOT_FOUND + + +def _create_widget(): + """Create a widget for testing.""" + engagement = factory_engagement_model() + widget = factory_widget_model({'engagement_id': engagement.id}) + return widget diff --git a/met-api/tests/utilities/factory_scenarios.py b/met-api/tests/utilities/factory_scenarios.py index 4b15ca5a7..ca0d45c7c 100644 --- a/met-api/tests/utilities/factory_scenarios.py +++ b/met-api/tests/utilities/factory_scenarios.py @@ -751,3 +751,62 @@ class TestTimelineInfo(dict, Enum): 'position': 1, 'status': TimelineEventStatus.Pending.value } + + +class TestWidgetPollInfo(dict, Enum): + """Test scenarios of widget polls.""" + + poll1 = { + 'title': fake.sentence(), + 'description': fake.text(), + 'status': 'active', + 'engagement_id': 1 # Placeholder, should be replaced with actual engagement ID in tests + } + + poll2 = { + 'title': fake.sentence(), + 'description': fake.text(), + 'status': 'inactive', + 'engagement_id': 1 # Placeholder, should be replaced with actual engagement ID in tests + } + + poll3 = { + 'title': fake.sentence(), + 'description': fake.text(), + 'status': 'active', + 'engagement_id': 2 # Placeholder, should be replaced with another engagement ID in tests + } + + +class TestPollAnswerInfo(dict, Enum): + """Test scenarios for poll answers.""" + + answer1 = { + 'answer_text': 'Answer 1' + } + + answer2 = { + 'answer_text': 'Answer 2' + } + + answer3 = { + 'answer_text': 'Answer 3' + } + + +class TestPollResponseInfo(dict, Enum): + """Test scenarios for poll responses.""" + + response1 = { + 'participant_id': fake.uuid4(), + 'selected_answer_id': 1, # should be replaced with an actual answer ID in tests + 'poll_id': 1, # should be replaced with an actual poll ID in tests + 'widget_id': 1, # Placeholder, should be replaced with an actual widget ID in tests + } + + response2 = { + 'participant_id': fake.uuid4(), + 'selected_answer_id': 2, # should be replaced with an actual answer ID in tests + 'poll_id': 1, # should be replaced with an actual poll ID in tests + 'widget_id': 1, # should be replaced with an actual widget ID in tests + } diff --git a/met-api/tests/utilities/factory_utils.py b/met-api/tests/utilities/factory_utils.py index feeef81cb..7fa29a843 100644 --- a/met-api/tests/utilities/factory_utils.py +++ b/met-api/tests/utilities/factory_utils.py @@ -30,6 +30,8 @@ from met_api.models.feedback import Feedback as FeedbackModel from met_api.models.membership import Membership as MembershipModel from met_api.models.participant import Participant as ParticipantModel +from met_api.models.poll_answers import PollAnswer as PollAnswerModel +from met_api.models.poll_responses import PollResponse as PollResponseModel from met_api.models.report_setting import ReportSetting as ReportSettingModel from met_api.models.staff_user import StaffUser as StaffUserModel from met_api.models.submission import Submission as SubmissionModel @@ -38,16 +40,19 @@ from met_api.models.timeline_event import TimelineEvent as TimelineEventModel from met_api.models.widget import Widget as WidgetModal from met_api.models.widget_documents import WidgetDocuments as WidgetDocumentModel +from met_api.models.widget_item import WidgetItem as WidgetItemModal from met_api.models.widget_map import WidgetMap as WidgetMapModel +from met_api.models.widget_poll import Poll as WidgetPollModel from met_api.models.widget_timeline import WidgetTimeline as WidgetTimelineModel from met_api.models.widget_video import WidgetVideo as WidgetVideoModel -from met_api.models.widget_item import WidgetItem as WidgetItemModal from met_api.utils.constants import TENANT_ID_HEADER from met_api.utils.enums import MembershipStatus from tests.utilities.factory_scenarios import ( TestCommentInfo, TestEngagementInfo, TestEngagementSlugInfo, TestFeedbackInfo, TestParticipantInfo, - TestReportSettingInfo, TestSubmissionInfo, TestSurveyInfo, TestTenantInfo, TestTimelineInfo, TestUserInfo, - TestWidgetDocumentInfo, TestWidgetInfo, TestWidgetItemInfo, TestWidgetMap, TestWidgetVideo) + TestPollAnswerInfo, TestPollResponseInfo, TestReportSettingInfo, TestSubmissionInfo, TestSurveyInfo, TestTenantInfo, + TestTimelineInfo, TestUserInfo, TestWidgetDocumentInfo, TestWidgetInfo, TestWidgetItemInfo, TestWidgetMap, + TestWidgetPollInfo, TestWidgetVideo) + CONFIG = get_named_config('testing') fake = Faker() @@ -340,6 +345,41 @@ def factory_engagement_setting_model(engagement_id): return setting +def factory_poll_model(widget, poll_info: dict = TestWidgetPollInfo.poll1): + """Produce a Poll model.""" + poll = WidgetPollModel( + title=poll_info.get('title'), + description=poll_info.get('description'), + status=poll_info.get('status'), + engagement_id=widget.engagement_id, + widget_id=widget.id + ) + poll.save() + return poll + + +def factory_poll_answer_model(poll, answer_info: dict = TestPollAnswerInfo.answer1): + """Produce a Poll model.""" + answer = PollAnswerModel( + answer_text=answer_info.get('answer_text'), + poll_id=poll.id + ) + answer.save() + return answer + + +def factory_poll_response_model(poll, answer, response_info: dict = TestPollResponseInfo.response1): + """Produce a Poll model.""" + response = PollResponseModel( + participant_id=response_info.get('participant_id'), + selected_answer_id=answer.id, + poll_id=poll.id, + widget_id=poll.widget_id + ) + response.save() + return response + + def factory_video_model(video_info: dict = TestWidgetVideo.video1): """Produce a comment model.""" video = WidgetVideoModel(