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 @@
+
+
+
+
+ Delete Feedback?
+
+
+
+ Are you sure you would like to delete this feedback?
+
+
+
+
+ Cancel
+ Delete
+
+
+
+
+
+
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 @@
+
+
+
+
+
+ Oncall feedback
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ (anonymous)
+
+
+
+
+ {{
+ item.shift_end_at | formatToUTC
+ }}
+
+ {{ item.shift_end_at | formatToTimeZones }}
+
+
+
+
+
+ {{ item.created_at | formatRelativeDate }}
+
+ {{ item.created_at | formatDate }}
+
+
+
+
+ {{ item.project.name }}
+
+
+
+
+
+
+ mdi-dots-vertical
+
+
+
+
+ Delete
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+ Filter
+
+
+
+
+ Service Feedback Filters
+
+
+
+
+
+
+
+
+
+
+ Apply Filters
+
+
+
+
+
+
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,