Skip to content

Commit

Permalink
Changes to show all survey results to superusers (#2078)
Browse files Browse the repository at this point in the history
* Changes to show all survey results to superusers

* fixing link

* removing hard coded values

* fixing linting

* splitting to seperate end points

* fixing auth check

* fixing linting

* merging method in service
  • Loading branch information
VineetBala-AOT authored Aug 28, 2023
1 parent 4a6c238 commit 45257ff
Show file tree
Hide file tree
Showing 20 changed files with 306 additions and 35 deletions.
32 changes: 22 additions & 10 deletions analytics-api/src/analytics_api/models/request_type_option.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ class RequestTypeOption(BaseModel, RequestMixin): # pylint: disable=too-few-pub
@classmethod
def get_survey_result(
cls,
engagement_id
engagement_id,
can_view_all_survey_results
):
"""Get the analytics survey id for an engagement id."""
analytics_survey_id = (db.session.query(SurveyModel.id)
Expand All @@ -28,15 +29,26 @@ def get_survey_result(
.subquery())

# Get all the survey questions specific to a survey id which are in active status.
survey_question = (db.session.query(RequestTypeOption.position.label('position'),
RequestTypeOption.label.label('label'),
RequestTypeOption.request_id)
.filter(and_(RequestTypeOption.survey_id.in_(analytics_survey_id),
RequestTypeOption.is_active == true(),
or_(RequestTypeOption.display == true(),
RequestTypeOption.display.is_(None))))
.order_by(RequestTypeOption.position)
.subquery())
# for users with role to view all surveys fetch all survey questions
# for all other users exclude questions excluded on report settings
if can_view_all_survey_results:
survey_question = (db.session.query(RequestTypeOption.position.label('position'),
RequestTypeOption.label.label('label'),
RequestTypeOption.request_id)
.filter(and_(RequestTypeOption.survey_id.in_(analytics_survey_id),
RequestTypeOption.is_active == true()))
.order_by(RequestTypeOption.position)
.subquery())
else:
survey_question = (db.session.query(RequestTypeOption.position.label('position'),
RequestTypeOption.label.label('label'),
RequestTypeOption.request_id)
.filter(and_(RequestTypeOption.survey_id.in_(analytics_survey_id),
RequestTypeOption.is_active == true(),
or_(RequestTypeOption.display == true(),
RequestTypeOption.display.is_(None))))
.order_by(RequestTypeOption.position)
.subquery())

# Get all the survey responses with the counts for each response specific to a survey id which
# are in active status.
Expand Down
35 changes: 30 additions & 5 deletions analytics-api/src/analytics_api/resources/survey_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
from flask_cors import cross_origin
from flask_restx import Namespace, Resource

from analytics_api.auth import auth
from analytics_api.auth import jwt as _jwt
from analytics_api.utils.roles import Role
from analytics_api.services.survey_result import SurveyResultService
from analytics_api.utils.util import allowedorigins, cors_preflight

Expand All @@ -30,17 +31,41 @@


@cors_preflight('GET,OPTIONS')
@API.route('/<engagement_id>')
class SurveyResult(Resource):
@API.route('/<engagement_id>/internal')
class SurveyResultInternal(Resource):
"""Resource for managing a survey result for single engagement."""

@staticmethod
@cross_origin(origins=allowedorigins())
@auth.optional
@_jwt.has_one_of_roles([Role.VIEW_ALL_SURVEY_RESULTS.value])
def get(engagement_id):
"""Fetch survey result for a single engagement id."""
try:
survey_result_record = SurveyResultService().get_survey_result(engagement_id)
survey_result_record = SurveyResultService().get_survey_result(engagement_id,
can_view_all_survey_results=True)

if survey_result_record:
return jsonify(data=survey_result_record), HTTPStatus.OK

return 'Engagement was not found', HTTPStatus.NOT_FOUND
except KeyError:
return 'Engagement was not found', HTTPStatus.INTERNAL_SERVER_ERROR
except ValueError as err:
return str(err), HTTPStatus.INTERNAL_SERVER_ERROR


@cors_preflight('GET,OPTIONS')
@API.route('/<engagement_id>/public')
class SurveyResultExternal(Resource):
"""Resource for managing a survey result for single engagement."""

@staticmethod
@cross_origin(origins=allowedorigins())
def get(engagement_id):
"""Fetch survey result for a single engagement id."""
try:
survey_result_record = SurveyResultService().get_survey_result(engagement_id,
can_view_all_survey_results=False)

if survey_result_record:
return jsonify(data=survey_result_record), HTTPStatus.OK
Expand Down
4 changes: 2 additions & 2 deletions analytics-api/src/analytics_api/services/survey_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ class SurveyResultService: # pylint: disable=too-few-public-methods
otherdateformat = '%Y-%m-%d'

@staticmethod
def get_survey_result(engagement_id) -> SurveyResultSchema:
def get_survey_result(engagement_id, can_view_all_survey_results) -> SurveyResultSchema:
"""Get Survey result by the engagement id."""
survey_result = RequestTypeOptionModel.get_survey_result(engagement_id)
survey_result = RequestTypeOptionModel.get_survey_result(engagement_id, can_view_all_survey_results)
survey_result_schema = SurveyResultSchema(many=True)
return survey_result_schema.dump(survey_result)
58 changes: 58 additions & 0 deletions analytics-api/src/analytics_api/utils/roles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# 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.
"""Role definitions."""
from enum import Enum


class Role(Enum):
"""User Role."""

PUBLIC_USER = 'public_user'
ANONYMOUS_USER = 'anonymous_user'

# STAFF Based roles
CREATE_TENANT = 'create_tenant'
VIEW_TENANT = 'view_tenant'
VIEW_USERS = 'view_users'
TOGGLE_USER_STATUS = 'toggle_user_status'
CREATE_ADMIN_USER = 'create_admin_user'
CREATE_TEAM = 'create_team'
CREATE_ENGAGEMENT = 'create_engagement'
VIEW_SURVEYS = 'view_surveys'
CREATE_SURVEY = 'create_survey'
EDIT_SURVEY = 'edit_survey'
CLONE_SURVEY = 'clone_survey'
PUBLISH_ENGAGEMENT = 'publish_engagement'
VIEW_ENGAGEMENT = 'view_engagement'
VIEW_ASSIGNED_ENGAGEMENTS = 'view_assigned_engagements'
VIEW_PRIVATE_ENGAGEMENTS = 'view_private_engagements'
EDIT_ENGAGEMENT = 'edit_engagement'
REVIEW_COMMENTS = 'review_comments'
REVIEW_ALL_COMMENTS = 'review_all_comments'
ACCESS_DASHBOARD = 'access_dashboard'
VIEW_MEMBERS = 'view_members'
EDIT_MEMBERS = 'edit_members'
VIEW_ALL_SURVEYS = 'view_all_surveys' # Super user can view all kind of surveys including hidden
EDIT_ALL_SURVEYS = 'edit_all_surveys'
EDIT_DRAFT_ENGAGEMENT = 'edit_draft_engagement'
EDIT_SCHEDULED_ENGAGEMENT = 'edit_scheduled_engagement'
EDIT_UPCOMING_ENGAGEMENT = 'edit_upcoming_engagement'
EDIT_OPEN_ENGAGEMENT = 'edit_open_engagement'
EDIT_CLOSED_ENGAGEMENT = 'edit_closed_engagement'
VIEW_APPROVED_COMMENTS = 'view_approved_comments' # used just in the front end to show the comment page
VIEW_FEEDBACKS = 'view_feedbacks'
VIEW_ALL_ENGAGEMENTS = 'view_all_engagements' # Allows user access to all engagements including draft
SHOW_ALL_COMMENT_STATUS = 'show_all_comment_status' # Allows user to see all comment status
EXPORT_TO_CSV = 'export_to_csv' # Allows users to export comments to csv
VIEW_ALL_SURVEY_RESULTS = 'view_all_survey_results' # Allows users to view results to all questions
116 changes: 116 additions & 0 deletions analytics-api/src/analytics_api/utils/user_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# 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.
"""User Context to hold request scoped variables."""

import functools
from typing import Dict

from flask import g, request

from analytics_api.utils.roles import Role


def _get_context():
"""Return User context."""
return UserContext()


class UserContext: # pylint: disable=too-many-instance-attributes
"""Object to hold request scoped user context."""

def __init__(self):
"""Return a User Context object."""
token_info: Dict = _get_token_info() or {}
self._token_info = token_info
self._user_name: str = token_info.get('username', token_info.get('preferred_username', None))
self._first_name: str = token_info.get('firstname', None)
self._last_name: str = token_info.get('lastname', None)
self._bearer_token: str = _get_token()
self._roles: list = token_info.get('realm_access', None).get('roles', []) if 'realm_access' in token_info \
else []
self._sub: str = token_info.get('sub', None)
self._name: str = f"{token_info.get('firstname', None)} {token_info.get('lastname', None)}"

@property
def user_name(self) -> str:
"""Return the user_name."""
return self._user_name if self._user_name else None

@property
def first_name(self) -> str:
"""Return the user_first_name."""
return self._first_name

@property
def last_name(self) -> str:
"""Return the user_last_name."""
return self._last_name

@property
def bearer_token(self) -> str:
"""Return the bearer_token."""
return self._bearer_token

@property
def roles(self) -> list:
"""Return the roles."""
return self._roles

@property
def sub(self) -> str:
"""Return the subject."""
return self._sub

def has_role(self, role_name: str) -> bool:
"""Return True if the user has the role."""
return role_name in self._roles

def is_staff_admin(self) -> bool:
"""Return True if the user is staff user."""
return Role.CREATE_ENGAGEMENT.value in self._roles if self._roles else False

def is_system(self) -> bool:
"""Return True if the user is system user.Helps to idenitfy connections from EPIC."""
return Role.SYSTEM.value in self._roles if self._roles else False

@property
def name(self) -> str:
"""Return the name."""
return self._name

@property
def token_info(self) -> Dict:
"""Return the name."""
return self._token_info


def user_context(function):
"""Add user context object as an argument to function."""

@functools.wraps(function)
def wrapper(*func_args, **func_kwargs):
context = _get_context()
func_kwargs['user_context'] = context
return function(*func_args, **func_kwargs)

return wrapper


def _get_token_info() -> Dict:
return g.jwt_oidc_token_info if g and 'jwt_oidc_token_info' in g else {}


def _get_token() -> str:
token: str = request.headers['Authorization'] if request and 'Authorization' in request.headers else None
return token.replace('Bearer ', '') if token else None
7 changes: 7 additions & 0 deletions analytics-api/src/analytics_api/utils/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,10 @@ class ContentType(Enum):
JSON = 'application/json'
FORM_URL_ENCODED = 'application/x-www-form-urlencoded'
PDF = 'application/pdf'


class DashboardType(Enum):
"""Dashboard Types."""

PUBLIC = 'public'
INTERNAL = 'internal'
1 change: 1 addition & 0 deletions met-api/src/met_api/utils/roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,4 @@ class Role(Enum):
VIEW_ALL_ENGAGEMENTS = 'view_all_engagements' # Allows user access to all engagements including draft
SHOW_ALL_COMMENT_STATUS = 'show_all_comment_status' # Allows user to see all comment status
EXPORT_TO_CSV = 'export_to_csv' # Allows users to export comments to csv
VIEW_ALL_SURVEY_RESULTS = 'view_all_survey_results' # Allows users to view results to all questions
2 changes: 1 addition & 1 deletion met-web/src/apiManager/endpoints/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ const Endpoints = {
GET: `${AppConfig.analyticsApiUrl}/engagements/map/engagement_id`,
},
AnalyticsSurveyResult: {
GET: `${AppConfig.analyticsApiUrl}/surveyresult/engagement_id`,
GET: `${AppConfig.analyticsApiUrl}/surveyresult/engagement_id/dashboard_type`,
},
};

Expand Down
7 changes: 6 additions & 1 deletion met-web/src/components/dashboard/EngagementsAccordion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import SurveysCompleted from '../publicDashboard/KPI/SurveysCompleted';
import ProjectLocation from '../publicDashboard/KPI/ProjectLocation';
import SubmissionTrend from '../publicDashboard/SubmissionTrend/SubmissionTrend';
import SurveyBar from '../publicDashboard/SurveyBar';
import { DashboardType } from 'constants/dashboardType';

const EngagementsAccordion = ({
engagements,
Expand Down Expand Up @@ -112,7 +113,11 @@ const EngagementsAccordion = ({
<SubmissionTrend engagement={engagement} engagementIsLoading={false} />
</Grid>
<Grid item xs={12} mt={2}>
<SurveyBar engagement={engagement} engagementIsLoading={false} />
<SurveyBar
engagement={engagement}
engagementIsLoading={false}
dashboardType={DashboardType.PUBLIC}
/>
</Grid>
</When>
</AccordionDetails>
Expand Down
15 changes: 13 additions & 2 deletions met-web/src/components/engagement/listing/ActionsDropDown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,16 +89,27 @@ export const ActionsDropDown = ({ engagement }: { engagement: Engagement }) => {
},
{
value: 3,
label: 'View Report',
label: 'View Report - Public',
action: () => {
navigate(`/engagements/${engagement.id}/dashboard`);
navigate(`/engagements/${engagement.id}/dashboard/public`);
},
condition:
submissionHasBeenOpened &&
(roles.includes(USER_ROLES.ACCESS_DASHBOARD) || assignedEngagements.includes(engagement.id)),
},
{
value: 4,
label: 'View Report - Internal',
action: () => {
navigate(`/engagements/${engagement.id}/dashboard/internal`);
},
condition:
submissionHasBeenOpened &&
roles.includes(USER_ROLES.VIEW_ALL_SURVEY_RESULTS) &&
(roles.includes(USER_ROLES.ACCESS_DASHBOARD) || assignedEngagements.includes(engagement.id)),
},
{
value: 5,
label: 'View All Comments',
action: () => {
navigate(`/surveys/${engagement.surveys[0].id}/comments`);
Expand Down
4 changes: 3 additions & 1 deletion met-web/src/components/publicDashboard/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const Dashboard = () => {
const { slug } = useParams();
const isTablet = useMediaQuery((theme: Theme) => theme.breakpoints.down('md'));
const navigate = useNavigate();
const { engagement, isEngagementLoading } = useContext(DashboardContext);
const { engagement, isEngagementLoading, dashboardType } = useContext(DashboardContext);
const [isPrinting, setIsPrinting] = React.useState(false);
const [projectMapData, setProjectMapData] = React.useState<Map | null>(null);
const [pdfExportProgress, setPdfExportProgress] = React.useState(0);
Expand Down Expand Up @@ -198,12 +198,14 @@ const Dashboard = () => {
<SurveyBarPrintable
engagement={engagement}
engagementIsLoading={isEngagementLoading}
dashboardType={dashboardType}
/>
</Box>
<SurveyBar
readComments={handleReadComments}
engagement={engagement}
engagementIsLoading={isEngagementLoading}
dashboardType={dashboardType}
/>
</Grid>
<When condition={isPrinting}>
Expand Down
Loading

0 comments on commit 45257ff

Please sign in to comment.