From ab2cad863d190d65705d24a3008f7daf143c6c68 Mon Sep 17 00:00:00 2001 From: djnunez-aot <103138766+djnunez-aot@users.noreply.github.com> Date: Tue, 22 Aug 2023 12:12:50 -0400 Subject: [PATCH] Back end updates for feedback api (#2035) * back end updates for feedback api * update tests * update feedback test * add new migration files & update tests for backend * update enums && lint * update migrations * add missing enum * update tests * update resource * fix migration error * upload tests for front end * update tests * test updates * add docstring and update feedback model * linting --- met-api/migrations/versions/04e6c48187da_.py | 40 ++++++++++++++ met-api/src/met_api/constants/feedback.py | 7 +++ met-api/src/met_api/models/feedback.py | 38 +++++++++++-- met-api/src/met_api/resources/feedback.py | 54 +++++++++++++++---- met-api/src/met_api/schemas/feedback.py | 4 +- .../src/met_api/services/feedback_service.py | 15 ++++++ met-api/tests/unit/api/test_feedback.py | 52 +++++++++++++++++- met-api/tests/unit/models/test_feedback.py | 3 +- met-api/tests/utilities/factory_scenarios.py | 6 +-- met-api/tests/utilities/factory_utils.py | 10 ++-- met-web/src/apiManager/endpoints/index.ts | 2 + met-web/src/models/feedback.ts | 7 +++ .../src/services/feedbackService/index.tsx | 19 ++++++- met-web/src/services/feedbackService/types.ts | 10 ++++ .../feedback/feedbackModal.test.tsx | 3 +- 15 files changed, 246 insertions(+), 24 deletions(-) create mode 100644 met-api/migrations/versions/04e6c48187da_.py diff --git a/met-api/migrations/versions/04e6c48187da_.py b/met-api/migrations/versions/04e6c48187da_.py new file mode 100644 index 000000000..266250c22 --- /dev/null +++ b/met-api/migrations/versions/04e6c48187da_.py @@ -0,0 +1,40 @@ +""" + +Revision ID: 04e6c48187da +Revises: f40da1b8f3e0 +Create Date: 2023-08-18 12:45:30.620941 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '04e6c48187da' +down_revision = 'f40da1b8f3e0' +branch_labels = None +depends_on = None + +# Define the Enum type for feedback status +feedback_status_enum = sa.Enum( + 'Unreviewed', 'Archived', name='feedbackstatustype') + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + + # Create the Enum type in the database + feedback_status_enum.create(op.get_bind()) + + op.add_column('feedback', sa.Column('status', sa.Enum( + 'Unreviewed', 'Archived', name='feedbackstatustype'), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('feedback', 'status') + + # Drop the Enum type from the database + feedback_status_enum.drop(op.get_bind()) + # ### end Alembic commands ### diff --git a/met-api/src/met_api/constants/feedback.py b/met-api/src/met_api/constants/feedback.py index 143551018..25ef33d6b 100644 --- a/met-api/src/met_api/constants/feedback.py +++ b/met-api/src/met_api/constants/feedback.py @@ -35,6 +35,13 @@ class CommentType(IntEnum): Else = 3 +class FeedbackStatusType(IntEnum): + """Status types enum.""" + + Unreviewed = 0 + Archived = 1 + + class FeedbackSourceType(IntEnum): """Source types enum.""" diff --git a/met-api/src/met_api/models/feedback.py b/met-api/src/met_api/models/feedback.py index 55032c2ce..5866498ee 100644 --- a/met-api/src/met_api/models/feedback.py +++ b/met-api/src/met_api/models/feedback.py @@ -7,8 +7,9 @@ from sqlalchemy import TEXT, asc, cast, desc from sqlalchemy.sql import text -from met_api.constants.feedback import CommentType, FeedbackSourceType, RatingType +from met_api.constants.feedback import CommentType, FeedbackSourceType, FeedbackStatusType, RatingType from met_api.models.pagination_options import PaginationOptions + from .base_model import BaseModel from .db import db @@ -18,11 +19,13 @@ class Feedback(BaseModel): __tablename__ = 'feedback' id = db.Column(db.Integer, primary_key=True, autoincrement=True) + status = db.Column(db.Enum(FeedbackStatusType), nullable=True) rating = db.Column(db.Enum(RatingType), nullable=True) comment_type = db.Column(db.Enum(CommentType), nullable=True) comment = db.Column(db.Text, nullable=True) source = db.Column(db.Enum(FeedbackSourceType), nullable=True) - tenant_id = db.Column(db.Integer, db.ForeignKey('tenant.id'), nullable=True) + tenant_id = db.Column( + db.Integer, db.ForeignKey('tenant.id'), nullable=True) @classmethod def get_all_paginated(cls, pagination_options: PaginationOptions, search_text=''): @@ -31,7 +34,8 @@ def get_all_paginated(cls, pagination_options: PaginationOptions, search_text='' if search_text: # Remove all non-digit characters from search text - query = query.filter(cast(Feedback.id, TEXT).like('%' + search_text + '%')) + query = query.filter( + cast(Feedback.id, TEXT).like('%' + search_text + '%')) sort = asc(text(pagination_options.sort_key)) if pagination_options.sort_order == 'asc'\ else desc(text(pagination_options.sort_key)) @@ -43,7 +47,8 @@ def get_all_paginated(cls, pagination_options: PaginationOptions, search_text='' items = query.all() return items, len(items) - page = query.paginate(page=pagination_options.page, per_page=pagination_options.size) + page = query.paginate(page=pagination_options.page, + per_page=pagination_options.size) return page.items, page.total @@ -51,6 +56,7 @@ def get_all_paginated(cls, pagination_options: PaginationOptions, search_text='' def create_feedback(feedback): """Create new feedback entity.""" new_feedback = Feedback( + status=feedback.get('status', None), comment=feedback.get('comment', None), created_date=datetime.utcnow(), rating=feedback.get('rating'), @@ -60,3 +66,27 @@ def create_feedback(feedback): db.session.add(new_feedback) db.session.commit() return new_feedback + + @classmethod + def delete_by_id(cls, feedback_id): + """Delete feedback by ID.""" + feedback = cls.query.get(feedback_id) + if feedback: + db.session.delete(feedback) + db.session.commit() + return True # Successfully deleted + return False # Feedback not found + + @classmethod + def update_feedback(cls, feedback_id, feedback_data): + """Update feedback by ID.""" + feedback = cls.query.get(feedback_id) + if not feedback: + return None # Feedback not found + + for key, value in feedback_data.items(): + if hasattr(feedback, key): + setattr(feedback, key, value) + + db.session.commit() + return feedback diff --git a/met-api/src/met_api/resources/feedback.py b/met-api/src/met_api/resources/feedback.py index 5dbd43a36..449656ac5 100644 --- a/met-api/src/met_api/resources/feedback.py +++ b/met-api/src/met_api/resources/feedback.py @@ -11,7 +11,8 @@ # 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. -"""API endpoints for managing an feedback resource.""" + +"""API endpoints for managing a feedback resource.""" from http import HTTPStatus @@ -21,22 +22,22 @@ from met_api.auth import auth from met_api.auth import jwt as _jwt -from met_api.models.pagination_options import PaginationOptions from met_api.schemas import utils as schema_utils +from met_api.models.pagination_options import PaginationOptions from met_api.services.feedback_service import FeedbackService from met_api.utils.token_info import TokenInfo from met_api.utils.util import allowedorigins, cors_preflight API = Namespace('feedbacks', description='Endpoints for Feedbacks Management') -"""Custom exception messages -""" + +# For operations that don't require an ID @cors_preflight('GET, POST, OPTIONS') -@API.route('/') -class Feedback(Resource): - """Resource for managing feedbacks.""" +@API.route('/', methods=['GET', 'POST']) +class FeedbackList(Resource): + """Feedback List Resource.""" @staticmethod @cross_origin(origins=allowedorigins()) @@ -52,7 +53,8 @@ def get(): sort_key=args.get('sort_key', 'name', str), sort_order=args.get('sort_order', 'asc', str), ) - feedback_records = FeedbackService().get_feedback_paginated(pagination_options, search_text) + feedback_records = FeedbackService().get_feedback_paginated( + pagination_options, search_text) return feedback_records, HTTPStatus.OK except ValueError as err: @@ -66,7 +68,8 @@ def post(): try: user_id = TokenInfo.get_id() request_json = request.get_json() - valid_format, errors = schema_utils.validate(request_json, 'feedback') + valid_format, errors = schema_utils.validate( + request_json, 'feedback') if not valid_format: return {'message': schema_utils.serialize(errors)}, HTTPStatus.BAD_REQUEST result = FeedbackService().create_feedback(request_json, user_id) @@ -75,3 +78,36 @@ def post(): return 'feedback was not found', HTTPStatus.INTERNAL_SERVER_ERROR except ValueError as err: return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + + +@cors_preflight('DELETE, PATCH, OPTIONS') +@API.route('/', methods=['DELETE', 'PATCH']) +class FeedbackById(Resource): + """Feedback Id Resource.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + def delete(feedback_id): + """Remove Feedback for an engagement.""" + try: + result = FeedbackService().delete_feedback(feedback_id) + if result: + return 'Feedback successfully removed', HTTPStatus.OK + return 'Feedback not found', HTTPStatus.NOT_FOUND + except KeyError as err: + return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + except ValueError as err: + return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + def patch(feedback_id): + """Update feedback by ID.""" + feedback_data = request.get_json() + updated_feedback = FeedbackService.update_feedback( + feedback_id, feedback_data) + if updated_feedback: + return updated_feedback, HTTPStatus.OK + return {'message': 'Feedback not found'}, HTTPStatus.NOT_FOUND diff --git a/met-api/src/met_api/schemas/feedback.py b/met-api/src/met_api/schemas/feedback.py index 89d322ac7..24a8fcaa1 100644 --- a/met-api/src/met_api/schemas/feedback.py +++ b/met-api/src/met_api/schemas/feedback.py @@ -2,7 +2,8 @@ from marshmallow import EXCLUDE, Schema, fields from marshmallow_enum import EnumField -from met_api.constants.feedback import CommentType, FeedbackSourceType, RatingType + +from met_api.constants.feedback import CommentType, FeedbackSourceType, FeedbackStatusType, RatingType class FeedbackSchema(Schema): @@ -16,6 +17,7 @@ class Meta: # pylint: disable=too-few-public-methods id = fields.Int(data_key='id') comment = fields.Str(data_key='comment') created_date = fields.DateTime(data_key='created_date') + status = EnumField(FeedbackStatusType, by_value=True) rating = EnumField(RatingType, by_value=True) comment_type = EnumField(CommentType, by_value=True) source = EnumField(FeedbackSourceType, by_value=True) diff --git a/met-api/src/met_api/services/feedback_service.py b/met-api/src/met_api/services/feedback_service.py index 2d5417ada..bbc2b5034 100644 --- a/met-api/src/met_api/services/feedback_service.py +++ b/met-api/src/met_api/services/feedback_service.py @@ -39,3 +39,18 @@ def create_feedback(cls, feedback: FeedbackSchema, user_id): new_feedback = Feedback.create_feedback(feedback) feedback_schema = FeedbackSchema() return feedback_schema.dump(new_feedback) + + @classmethod + def update_feedback(cls, feedback_id, feedback_data): + """Update feedback by its ID.""" + updated_feedback = Feedback.update_feedback(feedback_id, feedback_data) + if updated_feedback: + feedback_schema = FeedbackSchema() + return feedback_schema.dump(updated_feedback) + return None + + @classmethod + def delete_feedback(cls, feedback_id): + """Remove Feedback from engagement.""" + is_deleted = Feedback.delete_by_id(feedback_id) + return is_deleted diff --git a/met-api/tests/unit/api/test_feedback.py b/met-api/tests/unit/api/test_feedback.py index 4773345e6..f86823fb8 100644 --- a/met-api/tests/unit/api/test_feedback.py +++ b/met-api/tests/unit/api/test_feedback.py @@ -18,7 +18,7 @@ """ import json -from met_api.constants.feedback import FeedbackSourceType +from met_api.constants.feedback import FeedbackSourceType, FeedbackStatusType from met_api.utils.enums import ContentType from tests.utilities.factory_scenarios import TestJwtClaims @@ -31,6 +31,7 @@ def test_feedback(client, jwt, session): # pylint:disable=unused-argument feedback = factory_feedback_model() to_dict = { + 'status': feedback.status, 'rating': feedback.rating, 'comment_type': feedback.comment_type, 'comment': feedback.comment @@ -45,6 +46,7 @@ def test_feedback(client, jwt, session): # pylint:disable=unused-argument assert result.get('id') is not None assert result.get('rating') == feedback.rating assert result.get('comment_type') == feedback.comment_type + assert result.get('status') == feedback.status assert result.get('comment') == feedback.comment assert result.get('source') == FeedbackSourceType.Internal @@ -73,3 +75,51 @@ def test_invalid_feedback(client, jwt, session): # pylint:disable=unused-argume headers=headers, content_type=ContentType.JSON.value) print(rv.json.get('message')) assert rating_error_msg in rv.json.get('message') + + +def test_patch_feedback(client, jwt, session): # pylint:disable=unused-argument + """Assert that feedback can be updated via PATCH.""" + # Setup: Create a new feedback first + claims = TestJwtClaims.public_user_role + feedback = factory_feedback_model() + feedback_creation = { + 'status': feedback.status, + 'rating': feedback.rating, + 'comment_type': feedback.comment_type, + 'comment': feedback.comment + } + headers = factory_auth_header(jwt=jwt, claims=claims) + rv = client.post('/api/feedbacks/', data=json.dumps(feedback_creation), + headers=headers, content_type=ContentType.JSON.value) + feedback_id = rv.json.get('id') + + assert rv.status_code == 200 + + feedback_creation['status'] = FeedbackStatusType.Archived.value + + rv = client.patch(f'/api/feedbacks/{feedback_id}', data=json.dumps(feedback_creation), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == 200 + # Check if the status is update + assert rv.json.get('status') == FeedbackStatusType.Archived.value + + +def test_delete_feedback(client, jwt, session): # pylint:disable=unused-argument + """Assert that feedback can be deleted.""" + # Setup: Create a new feedback first + claims = TestJwtClaims.public_user_role + feedback = factory_feedback_model() + feedback_creation = { + 'status': feedback.status, + 'rating': feedback.rating, + 'comment_type': feedback.comment_type, + 'comment': feedback.comment + } + headers = factory_auth_header(jwt=jwt, claims=claims) + rv = client.post('/api/feedbacks/', data=json.dumps(feedback_creation), + headers=headers, content_type=ContentType.JSON.value) + feedback_id = rv.json.get('id') + assert rv.status_code == 200 + # Now, delete this feedback + rv = client.delete(f'/api/feedbacks/{feedback_id}', headers=headers) + assert rv.status_code == 200 diff --git a/met-api/tests/unit/models/test_feedback.py b/met-api/tests/unit/models/test_feedback.py index ef03a3046..d6642c9d9 100644 --- a/met-api/tests/unit/models/test_feedback.py +++ b/met-api/tests/unit/models/test_feedback.py @@ -17,7 +17,7 @@ """ from faker import Faker -from met_api.constants.feedback import FeedbackSourceType +from met_api.constants.feedback import FeedbackSourceType, FeedbackStatusType from met_api.models import Feedback as FeedbackModel from met_api.models.pagination_options import PaginationOptions @@ -31,6 +31,7 @@ def test_feedback(session): feedback = factory_feedback_model() assert feedback.id is not None feedback_existing = FeedbackModel.find_by_id(feedback.id) + assert feedback.status == FeedbackStatusType.Unreviewed assert feedback.comment == feedback_existing.comment assert feedback.source == FeedbackSourceType.Public diff --git a/met-api/tests/utilities/factory_scenarios.py b/met-api/tests/utilities/factory_scenarios.py index d6c103943..fb792e788 100644 --- a/met-api/tests/utilities/factory_scenarios.py +++ b/met-api/tests/utilities/factory_scenarios.py @@ -23,10 +23,9 @@ from met_api.config import get_named_config from met_api.constants.comment_status import Status as CommentStatus -from met_api.constants.engagement_status import SubmissionStatus from met_api.constants.engagement_status import Status as EngagementStatus - -from met_api.constants.feedback import CommentType, FeedbackSourceType, RatingType +from met_api.constants.engagement_status import SubmissionStatus +from met_api.constants.feedback import CommentType, FeedbackSourceType, FeedbackStatusType, RatingType from met_api.constants.widget import WidgetType from met_api.utils.enums import LoginSource, UserStatus @@ -235,6 +234,7 @@ class TestFeedbackInfo(dict, Enum): """Test scenarios of feedback.""" feedback1 = { + 'status': FeedbackStatusType.Unreviewed, 'comment': 'A feedback comment', 'rating': RatingType.Satisfied, 'comment_type': CommentType.Idea, diff --git a/met-api/tests/utilities/factory_utils.py b/met-api/tests/utilities/factory_utils.py index 9f4ffe8f6..4b221fc12 100644 --- a/met-api/tests/utilities/factory_utils.py +++ b/met-api/tests/utilities/factory_utils.py @@ -154,7 +154,8 @@ def factory_tenant_model(tenant_info: dict = TestTenantInfo.tenant1): def factory_staff_user_model(external_id=None, user_info: dict = TestUserInfo.user_staff_1): """Produce a staff user model.""" # Generate a external id if not passed - external_id = fake.random_number(digits=5) if external_id is None else external_id + external_id = fake.random_number( + digits=5) if external_id is None else external_id user = StaffUserModel( first_name=user_info['first_name'], last_name=user_info['last_name'], @@ -170,7 +171,8 @@ def factory_staff_user_model(external_id=None, user_info: dict = TestUserInfo.us def factory_participant_model(participant: dict = TestParticipantInfo.participant1): """Produce a participant model.""" participant = ParticipantModel( - email_address=ParticipantModel.encode_email(participant['email_address']), + email_address=ParticipantModel.encode_email( + participant['email_address']), ) participant.save() return participant @@ -193,6 +195,7 @@ def factory_membership_model(user_id, engagement_id, member_type='TEAM_MEMBER', def factory_feedback_model(feedback_info: dict = TestFeedbackInfo.feedback1, status=None): """Produce a feedback model.""" feedback = FeedbackModel( + status=feedback_info.get('status'), comment=fake.text(), rating=feedback_info.get('rating'), comment_type=feedback_info.get('comment_type'), @@ -292,7 +295,8 @@ def token_info(): """Return token info.""" return claims - monkeypatch.setattr('met_api.utils.user_context._get_token_info', token_info) + monkeypatch.setattr( + 'met_api.utils.user_context._get_token_info', token_info) def factory_engagement_slug_model(eng_slug_info: dict = TestEngagementSlugInfo.slug1): diff --git a/met-web/src/apiManager/endpoints/index.ts b/met-web/src/apiManager/endpoints/index.ts index 47a7cec62..a6dca58f3 100644 --- a/met-web/src/apiManager/endpoints/index.ts +++ b/met-web/src/apiManager/endpoints/index.ts @@ -76,6 +76,8 @@ const Endpoints = { Feedback: { GET_LIST: `${AppConfig.apiUrl}/feedbacks/`, CREATE: `${AppConfig.apiUrl}/feedbacks/`, + UPDATE: `${AppConfig.apiUrl}/feedbacks/`, + DELETE: `${AppConfig.apiUrl}/feedbacks/`, }, EmailVerification: { GET: `${AppConfig.apiUrl}/email_verification/verification_token`, diff --git a/met-web/src/models/feedback.ts b/met-web/src/models/feedback.ts index e5b84a32c..81276386f 100644 --- a/met-web/src/models/feedback.ts +++ b/met-web/src/models/feedback.ts @@ -4,6 +4,12 @@ export interface Feedback { comment: string; comment_type: CommentTypeEnum; source: SourceTypeEnum; + status: FeedbackStatusEnum; +} + +export enum FeedbackStatusEnum { + NotReviewed = 0, + Archived = 1, } export enum RatingTypeEnum { @@ -29,5 +35,6 @@ export const createDefaultFeedback = (): Feedback => { comment_type: CommentTypeEnum.None, created_date: '', source: SourceTypeEnum.Public, + status: FeedbackStatusEnum.NotReviewed, }; }; diff --git a/met-web/src/services/feedbackService/index.tsx b/met-web/src/services/feedbackService/index.tsx index d52671ed4..36647e3ae 100644 --- a/met-web/src/services/feedbackService/index.tsx +++ b/met-web/src/services/feedbackService/index.tsx @@ -2,7 +2,7 @@ import http from 'apiManager/httpRequestHandler'; import Endpoints from 'apiManager/endpoints'; import { Page } from 'services/type'; import { Feedback } from 'models/feedback'; -import { GetFeedbackRequest, PostFeedbackRequest } from './types'; +import { GetFeedbackRequest, PostFeedbackRequest, UpdateFeedbackRequest } from './types'; export const getFeedbacksPage = async ({ page, @@ -33,3 +33,20 @@ export const createFeedback = async (feedback: PostFeedbackRequest): Promise => { + const url = `${Endpoints.Feedback.UPDATE}/${feedback_id}`; + const response = await http.PatchRequest(url, feedback); + if (response.data) { + return response.data; + } + return Promise.reject('Failed to update feedback'); +}; + +export const deleteFeedback = async (feedback_id: number): Promise => { + const url = `${Endpoints.Feedback.DELETE}/${feedback_id}`; + const response = await http.DeleteRequest(url); + if (response.status !== 200) { + return Promise.reject('Failed to delete feedback'); + } +}; diff --git a/met-web/src/services/feedbackService/types.ts b/met-web/src/services/feedbackService/types.ts index 2a0624082..1d5183356 100644 --- a/met-web/src/services/feedbackService/types.ts +++ b/met-web/src/services/feedbackService/types.ts @@ -1,3 +1,5 @@ +import { FeedbackStatusEnum } from 'models/feedback'; + export interface GetFeedbackRequest { page?: number; size?: number; @@ -10,4 +12,12 @@ export interface PostFeedbackRequest { rating: number; comment_type: number; comment: string; + status: FeedbackStatusEnum; +} + +export interface UpdateFeedbackRequest { + rating?: number; + comment_type?: number; + comment?: string; + status?: FeedbackStatusEnum; } diff --git a/met-web/tests/unit/components/feedback/feedbackModal.test.tsx b/met-web/tests/unit/components/feedback/feedbackModal.test.tsx index 20a4a3da1..a65b7112d 100644 --- a/met-web/tests/unit/components/feedback/feedbackModal.test.tsx +++ b/met-web/tests/unit/components/feedback/feedbackModal.test.tsx @@ -6,7 +6,7 @@ import * as feedbackService from 'services/feedbackService'; import '@testing-library/jest-dom'; import { setupEnv } from '../setEnvVars'; import { FeedbackModal } from 'components/feedback/FeedbackModal'; -import { CommentTypeEnum, SourceTypeEnum } from 'models/feedback'; +import { CommentTypeEnum, FeedbackStatusEnum, SourceTypeEnum } from 'models/feedback'; describe('Feedback modal tests', () => { jest.spyOn(reactRedux, 'useDispatch').mockImplementation(() => jest.fn()); @@ -47,6 +47,7 @@ describe('Feedback modal tests', () => { test('Submit shows thank you message', async () => { createFeedbackMock.mockReturnValue( Promise.resolve({ + status: FeedbackStatusEnum.NotReviewed, comment_type: CommentTypeEnum.None, comment: '', rating: 1,