Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Deseng 464 : Poll widget UI #2367

Merged
merged 13 commits into from
Jan 31, 2024
5 changes: 5 additions & 0 deletions CHANGELOG.MD
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
- **Task** Updated Babel Traverse library. [🎟️DESENG-474](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-474)
- Run `npm audit fix` to update the vulnerable Babel traverse library.

## January 26, 2024
- **Task** Poll Widget: Front-end. [🎟️DESENG-464](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-464)
- Created UI for Poll Widget.
- Updated Poll widget API and unit tests.

## January 25, 2024
- **Task** Resolve issue preventing met-web from deploying on the Dev OpenShift environment. [🎟️DESENG-469](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-469)
- Remove Epic Engage-related links and update Keycloak link.
Expand Down
169 changes: 94 additions & 75 deletions met-api/src/met_api/resources/widget_poll.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@
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'
API = Namespace(
'widget_polls', description='Endpoints for Poll Widget Management'
)


@cors_preflight('GET, POST')
Expand All @@ -28,7 +29,10 @@ 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
return (
WidgetPollSchema().dump(widget_poll, many=True),
HTTPStatus.OK,
)
except BusinessException as err:
return str(err), err.status_code

Expand All @@ -39,22 +43,22 @@ def post(widget_id):
"""Create poll widget."""
try:
request_json = request.get_json()
valid_format, errors = Polls.validate_request_format(request_json)
valid_format, errors = schema_utils.validate(
request_json, 'poll_widget'
)
if not valid_format:
return {'message': INVALID_REQUEST_MESSAGE, 'errors': errors}, HTTPStatus.BAD_REQUEST
widget_poll = WidgetPollService().create_poll(widget_id, request_json)
raise BusinessException(
error=schema_utils.serialize(errors),
status_code=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('/<int:poll_widget_id>')
Expand All @@ -68,23 +72,31 @@ 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)
valid_format, errors = schema_utils.validate(
request_json, 'poll_widget_update'
)
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)
raise BusinessException(
error=schema_utils.serialize(errors),
status_code=HTTPStatus.BAD_REQUEST,
)
# Check if the poll engagement is published
if WidgetPollService.is_poll_engagement_published(poll_widget_id):
# Define the keys to check in the request_json
keys_to_check = ['title', 'description', 'answers']
if any(key in request_json for key in keys_to_check):
raise BusinessException(
error='Cannot update poll widget as the engagement is published',
status_code=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('/<int:poll_widget_id>/responses')
Expand All @@ -94,58 +106,65 @@ class PollResponseRecord(Resource):
@staticmethod
@cross_origin(origins=allowedorigins())
def post(widget_id, poll_widget_id):
# pylint: disable=too-many-return-statements
"""Record a response for a given poll widget."""
try:
response_data = request.get_json()
valid_format, errors = PollResponseRecord.validate_request_format(response_data)
poll_response_data = request.get_json()
valid_format, errors = schema_utils.validate(
poll_response_data, 'poll_response'
)
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)
raise BusinessException(
error=schema_utils.serialize(errors),
status_code=HTTPStatus.BAD_REQUEST,
)

# Prepare poll request object
poll_response_dict = {
**poll_response_data,
'poll_id': poll_widget_id,
'widget_id': widget_id,
'participant_id': hash_ip(request.remote_addr),
}

# Check if poll active or not
if not WidgetPollService.is_poll_active(poll_widget_id):
raise BusinessException(
error='Poll is not active',
status_code=HTTPStatus.BAD_REQUEST,
)

# Check if engagement of this poll is published or not
if not WidgetPollService.is_poll_engagement_published(
poll_widget_id
):
raise BusinessException(
error='Poll engagement is not published',
status_code=HTTPStatus.BAD_REQUEST,
)

# Check poll limit execeeded or not
if WidgetPollService.check_already_polled(
poll_widget_id, poll_response_dict['participant_id'], 10
):
raise BusinessException(
error='Limit exceeded for this poll',
status_code=HTTPStatus.BAD_REQUEST,
)

