Skip to content

Commit

Permalink
Adds new oncall service feedback table (#3856)
Browse files Browse the repository at this point in the history
  • Loading branch information
whitdog47 authored Oct 11, 2023
1 parent 22f4253 commit ba15aee
Show file tree
Hide file tree
Showing 19 changed files with 566 additions and 34 deletions.
30 changes: 30 additions & 0 deletions src/dispatch/auth/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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 ###
19 changes: 8 additions & 11 deletions src/dispatch/feedback/service/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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(
Expand All @@ -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):
Expand Down
5 changes: 4 additions & 1 deletion src/dispatch/feedback/service/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
39 changes: 37 additions & 2 deletions src/dispatch/feedback/service/views.py
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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)
2 changes: 2 additions & 0 deletions src/dispatch/individual/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down
12 changes: 8 additions & 4 deletions src/dispatch/plugins/dispatch_slack/feedback/interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -409,14 +410,17 @@ 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,
rating=ServiceFeedbackRating(rating),
schedule=schedule_id,
shift_end_at=shift_end_at,
shift_start_at=None,
project=project,
)

service_feedback = feedback_service.create(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
},
}
</script>
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<delete-dialog />
<v-row no-gutters>
<v-col>
<div class="headline">Feedback</div>
<div class="headline">Incident feedback</div>
</v-col>
<v-col class="text-right">
<table-filter-dialog :projects="defaultUserProjects" />
Expand Down Expand Up @@ -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",
Expand All @@ -104,7 +104,7 @@ export default {
},
computed: {
...mapFields("feedback", [
...mapFields("incident_feedback", [
"table.options.q",
"table.options.page",
"table.options.itemsPerPage",
Expand Down Expand Up @@ -136,7 +136,7 @@ export default {
},
methods: {
...mapActions("feedback", ["getAll", "removeShow"]),
...mapActions("incident_feedback", ["getAll", "removeShow"]),
},
created() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
37 changes: 37 additions & 0 deletions src/dispatch/static/dispatch/src/feedback/service/DeleteDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<template>
<v-dialog v-model="showRemove" persistent max-width="800px">
<v-card>
<v-card-title>
<span class="headline">Delete Feedback?</span>
</v-card-title>
<v-card-text>
<v-container grid-list-md>
<v-layout wrap> Are you sure you would like to delete this feedback? </v-layout>
</v-container>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="blue en-1" text @click="closeRemove()"> Cancel </v-btn>
<v-btn color="red en-1" text @click="remove()"> Delete </v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>

<script>
import { mapActions } from "vuex"
import { mapFields } from "vuex-map-fields"
export default {
name: "ServiceFeedbackDeleteDialog",
data() {
return {}
},
computed: {
...mapFields("service_feedback", ["dialogs.showRemove"]),
},
methods: {
...mapActions("service_feedback", ["remove", "closeRemove"]),
},
}
</script>
Loading

0 comments on commit ba15aee

Please sign in to comment.