Skip to content

Commit

Permalink
[TO MAIN] DESENG-513 - Add poll results to results tab (#2422)
Browse files Browse the repository at this point in the history
* DESENG-513: Poll results view

* DESENG-513: Lint fixes

* DESENG-513: Updated changelog

* DESENG-513: Votes text update

* Removed comments

* DESENG-513: Fixing review comments
  • Loading branch information
ratheesh-aot authored Mar 20, 2024
1 parent 29ea71f commit 50999d4
Show file tree
Hide file tree
Showing 12 changed files with 503 additions and 6 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.MD
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
## March 19, 2024

- **Task**: Add poll results to results tab [DESENG-513](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-513)
- Added poll results to results tab.
- Added poll results API.
- Added Unit tests.

- **Task**: Change static english text to be able to support string translations [DESENG-467](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-467)
- Implemented a language selector in the public header.
- Incorporated logic to dynamically adjust the unauthenticated route based on the selected language and load the appropriate translation file.
Expand Down
12 changes: 12 additions & 0 deletions met-api/src/met_api/resources/widget_poll.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
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.services.poll_response_service import PollResponseService
from met_api.utils.util import allowedorigins, cors_preflight
from met_api.utils.ip_util import hash_ip

Expand Down Expand Up @@ -165,3 +166,14 @@ def post(widget_id, poll_widget_id):

except BusinessException as err:
return err.error, err.status_code

@staticmethod
@cross_origin(origins=allowedorigins())
@_jwt.requires_auth
def get(poll_widget_id, **_):
"""Get poll responses for a given widget."""
try:
poll_results = PollResponseService().get_poll_details_with_response_counts(poll_widget_id)
return jsonify(poll_results), HTTPStatus.OK
except BusinessException as err:
return err.error, err.status_code
72 changes: 68 additions & 4 deletions met-api/src/met_api/services/poll_response_service.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
"""Service for Poll Response management."""

from http import HTTPStatus
from sqlalchemy import func
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
from met_api.models import Poll, PollAnswer, db
from met_api.services import authorization
from met_api.constants.membership_type import MembershipType
from met_api.utils.roles import Role


class PollResponseService:
Expand Down Expand Up @@ -31,8 +37,7 @@ def create_response(response_data: dict) -> PollResponseModel:
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
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:
Expand All @@ -46,5 +51,64 @@ def get_poll_count(poll_id: int, ip_addr: str = None) -> int:
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
raise BusinessException(f'Error creating poll response: {e}', HTTPStatus.INTERNAL_SERVER_ERROR) from e

@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 get_poll_details_with_response_counts(poll_id):
"""
Get poll details along with response counts for each answer for a specific poll.
:param poll_id: The ID of the poll.
:return: Poll details and response counts for each answer in a structured format.
"""
poll = Poll.query.get(poll_id)
if not poll:
raise BusinessException('Poll not found', HTTPStatus.NOT_FOUND)
# Check authorization
PollResponseService._check_authorization(poll.engagement_id)

# Query to join PollAnswer and PollResponse and count responses for each answer
poll_data = (
db.session.query(
PollAnswer.id.label('answer_id'),
PollAnswer.answer_text,
func.count(PollResponseModel.selected_answer_id).label('response_count')
)
.select_from(PollAnswer)
.outerjoin(PollResponseModel, PollAnswer.id == PollResponseModel.selected_answer_id)
.filter(PollAnswer.poll_id == poll_id)
.group_by(PollAnswer.id, PollAnswer.answer_text)
.all()
)

# Calculate total responses
total_responses = sum(response_count for _, _, response_count in poll_data)

# Construct response dictionary
response = {
'poll_id': poll_id,
'title': poll.title,
'description': poll.description,
'total_response': total_responses,
'answers': [
{
'answer_id': answer_id,
'answer_text': answer_text,
'total_response': response_count,
'percentage': (response_count / total_responses * 100) if total_responses > 0 else 0
} for answer_id, answer_text, response_count in poll_data
]
}

return response
49 changes: 48 additions & 1 deletion met-api/tests/unit/api/test_widget_poll.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
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)
factory_auth_header, factory_engagement_model, factory_poll_answer_model, factory_poll_model,
factory_poll_response_model, factory_widget_model)


