diff --git a/src/dispatch/auth/permissions.py b/src/dispatch/auth/permissions.py index d4936f03b3e4..98d0262a57bd 100644 --- a/src/dispatch/auth/permissions.py +++ b/src/dispatch/auth/permissions.py @@ -436,3 +436,33 @@ def has_required_permissions( participant.individual.email for participant in current_case.participants ] return current_user.email in participant_emails + + +class FeedbackDeletePermission(BasePermission): + def has_required_permissions( + self, + request: Request, + ) -> bool: + permission = any_permission( + permissions=[ + SensitiveProjectActionPermission, + ], + request=request, + ) + if not permission: + individual_contact_id = request.path_params.get("individual_contact_id", "0") + # "0" is passed if the feedback is anonymous + if individual_contact_id != "0": + pk = PrimaryKeyModel(id=individual_contact_id) + individual_contact = individual_contact_service.get( + db_session=request.state.db, individual_contact_id=pk.id + ) + + if not individual_contact: + return False + + current_user = get_current_user(request=request) + if individual_contact.email == current_user.email: + return True + + return permission diff --git a/src/dispatch/database/revisions/tenant/versions/2023-10-06_064c71206256.py b/src/dispatch/database/revisions/tenant/versions/2023-10-06_064c71206256.py new file mode 100644 index 000000000000..145a3b2eae55 --- /dev/null +++ b/src/dispatch/database/revisions/tenant/versions/2023-10-06_064c71206256.py @@ -0,0 +1,35 @@ +"""Adds project to the service feedback table and allows hours to be fractional + +Revision ID: 064c71206256 +Revises: 3538650dc471 +Create Date: 2023-10-06 09:53:50.119947 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "064c71206256" +down_revision = "3538650dc471" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("service_feedback", sa.Column("project_id", sa.Integer(), nullable=True)) + op.create_foreign_key(None, "service_feedback", "project", ["project_id"], ["id"]) + op.alter_column( + "service_feedback", "hours", existing_type=sa.Integer(), type_=sa.Numeric(), nullable=True + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, "service_feedback", type_="foreignkey") + op.drop_column("service_feedback", "project_id") + op.alter_column( + "service_feedback", "hours", existing_type=sa.Numeric(), type_=sa.Integer(), nullable=True + ) + # ### end Alembic commands ### diff --git a/src/dispatch/feedback/service/models.py b/src/dispatch/feedback/service/models.py index e2773f347bac..df520c9306f6 100644 --- a/src/dispatch/feedback/service/models.py +++ b/src/dispatch/feedback/service/models.py @@ -2,8 +2,7 @@ from pydantic import Field from typing import Optional, List -from sqlalchemy import Column, Integer, ForeignKey, DateTime, String -from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy import Column, Integer, ForeignKey, DateTime, String, Numeric from sqlalchemy_utils import TSVectorType from sqlalchemy.orm import relationship @@ -18,16 +17,16 @@ class ServiceFeedback(TimeStampMixin, FeedbackMixin, Base): # Columns id = Column(Integer, primary_key=True) - feedback = Column(String) - rating = Column(String) schedule = Column(String) - hours = Column(Integer) + hours = Column(Numeric(precision=10, scale=2)) shift_start_at = Column(DateTime) shift_end_at = Column(DateTime) # Relationships individual_contact_id = Column(Integer, ForeignKey("individual_contact.id")) - individual = relationship("IndividualContact") + + project_id = Column(Integer, ForeignKey("project.id")) + project = relationship("Project") search_vector = Column( TSVectorType( @@ -37,20 +36,18 @@ class ServiceFeedback(TimeStampMixin, FeedbackMixin, Base): ) ) - @hybrid_property - def project(self): - return self.individual.project - # Pydantic models class ServiceFeedbackBase(DispatchBase): feedback: Optional[str] = Field(None, nullable=True) - hours: Optional[int] + hours: Optional[float] individual: Optional[IndividualContactReadMinimal] rating: ServiceFeedbackRating = ServiceFeedbackRating.little_effort schedule: Optional[str] shift_end_at: Optional[datetime] shift_start_at: Optional[datetime] + project: Optional[ProjectRead] + created_at: Optional[datetime] class ServiceFeedbackCreate(ServiceFeedbackBase): diff --git a/src/dispatch/feedback/service/service.py b/src/dispatch/feedback/service/service.py index 368dc059bace..3e0bea372229 100644 --- a/src/dispatch/feedback/service/service.py +++ b/src/dispatch/feedback/service/service.py @@ -26,9 +26,12 @@ def create(*, service_feedback_in: ServiceFeedbackCreate, db_session: Session) - None if not service_feedback_in.individual else service_feedback_in.individual.id ) + project_id = None if not service_feedback_in.project else service_feedback_in.project.id + service_feedback = ServiceFeedback( - **service_feedback_in.dict(exclude={"individual"}), + **service_feedback_in.dict(exclude={"individual", "project"}), individual_contact_id=individual_contact_id, + project_id=project_id, ) db_session.add(service_feedback) db_session.commit() diff --git a/src/dispatch/feedback/service/views.py b/src/dispatch/feedback/service/views.py index 280ec390ac5e..cb1606c616c5 100644 --- a/src/dispatch/feedback/service/views.py +++ b/src/dispatch/feedback/service/views.py @@ -1,8 +1,15 @@ -from fastapi import APIRouter +from fastapi import APIRouter, HTTPException, status, Depends +from dispatch.auth.permissions import ( + FeedbackDeletePermission, + PermissionsDependency, +) +from dispatch.database.core import DbSession from dispatch.database.service import search_filter_sort_paginate, CommonParameters +from dispatch.models import PrimaryKey -from .models import ServiceFeedbackPagination +from .models import ServiceFeedbackRead, ServiceFeedbackPagination +from .service import get, delete router = APIRouter() @@ -12,3 +19,31 @@ def get_feedback_entries(commons: CommonParameters): """Get all feedback entries, or only those matching a given search term.""" return search_filter_sort_paginate(model="ServiceFeedback", **commons) + + +@router.get("/{service_feedback_id}", response_model=ServiceFeedbackRead) +def get_feedback(db_session: DbSession, service_feedback_id: PrimaryKey): + """Get a feedback entry by its id.""" + feedback = get(db_session=db_session, service_feedback_id=service_feedback_id) + if not feedback: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=[{"msg": "A feedback entry with this id does not exist."}], + ) + return feedback + + +@router.delete( + "/{service_feedback_id}/{individual_contact_id}", + response_model=None, + dependencies=[Depends(PermissionsDependency([FeedbackDeletePermission]))], +) +def delete_feedback(db_session: DbSession, service_feedback_id: PrimaryKey): + """Delete a feedback entry, returning only an HTTP 200 OK if successful.""" + feedback = get(db_session=db_session, service_feedback_id=service_feedback_id) + if not feedback: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=[{"msg": "A feedback entry with this id does not exist."}], + ) + delete(db_session=db_session, service_feedback_id=service_feedback_id) diff --git a/src/dispatch/individual/models.py b/src/dispatch/individual/models.py index 6d53a32249e7..b21d9941b231 100644 --- a/src/dispatch/individual/models.py +++ b/src/dispatch/individual/models.py @@ -42,6 +42,8 @@ class IndividualContact(Base, ContactMixin, ProjectMixin): external_id = Column(String) events = relationship("Event", backref="individual") + service_feedback = relationship("ServiceFeedback", backref="individual") + filters = relationship( "SearchFilter", secondary=assoc_individual_filters, backref="individuals" ) diff --git a/src/dispatch/plugins/dispatch_slack/feedback/interactive.py b/src/dispatch/plugins/dispatch_slack/feedback/interactive.py index ea61d7665323..4ede823ed9ec 100644 --- a/src/dispatch/plugins/dispatch_slack/feedback/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/feedback/interactive.py @@ -20,11 +20,12 @@ from dispatch.feedback.incident.models import FeedbackCreate from dispatch.feedback.service import service as feedback_service from dispatch.individual import service as individual_service -from dispatch.feedback.service.models import ServiceFeedbackRating +from dispatch.feedback.service.models import ServiceFeedbackRating, ServiceFeedbackCreate from dispatch.incident import service as incident_service from dispatch.participant import service as participant_service from dispatch.feedback.service.reminder import service as reminder_service from dispatch.plugin import service as plugin_service +from dispatch.project import service as project_service from dispatch.plugins.dispatch_slack.bolt import app from dispatch.plugins.dispatch_slack.fields import static_select_block from dispatch.plugins.dispatch_slack.middleware import ( @@ -375,8 +376,8 @@ def handle_oncall_shift_feedback_submission_event( db_session: Session, form_data: dict, ): - hours = form_data.get(ServiceFeedbackNotificationBlockIds.hours_input, {}) - if not hours.isnumeric(): + hours = form_data.get(ServiceFeedbackNotificationBlockIds.hours_input, "") + if not hours.replace(".", "", 1).isdigit(): ack_with_error(ack=ack) return @@ -409,7 +410,9 @@ def handle_oncall_shift_feedback_submission_event( ) ) - service_feedback = feedback_service.ServiceFeedbackCreate( + project = project_service.get(db_session=db_session, project_id=project_id) + + service_feedback = ServiceFeedbackCreate( feedback=feedback, hours=hours, individual=individual, @@ -417,6 +420,7 @@ def handle_oncall_shift_feedback_submission_event( schedule=schedule_id, shift_end_at=shift_end_at, shift_start_at=None, + project=project, ) service_feedback = feedback_service.create( diff --git a/src/dispatch/static/dispatch/src/feedback/DeleteDialog.vue b/src/dispatch/static/dispatch/src/feedback/incident/DeleteDialog.vue similarity index 86% rename from src/dispatch/static/dispatch/src/feedback/DeleteDialog.vue rename to src/dispatch/static/dispatch/src/feedback/incident/DeleteDialog.vue index a49c9f81b9e5..35a0f21ac79b 100644 --- a/src/dispatch/static/dispatch/src/feedback/DeleteDialog.vue +++ b/src/dispatch/static/dispatch/src/feedback/incident/DeleteDialog.vue @@ -27,11 +27,11 @@ export default { return {} }, computed: { - ...mapFields("feedback", ["dialogs.showRemove"]), + ...mapFields("incident_feedback", ["dialogs.showRemove"]), }, methods: { - ...mapActions("feedback", ["remove", "closeRemove"]), + ...mapActions("incident_feedback", ["remove", "closeRemove"]), }, } diff --git a/src/dispatch/static/dispatch/src/feedback/Table.vue b/src/dispatch/static/dispatch/src/feedback/incident/Table.vue similarity index 94% rename from src/dispatch/static/dispatch/src/feedback/Table.vue rename to src/dispatch/static/dispatch/src/feedback/incident/Table.vue index 60e2cbc25f5e..d018bf9f0a86 100644 --- a/src/dispatch/static/dispatch/src/feedback/Table.vue +++ b/src/dispatch/static/dispatch/src/feedback/incident/Table.vue @@ -3,7 +3,7 @@ -
Feedback
+
Incident feedback
@@ -74,10 +74,10 @@ import { mapFields } from "vuex-map-fields" import { mapActions } from "vuex" -import DeleteDialog from "@/feedback/DeleteDialog.vue" +import DeleteDialog from "@/feedback/incident/DeleteDialog.vue" import Participant from "@/incident/Participant.vue" import RouterUtils from "@/router/utils" -import TableFilterDialog from "@/feedback/TableFilterDialog.vue" +import TableFilterDialog from "@/feedback/incident/TableFilterDialog.vue" export default { name: "FeedbackTable", @@ -104,7 +104,7 @@ export default { }, computed: { - ...mapFields("feedback", [ + ...mapFields("incident_feedback", [ "table.options.q", "table.options.page", "table.options.itemsPerPage", @@ -136,7 +136,7 @@ export default { }, methods: { - ...mapActions("feedback", ["getAll", "removeShow"]), + ...mapActions("incident_feedback", ["getAll", "removeShow"]), }, created() { diff --git a/src/dispatch/static/dispatch/src/feedback/TableFilterDialog.vue b/src/dispatch/static/dispatch/src/feedback/incident/TableFilterDialog.vue similarity index 93% rename from src/dispatch/static/dispatch/src/feedback/TableFilterDialog.vue rename to src/dispatch/static/dispatch/src/feedback/incident/TableFilterDialog.vue index f97db440bca1..355733a81209 100644 --- a/src/dispatch/static/dispatch/src/feedback/TableFilterDialog.vue +++ b/src/dispatch/static/dispatch/src/feedback/incident/TableFilterDialog.vue @@ -62,7 +62,10 @@ export default { }, computed: { - ...mapFields("feedback", ["table.options.filters.incident", "table.options.filters.project"]), + ...mapFields("incident_feedback", [ + "table.options.filters.incident", + "table.options.filters.project", + ]), numFilters: function () { return sum([this.incident.length, this.project.length]) diff --git a/src/dispatch/static/dispatch/src/feedback/api.js b/src/dispatch/static/dispatch/src/feedback/incident/api.js similarity index 100% rename from src/dispatch/static/dispatch/src/feedback/api.js rename to src/dispatch/static/dispatch/src/feedback/incident/api.js diff --git a/src/dispatch/static/dispatch/src/feedback/store.js b/src/dispatch/static/dispatch/src/feedback/incident/store.js similarity index 98% rename from src/dispatch/static/dispatch/src/feedback/store.js rename to src/dispatch/static/dispatch/src/feedback/incident/store.js index 0c8bf4cea0e8..1b7258c70203 100644 --- a/src/dispatch/static/dispatch/src/feedback/store.js +++ b/src/dispatch/static/dispatch/src/feedback/incident/store.js @@ -2,7 +2,7 @@ import { getField, updateField } from "vuex-map-fields" import { debounce } from "lodash" import SearchUtils from "@/search/utils" -import FeedbackApi from "@/feedback/api" +import FeedbackApi from "@/feedback/incident/api" const getDefaultSelectedState = () => { return { diff --git a/src/dispatch/static/dispatch/src/feedback/service/DeleteDialog.vue b/src/dispatch/static/dispatch/src/feedback/service/DeleteDialog.vue new file mode 100644 index 000000000000..9c597cb01d8e --- /dev/null +++ b/src/dispatch/static/dispatch/src/feedback/service/DeleteDialog.vue @@ -0,0 +1,37 @@ + + + diff --git a/src/dispatch/static/dispatch/src/feedback/service/Table.vue b/src/dispatch/static/dispatch/src/feedback/service/Table.vue new file mode 100644 index 000000000000..a6f797fcdff9 --- /dev/null +++ b/src/dispatch/static/dispatch/src/feedback/service/Table.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/src/dispatch/static/dispatch/src/feedback/service/TableFilterDialog.vue b/src/dispatch/static/dispatch/src/feedback/service/TableFilterDialog.vue new file mode 100644 index 000000000000..df6ef699c8fc --- /dev/null +++ b/src/dispatch/static/dispatch/src/feedback/service/TableFilterDialog.vue @@ -0,0 +1,74 @@ + + + diff --git a/src/dispatch/static/dispatch/src/service_feedback/api.js b/src/dispatch/static/dispatch/src/feedback/service/api.js similarity index 60% rename from src/dispatch/static/dispatch/src/service_feedback/api.js rename to src/dispatch/static/dispatch/src/feedback/service/api.js index 95b15f247874..b90296bc44df 100644 --- a/src/dispatch/static/dispatch/src/service_feedback/api.js +++ b/src/dispatch/static/dispatch/src/feedback/service/api.js @@ -6,4 +6,8 @@ export default { getAll(options) { return API.get(`${resource}`, { params: { ...options } }) }, + + delete(feedbackId, individualId) { + return API.delete(`${resource}/${feedbackId}/${individualId}`) + }, } diff --git a/src/dispatch/static/dispatch/src/feedback/service/store.js b/src/dispatch/static/dispatch/src/feedback/service/store.js new file mode 100644 index 000000000000..65bc2edfdc0e --- /dev/null +++ b/src/dispatch/static/dispatch/src/feedback/service/store.js @@ -0,0 +1,109 @@ +import { getField, updateField } from "vuex-map-fields" +import { debounce } from "lodash" + +import SearchUtils from "@/search/utils" +import ServiceFeedbackApi from "@/feedback/service/api" + +const getDefaultSelectedState = () => { + return { + feedback: null, + id: null, + incident: null, + individual: null, + project: null, + rating: null, + loading: false, + } +} + +const state = { + selected: { + ...getDefaultSelectedState(), + }, + dialogs: { + showRemove: false, + }, + table: { + rows: { + items: [], + total: null, + }, + options: { + q: "", + page: 1, + itemsPerPage: 10, + sortBy: ["created_at"], + descending: [true], + filters: { + project: [], + }, + }, + loading: false, + }, +} + +const getters = { + getField, +} + +const actions = { + getAll: debounce(({ commit, state }) => { + commit("SET_TABLE_LOADING", "primary") + let params = SearchUtils.createParametersFromTableOptions( + { ...state.table.options }, + "ServiceFeedback" + ) + return ServiceFeedbackApi.getAll(params).then((response) => { + commit("SET_TABLE_LOADING", false) + commit("SET_TABLE_ROWS", response.data) + }) + }, 500), + removeShow({ commit }, feedback) { + commit("SET_DIALOG_DELETE", true) + commit("SET_SELECTED", feedback) + }, + closeRemove({ commit }) { + commit("SET_DIALOG_DELETE", false) + commit("RESET_SELECTED") + }, + remove({ commit, dispatch }) { + return ServiceFeedbackApi.delete(state.selected.id, state.selected.individual?.id || "0").then( + function () { + dispatch("closeRemove") + dispatch("getAll") + commit( + "notification_backend/addBeNotification", + { text: "Feedback deleted successfully.", type: "success" }, + { root: true } + ) + } + ) + }, +} + +const mutations = { + updateField, + SET_SELECTED(state, value) { + state.selected = Object.assign(state.selected, value) + }, + SET_TABLE_LOADING(state, value) { + state.table.loading = value + }, + SET_TABLE_ROWS(state, value) { + state.table.rows = value + }, + SET_DIALOG_DELETE(state, value) { + state.dialogs.showRemove = value + }, + RESET_SELECTED(state) { + state.selected = Object.assign(state.selected, getDefaultSelectedState()) + }, +} + +export default { + namespaced: true, + state, + getters, + actions, + mutations, +} diff --git a/src/dispatch/static/dispatch/src/router/config.js b/src/dispatch/static/dispatch/src/router/config.js index be12fbd66e8a..ae897a62eecf 100644 --- a/src/dispatch/static/dispatch/src/router/config.js +++ b/src/dispatch/static/dispatch/src/router/config.js @@ -286,7 +286,7 @@ export const protectedRoute = [ path: "feedback", component: DefaultLayout, name: "feedback", - redirect: { name: "FeedbackTable" }, + redirect: { name: "IncidentFeedbackTable" }, meta: { title: "Feedback", icon: "mdi-message-alert", @@ -296,10 +296,16 @@ export const protectedRoute = [ }, children: [ { - path: "/:organization/feedback", - name: "FeedbackTable", - meta: { title: "Feedback" }, - component: () => import("@/feedback/Table.vue"), + path: "/:organization/feedback/incident", + name: "IncidentFeedbackTable", + meta: { title: "Incident feedback", group: "feedback" }, + component: () => import("@/feedback/incident/Table.vue"), + }, + { + path: "/:organization/feedback/service", + name: "ServiceFeedbackTable", + meta: { title: "Oncall feedback", group: "feedback" }, + component: () => import("@/feedback/service/Table.vue"), }, ], }, diff --git a/src/dispatch/static/dispatch/src/store.js b/src/dispatch/static/dispatch/src/store.js index fce02f0db745..68c61635e4bf 100644 --- a/src/dispatch/static/dispatch/src/store.js +++ b/src/dispatch/static/dispatch/src/store.js @@ -12,7 +12,7 @@ import definition from "@/definition/store" import document from "@/document/store" import entity from "@/entity/store" import entity_type from "@/entity_type/store" -import feedback from "@/feedback/store" +import incident_feedback from "@/feedback/incident/store" import incident from "@/incident/store" import incident_cost_type from "@/incident_cost_type/store" import incident_priority from "@/incident/priority/store" @@ -30,6 +30,7 @@ import reference from "@/document/reference/store" import runbook from "@/document/runbook/store" import search from "@/search/store" import service from "@/service/store" +import service_feedback from "@/feedback/service/store" import signal from "@/signal/store" import signalEngagement from "@/signal/engagement/store" import signalFilter from "@/signal/filter/store" @@ -61,7 +62,7 @@ export default new Vuex.Store({ document, entity, entity_type, - feedback, + incident_feedback, incident, incident_cost_type, incident_priority, @@ -85,6 +86,7 @@ export default new Vuex.Store({ runbook, search, service, + service_feedback, source, sourceDataFormat, sourceEnvironment,