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

[TO MAIN] DESENG-513 - Add poll results to results tab #2422

Merged
merged 7 commits into from
Mar 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
}
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
}
} finally {
setItWidgetsLoading(false);
}

You could add this block in to make sure you set this state regardless of whether try or catch runs

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Doable. Will update other useEffect functions too.

};

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
Loading