fake = Faker()
Expand Down Expand Up @@ -211,3 +212,49 @@ def test_record_poll_response(client, session, jwt):
)

assert rv.status_code == HTTPStatus.BAD_REQUEST


def test_get_poll_response(client, jwt, session, setup_admin_user_and_claims):
"""Assert that a response for a poll widget can be retrieved."""
# Test setup: create a poll widget and a response 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)
answer1 = factory_poll_answer_model(poll, TestPollAnswerInfo.answer1)
answer2 = factory_poll_answer_model(poll, TestPollAnswerInfo.answer2)
answer3 = factory_poll_answer_model(poll, TestPollAnswerInfo.answer3)

# Recording 2 votes for answer1
factory_poll_response_model(poll, answer1)
factory_poll_response_model(poll, answer1)

# Recording 2 votes for answer2
factory_poll_response_model(poll, answer2)
factory_poll_response_model(poll, answer2)

# Sending GET request
rv = client.get(
f'/api/widgets/{widget.id}/polls/{poll.id}/responses',
headers=headers,
content_type=ContentType.JSON.value,
)

# Checking response
assert rv.status_code == HTTPStatus.OK
# Testing Poll title and total_response
assert rv.json.get('title') == poll.title
assert rv.json.get('total_response') == 4
# Testing First answer
assert rv.json.get('answers')[0].get('answer_id') == answer1.id
assert rv.json.get('answers')[0].get('total_response') == 2
assert rv.json.get('answers')[0].get('percentage') == 50
# Testing Second answer
assert rv.json.get('answers')[1].get('answer_id') == answer2.id
assert rv.json.get('answers')[1].get('total_response') == 2
assert rv.json.get('answers')[1].get('percentage') == 50
# Testing Third answer
assert rv.json.get('answers')[2].get('answer_id') == answer3.id
assert rv.json.get('answers')[2].get('total_response') == 0
assert rv.json.get('answers')[2].get('percentage') == 0
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Grid, Box } from '@mui/material';
import { MetPaper, PrimaryButton, SecondaryButton } from 'components/common';
import { EngagementTabsContext } from '../EngagementTabsContext';
import { ActionContext } from '../../ActionContext';
import PollResultsView from './PollResults/PollResultsView';