# Record poll response in database
poll_response = WidgetPollService.record_response(
poll_response_dict
)
if poll_response.id:
return {
'message': 'Response recorded successfully'
}, HTTPStatus.CREATED

raise BusinessException(
error='Response failed to record',
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
)

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
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
]
}
],
"required": ["widget_id", "engagement_id"],
"required": [],
"properties": {
"title": {
"$id": "#/properties/title",
Expand Down
55 changes: 46 additions & 9 deletions met-api/src/met_api/services/widget_poll_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@
from http import HTTPStatus

from sqlalchemy.exc import SQLAlchemyError

from met_api.constants.engagement_status import Status as EngagementStatus
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.engagement_service import EngagementService
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
Expand All @@ -24,7 +27,9 @@ 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)
raise BusinessException(
'Poll widget not found', HTTPStatus.NOT_FOUND
)
return poll

@staticmethod
Expand All @@ -33,7 +38,9 @@ def create_poll(widget_id: int, poll_details: dict):
try:
eng_id = poll_details.get('engagement_id')
WidgetPollService._check_authorization(eng_id)
return WidgetPollService._create_poll_model(widget_id, poll_details)
return WidgetPollService._create_poll_model(
widget_id, poll_details
)
except SQLAlchemyError as exc:
raise BusinessException(str(exc), HTTPStatus.BAD_REQUEST) from exc

Expand All @@ -45,9 +52,13 @@ def update_poll(widget_id: int, poll_widget_id: int, poll_data: dict):
WidgetPollService._check_authorization(widget_poll.engagement_id)

if widget_poll.widget_id != widget_id:
raise BusinessException('Invalid widget ID', HTTPStatus.BAD_REQUEST)
raise BusinessException(
'Invalid widget ID', HTTPStatus.BAD_REQUEST
)

return WidgetPollService._update_poll_model(poll_widget_id, poll_data)
return WidgetPollService._update_poll_model(
poll_widget_id, poll_data
)
except SQLAlchemyError as exc:
raise BusinessException(str(exc), HTTPStatus.BAD_REQUEST) from exc

Expand Down Expand Up @@ -77,6 +88,21 @@ def is_poll_active(poll_id: int) -> bool:
except SQLAlchemyError as exc:
raise BusinessException(str(exc), HTTPStatus.BAD_REQUEST) from exc

@staticmethod
def is_poll_engagement_published(poll_id: int) -> bool:
"""Check if the poll is published."""
try:
poll = WidgetPollService.get_poll_by_id(poll_id)
engagement = EngagementService().get_engagement(poll.engagement_id)
pub_val = EngagementStatus.Published.value
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you save this to a variable to keep the line length short on line 102?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. as the max length of line by flake8 is 79

# Return False immediately if engagement is None
if engagement is None:
return False
# Check if the engagement's status matches the published value
return engagement.get('status_id') == pub_val
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return engagement.get('status_id') == pub_val
return engagement.get('status_id') == EngagementStatus.Published.value

Copy link
Collaborator Author

@ratheesh-aot ratheesh-aot Jan 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the max length of the line by flake8 is 79.

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."""
Expand All @@ -94,12 +120,23 @@ def _update_poll_model(poll_id: int, poll_data: dict):
@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)
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)
try:
if 'answers' in poll_data and len(poll_data['answers']) > 0:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By the looks of this, if a user tried to save a poll with no answers then it would fail silently, correct?

This probably won't happen that often but we should probably notify them that their save was unsuccessful. Does that happen somewhere? Or does the frontend block saving if no answers are provided?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function ensures that if there are no changes to the answers in the API, there is no need to update the answers. Especially, while patching status only when engagement is published.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, it's clear now!

PollAnswerService.delete_poll_answers(poll_id)
answers_data = poll_data.get('answers', [])
PollAnswerService.create_bulk_poll_answers(
poll_id, answers_data
)
except SQLAlchemyError as exc:
raise BusinessException(str(exc), HTTPStatus.BAD_REQUEST) from exc
Loading
Loading