const EngagementResults = () => {
const { isSaving } = useContext(ActionContext);
Expand All @@ -19,6 +20,9 @@ const EngagementResults = () => {
spacing={2}
sx={{ padding: '2em' }}
>
<Grid item xs={12}>
<PollResultsView />
</Grid>
<Box
position="sticky"
bottom={0}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { openNotification } from 'services/notificationService/notificationSlice';
import { getWidgets } from 'services/widgetService';
import { fetchPollWidgets, fetchPollResults } from 'services/widgetService/PollService/index';
import { ActionContext } from '../../../ActionContext';
import { Widget, WidgetType } from 'models/widget';
import { PollWidget, PollResultResponse } from 'models/pollWidget';
import { useAppDispatch } from 'hooks';

export interface EngagementPollContextProps {
widget: Widget | null;
isWidgetsLoading: boolean;
pollWidget: PollWidget | null | undefined;
isLoadingPollWidget: boolean;
pollResults: PollResultResponse | null;
isPollResultsLoading: boolean;
}

export const EngagementPollContext = createContext<EngagementPollContextProps>({
widget: null,
isWidgetsLoading: true,
pollWidget: null,
isLoadingPollWidget: true,
pollResults: null,
isPollResultsLoading: true,
});

export const EngagementPollContextProvider = ({ children }: { children: JSX.Element | JSX.Element[] }) => {
const { savedEngagement } = useContext(ActionContext);
const [widgets, setWidgets] = useState<Widget[] | null>(null);
const [widget, setWidget] = useState<Widget | null>(null);
const [isWidgetsLoading, setIsWidgetsLoading] = useState(true);
const [pollWidget, setPollWidget] = useState<PollWidget | null | undefined>(null);
const [isLoadingPollWidget, setIsLoadingPollWidget] = useState(true);
const [pollResults, setPollResults] = useState<PollResultResponse | null>(null);
const [isPollResultsLoading, setIsPollResultsLoading] = useState(true);
const dispatch = useAppDispatch();

const loadWidgets = async () => {
if (!savedEngagement.id) {
setIsWidgetsLoading(false);
return;
}

try {
const widgetsList = await getWidgets(savedEngagement.id);
setWidgets(widgetsList);
setIsWidgetsLoading(false);
} catch (err) {
setIsWidgetsLoading(false);
dispatch(openNotification({ severity: 'error', text: 'Error fetching engagement widgets' }));
} finally {
setIsWidgetsLoading(false);
}
};

const loadPollWidget = async () => {
const widget = widgets?.find((w) => w.widget_type_id === WidgetType.Poll) ?? null;
setWidget(widget);
if (!widget) {
setIsLoadingPollWidget(false);
return;
}
try {
const result = await fetchPollWidgets(widget.id);
setPollWidget(result.at(-1));
setIsLoadingPollWidget(false);
} catch (error) {
dispatch(openNotification({ severity: 'error', text: 'An error occurred while trying to load Poll data' }));
setIsLoadingPollWidget(false);
} finally {
setIsLoadingPollWidget(false);
}
};

const getPollResults = async () => {
try {
if (!widget || !pollWidget) {
return;
}
const data = await fetchPollResults(widget.id, pollWidget.id);
setPollResults(data);
setIsPollResultsLoading(false);
} catch (error) {
dispatch(openNotification({ severity: 'error', text: 'Error fetching poll results' }));
setIsPollResultsLoading(false);
} finally {
setIsPollResultsLoading(false);
}
};

useEffect(() => {
loadWidgets();
}, [savedEngagement.id]);

useEffect(() => {
loadPollWidget();
}, [widgets]);

useEffect(() => {
getPollResults();
}, [widget, pollWidget]);

return (
<EngagementPollContext.Provider
value={{
widget,
isWidgetsLoading,
pollWidget,
isLoadingPollWidget,
pollResults,
isPollResultsLoading,
}}
>
{children}
</EngagementPollContext.Provider>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React, { useContext } from 'react';
import { Grid } from '@mui/material';
import { EngagementPollContext } from './EngagementPollContext';
import ResultView from './ResultView';
import { MidScreenLoader } from 'components/common';

export const PollResult = () => {
const { widget, isWidgetsLoading, isLoadingPollWidget, pollWidget, pollResults, isPollResultsLoading } =
useContext(EngagementPollContext);

// Show a loader while the data is being loaded
if (isLoadingPollWidget || isWidgetsLoading || isPollResultsLoading) {
return (
<Grid container direction="row" alignItems={'flex-start'} justifyContent="flex-start" spacing={2}>
<Grid item xs={12}>
<MidScreenLoader />
</Grid>
</Grid>
);
}

// Check if both widget and pollWidget are available
if (
widget &&
pollWidget &&
widget.id !== undefined &&
pollWidget.id !== undefined &&
pollResults?.poll_id !== undefined
) {
return <ResultView pollResult={pollResults} widget={widget} />;
}

// Display a message or handle the null case when widgetId or pollId is not available
return null;
};

export default PollResult;
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React from 'react';
import { EngagementPollContextProvider } from './EngagementPollContext';
import PollResult from './PollResult';

export const PollResultsView = () => {
return (
<EngagementPollContextProvider>
<PollResult />
</EngagementPollContextProvider>
);
};

export default PollResultsView;
Loading

0 comments on commit 50999d4

Please sign in to comment.