diff --git a/docs/docs/user-guide/cost_model.mdx b/docs/docs/user-guide/cost_model.mdx new file mode 100644 index 000000000000..7f77386fed52 --- /dev/null +++ b/docs/docs/user-guide/cost_model.mdx @@ -0,0 +1,105 @@ +# Cost Model + +Our Cost Model is a feature that enables teams to estimate response cost for each incident. Users can opt in to create and use personalized cost calculations for each incident based on participant activity. + +If no cost model is assigned to an incident, the default classic cost model will be used. See [Incident Cost Type](../administration/settings/incident/incident-cost-type.mdx###calculating-incident-cost). + +
+ +![](/img/admin-ui-cost-model.png) + +
+ +## Key Features + +### Customizable Cost Models +Users have the flexibility to define their unique cost models based on their organization's workflow and tools. This customization can be tailored to each incident, providing a versitile approach to cost calculation. The cost model for an incident can be changed at any time during its lifespan. All participant activity costs moving forward will be calculated using the new cost model. + +### Plugin-Based Tracking +Users can track costs from their existing tools by using our plugin-based tracking system. Users have the flexibility to select which plugins and specific plugin events they want to track, offering a targeted approach to cost calculation. + +### Effort Assignment +For each tracked activity, users can assign a quantifiable measure of effort, represented in seconds of work time. This feature provides a more accurate representation of the cost of an incident. + +### Incident Cost Calculation +Incident cost calculation is based on the cost model and effort assignment for each tracked participant activity. This helps in understanding resource utilization and cost of an incident. + + +## Currently Supported Plugin Events + +### Slack: Channel Activity +This event tracks activity within a specific Slack channel. By periodically polling channel messages, this gathers insights into the activity and engagement levels of each participant. + +### Slack: Thread Activity +This event tracks activity within a specific Slack thread. By periodically polling thread replies, this gathers insights into the activity and engagement levels of each participant. + + +
+ +![](/img/admin-ui-edit-cost-model.png) + +
+ +## Cost Calculation Examples + +Below, we illustrate the use of the cost model through two examples. These are based on the following values: + +Cost Model 1 + +| Plugin Event | Response Time (seconds) +| ------------ | ------------- +| Slack Channel Activity | 300 + +The employee hourly rate can be adjusted by modifying the `Annual Employee Cost` and `Business Year Hours` fields in the [project settings](../administration/settings/project.mdx). In these examples, we will use the following value: +``` +hourly_rate = 100 +``` + +#### Example 1 + +Consider the following Slack channel activity for `Incident 1`: + +| Slack Channel Activity Timestamp | Participant +| ------------ | ------------- +| 100 | Cookie Doe +| 200 | Nate Flex + +The resulting recorded participant activity will be: + +| Participant | started_at | ended_at | Plugin Event | Incident +| ------------ | ------------- | ------------- | ------------- | ------------- +| Cookie Doe | 100 | 400 | Slack Channel Activity | Incident 1 +| Nate Flex | 200 | 500 | Slack Channel Activity | Incident 1 + + +The incident cost is then calculated as: + +``` +( (400 - 100) + (500-200) ) / SECONDS_IN_HOUR * hourly_rate = $16.67 +``` + +#### Example 2 + +Consider the following Slack channel activity for `Incident 2`: + +| Slack Channel Activity Timestamp | Participant +| ------------ | ------------- +| 100 | Cookie Doe +| 150 | Cookie Doe +| 200 | Nate Flex +| 500 | Cookie Doe + +The resulting recorded participant activity will be: + +| Participant | started_at | ended_at | Plugin Event | Incident +| ------------ | ------------- | ------------- | ------------- | ------------- +| Cookie Doe | 100 | 450 | Slack Channel Activity | Incident 2 +| Nate Flex | 200 | 500 | Slack Channel Activity | Incident 2 +| Cookie Doe | 500 | 800 | Slack Channel Activity | Incident 2 + + +The incident cost is then calculated as: + +``` +( (450 - 100) + (500 - 200) + (800 - 500) ) / SECONDS_IN_HOUR * hourly_rate = $26.39 +``` diff --git a/docs/static/img/admin-ui-cost-model.png b/docs/static/img/admin-ui-cost-model.png new file mode 100644 index 000000000000..c0148fc5bf35 Binary files /dev/null and b/docs/static/img/admin-ui-cost-model.png differ diff --git a/docs/static/img/admin-ui-edit-cost-model.png b/docs/static/img/admin-ui-edit-cost-model.png new file mode 100644 index 000000000000..aefb98d84acd Binary files /dev/null and b/docs/static/img/admin-ui-edit-cost-model.png differ diff --git a/src/dispatch/__init__.py b/src/dispatch/__init__.py index 43005942e761..4f9fc2738f83 100644 --- a/src/dispatch/__init__.py +++ b/src/dispatch/__init__.py @@ -20,6 +20,10 @@ from dispatch.route.models import Recommendation # noqa lgtm[py/unused-import] from dispatch.conference.models import Conference # noqa lgtm[py/unused-import] from dispatch.conversation.models import Conversation # noqa lgtm[py/unused-import] + from dispatch.cost_model.models import ( + CostModel, # noqa lgtm[py/unused-import] + CostModelActivity, # noqa lgtm[py/unused-import] + ) from dispatch.definition.models import Definition # noqa lgtm[py/unused-import] from dispatch.document.models import Document # noqa lgtm[py/unused-import] from dispatch.event.models import Event # noqa lgtm[py/unused-import] @@ -37,8 +41,11 @@ from dispatch.individual.models import IndividualContact # noqa lgtm[py/unused-import] from dispatch.notification.models import Notification # noqa lgtm[py/unused-import] from dispatch.participant.models import Participant # noqa lgtm[py/unused-import] + from dispatch.participant_activity.models import ( + ParticipantActivity, # noqa lgtm[py/unused-import] + ) from dispatch.participant_role.models import ParticipantRole # noqa lgtm[py/unused-import] - from dispatch.plugin.models import Plugin # noqa lgtm[py/unused-import] + from dispatch.plugin.models import Plugin, PluginEvent # noqa lgtm[py/unused-import] from dispatch.report.models import Report # noqa lgtm[py/unused-import] from dispatch.service.models import Service # noqa lgtm[py/unused-import] from dispatch.storage.models import Storage # noqa lgtm[py/unused-import] @@ -61,7 +68,9 @@ from dispatch.case.severity.models import CaseSeverity # noqa lgtm[py/unused-import] from dispatch.case.type.models import CaseType # noqa lgtm[py/unused-import] from dispatch.signal.models import Signal # noqa lgtm[py/unused-import] - from dispatch.feedback.service.reminder.models import ServiceFeedbackReminder # noqa lgtm[py/unused-import] + from dispatch.feedback.service.reminder.models import ( + ServiceFeedbackReminder, # noqa lgtm[py/unused-import] + ) from dispatch.forms.type.models import FormsType # noqa lgtm[py/unused-import] from dispatch.forms.models import Forms # noqa lgtm[py/unused-import] diff --git a/src/dispatch/api.py b/src/dispatch/api.py index 8f0bef970476..e532d9de2b03 100644 --- a/src/dispatch/api.py +++ b/src/dispatch/api.py @@ -30,6 +30,7 @@ from dispatch.incident.type.views import router as incident_type_router from dispatch.incident.views import router as incident_router from dispatch.incident_cost.views import router as incident_cost_router +from dispatch.cost_model.views import router as cost_model_router from dispatch.incident_cost_type.views import router as incident_cost_type_router from dispatch.incident_role.views import router as incident_role_router from dispatch.individual.views import router as individual_contact_router @@ -197,6 +198,11 @@ def get_organization_path(organization: OrganizationSlug): prefix="/case_severities", tags=["case_severities"], ) +authenticated_organization_api_router.include_router( + cost_model_router, + prefix="/cost_models", + tags=["cost_models"], +) authenticated_organization_api_router.include_router( workflow_router, prefix="/workflows", tags=["workflows"] ) @@ -223,13 +229,12 @@ def get_organization_path(organization: OrganizationSlug): authenticated_organization_api_router.include_router( incident_role_router, prefix="/incident_roles", tags=["role"] ) -authenticated_organization_api_router.include_router( - forms_router, prefix="/forms", tags=["forms"] -) +authenticated_organization_api_router.include_router(forms_router, prefix="/forms", tags=["forms"]) authenticated_organization_api_router.include_router( forms_type_router, prefix="/forms_type", tags=["forms_type"] ) + @api_router.get("/healthcheck", include_in_schema=False) def healthcheck(): return {"status": "ok"} diff --git a/src/dispatch/cli.py b/src/dispatch/cli.py index 538be8a3f219..02551ba34115 100644 --- a/src/dispatch/cli.py +++ b/src/dispatch/cli.py @@ -83,7 +83,7 @@ def install_plugins(force): from dispatch.common.utils.cli import install_plugins from dispatch.database.core import SessionLocal from dispatch.plugin import service as plugin_service - from dispatch.plugin.models import Plugin + from dispatch.plugin.models import Plugin, PluginEvent from dispatch.plugins.base import plugins install_plugins() @@ -104,6 +104,7 @@ def install_plugins(force): description=p.description, ) db_session.add(plugin) + record = plugin else: if force: click.secho(f"Updating plugin... Slug: {p.slug} Version: {p.version}", fg="blue") @@ -115,6 +116,23 @@ def install_plugins(force): record.description = p.description record.type = p.type + # Registers the plugin events with the plugin or updates the plugin events + for plugin_event_in in p.plugin_events: + click.secho(f" Registering plugin event... Slug: {plugin_event_in.slug}", fg="blue") + if plugin_event := plugin_service.get_plugin_event_by_slug( + db_session=db_session, slug=plugin_event_in.slug + ): + plugin_event.name = plugin_event_in.name + plugin_event.description = plugin_event_in.description + plugin_event.plugin = record + else: + plugin_event = PluginEvent( + name=plugin_event_in.name, + slug=plugin_event_in.slug, + description=plugin_event_in.description, + plugin=record, + ) + db_session.add(plugin_event) db_session.commit() diff --git a/src/dispatch/conversation/enums.py b/src/dispatch/conversation/enums.py index a85e7498f2ad..9e249b5e6756 100644 --- a/src/dispatch/conversation/enums.py +++ b/src/dispatch/conversation/enums.py @@ -20,3 +20,8 @@ class ConversationButtonActions(DispatchEnum): service_feedback = "service-feedback" subscribe_user = "subscribe-user" update_task_status = "update-task-status" + + +class ConversationFilters(DispatchEnum): + exclude_bots = "exclude-bots" + exclude_channel_join = "exclude-channel-join" diff --git a/src/dispatch/cost_model/__init__.py b/src/dispatch/cost_model/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/dispatch/cost_model/models.py b/src/dispatch/cost_model/models.py new file mode 100644 index 000000000000..41ac682f5641 --- /dev/null +++ b/src/dispatch/cost_model/models.py @@ -0,0 +1,111 @@ +from datetime import datetime +from pydantic import Field +from sqlalchemy import ( + Column, + Integer, + String, + Boolean, + ForeignKey, + PrimaryKeyConstraint, + Table, +) +from sqlalchemy.orm import relationship +from sqlalchemy.sql.schema import UniqueConstraint +from sqlalchemy_utils import TSVectorType +from typing import List, Optional + +from dispatch.database.core import Base +from dispatch.models import ( + DispatchBase, + NameStr, + Pagination, + PrimaryKey, + ProjectMixin, + TimeStampMixin, +) +from dispatch.plugin.models import PluginEventRead, PluginEvent +from dispatch.project.models import ProjectRead + +assoc_cost_model_activities = Table( + "assoc_cost_model_activities", + Base.metadata, + Column("cost_model_id", Integer, ForeignKey("cost_model.id", ondelete="CASCADE")), + Column( + "cost_model_activity_id", + Integer, + ForeignKey("cost_model_activity.id", ondelete="CASCADE"), + ), + PrimaryKeyConstraint("cost_model_id", "cost_model_activity_id"), +) + + +# SQLAlchemy Model +class CostModelActivity(Base): + id = Column(Integer, primary_key=True) + plugin_event_id = Column(Integer, ForeignKey(PluginEvent.id, ondelete="CASCADE")) + plugin_event = relationship(PluginEvent, backref="plugin_event") + response_time_seconds = Column(Integer, default=300) + enabled = Column(Boolean, default=True) + + +class CostModel(Base, TimeStampMixin, ProjectMixin): + __table_args__ = (UniqueConstraint("name", "project_id"),) + id = Column(Integer, primary_key=True) + name = Column(String) + description = Column(String) + enabled = Column(Boolean) + activities = relationship( + "CostModelActivity", + secondary=assoc_cost_model_activities, + lazy="subquery", + backref="cost_model", + ) + search_vector = Column( + TSVectorType("name", "description", weights={"name": "A", "description": "B"}) + ) + + +# Pydantic Models +class CostModelActivityBase(DispatchBase): + plugin_event: PluginEventRead + response_time_seconds: Optional[int] = 300 + enabled: Optional[bool] = Field(True, nullable=True) + + +class CostModelActivityCreate(CostModelActivityBase): + pass + + +class CostModelActivityRead(CostModelActivityBase): + id: PrimaryKey + + +class CostModelActivityUpdate(CostModelActivityBase): + id: Optional[PrimaryKey] + + +class CostModelBase(DispatchBase): + name: NameStr + description: Optional[str] = Field(None, nullable=True) + enabled: Optional[bool] = Field(True, nullable=True) + created_at: Optional[datetime] + updated_at: Optional[datetime] + project: ProjectRead + + +class CostModelUpdate(CostModelBase): + id: PrimaryKey + activities: Optional[List[CostModelActivityUpdate]] = [] + + +class CostModelCreate(CostModelBase): + activities: Optional[List[CostModelActivityCreate]] = [] + + +class CostModelRead(CostModelBase): + id: PrimaryKey + activities: Optional[List[CostModelActivityRead]] = [] + + +class CostModelPagination(Pagination): + items: List[CostModelRead] = [] diff --git a/src/dispatch/cost_model/service.py b/src/dispatch/cost_model/service.py new file mode 100644 index 000000000000..09b95a4e61dd --- /dev/null +++ b/src/dispatch/cost_model/service.py @@ -0,0 +1,198 @@ +from datetime import datetime +import logging +from typing import List + +from .models import ( + CostModel, + CostModelCreate, + CostModelRead, + CostModelUpdate, + CostModelActivity, + CostModelActivityCreate, + CostModelActivityUpdate, +) +from dispatch.cost_model import service as cost_model_service +from dispatch.plugin import service as plugin_service +from dispatch.project import service as project_service + +log = logging.getLogger(__name__) + + +def has_unique_plugin_event(cost_model_in: CostModelRead) -> bool: + seen = set() + for activity in cost_model_in.activities: + if activity.plugin_event.id in seen: + log.warning( + f"Duplicate plugin event id detected. Please ensure all plugin events are unique for each cost model. Duplicate id: {activity.plugin_event.id}" + ) + return False + seen.add(activity.plugin_event.id) + return True + + +def get_all(*, db_session, project_id: int) -> List[CostModel]: + """Returns all cost models.""" + if project_id: + return db_session.query(CostModel).filter(CostModel.project_id == project_id) + return db_session.query(CostModel) + + +def get_cost_model_activity_by_id(*, db_session, cost_model_activity_id: int) -> CostModelActivity: + """Returns a cost model activity based on the given cost model activity id.""" + return ( + db_session.query(CostModelActivity) + .filter(CostModelActivity.id == cost_model_activity_id) + .one() + ) + + +def delete_cost_model_activity(*, db_session, cost_model_activity_id: int): + """Deletes a cost model activity.""" + cost_model_activity = get_cost_model_activity_by_id( + db_session=db_session, cost_model_activity_id=cost_model_activity_id + ) + db_session.delete(cost_model_activity) + db_session.commit() + + +def update_cost_model_activity(*, db_session, cost_model_activity_in: CostModelActivityUpdate): + """Updates a cost model activity.""" + cost_model_activity = get_cost_model_activity_by_id( + db_session=db_session, cost_model_activity_id=cost_model_activity_in.id + ) + + cost_model_activity.response_time_seconds = cost_model_activity_in.response_time_seconds + cost_model_activity.enabled = cost_model_activity_in.enabled + cost_model_activity.plugin_event_id = cost_model_activity_in.plugin_event.id + + db_session.commit() + return cost_model_activity + + +def create_cost_model_activity( + *, db_session, cost_model_activity_in: CostModelActivityCreate +) -> CostModelActivity: + cost_model_activity = CostModelActivity( + response_time_seconds=cost_model_activity_in.response_time_seconds, + enabled=cost_model_activity_in.enabled, + plugin_event_id=cost_model_activity_in.plugin_event.id, + ) + + db_session.add(cost_model_activity) + db_session.commit() + return cost_model_activity + + +def delete(*, db_session, cost_model_id: int): + """Deletes a cost model.""" + cost_model = get_cost_model_by_id(db_session=db_session, cost_model_id=cost_model_id) + if not cost_model: + raise ValueError( + f"Unable to delete cost model. No cost model found with id {cost_model_id}." + ) + + db_session.delete(cost_model) + db_session.commit() + + +def update(*, db_session, cost_model_in: CostModelUpdate) -> CostModel: + """Updates a cost model.""" + if not has_unique_plugin_event(cost_model_in): + raise KeyError("Unable to update cost model. Duplicate plugin event ids detected.") + + cost_model = get_cost_model_by_id(db_session=db_session, cost_model_id=cost_model_in.id) + if not cost_model: + raise ValueError("Unable to update cost model. No cost model found with that id.") + + cost_model.name = cost_model_in.name + cost_model.description = cost_model_in.description + cost_model.enabled = cost_model_in.enabled + cost_model.created_at = cost_model_in.created_at + cost_model.updated_at = ( + cost_model_in.updated_at if cost_model_in.updated_at else datetime.utcnow() + ) + + # Update all recognized activities. Delete all removed activites. + update_activities = [] + delete_activities = [] + + for activity in cost_model.activities: + updated = False + for idx_in, activity_in in enumerate(cost_model_in.activities): + if activity.plugin_event.id == activity_in.plugin_event.id: + update_activities.append((activity, activity_in)) + cost_model_in.activities.pop(idx_in) + updated = True + break + if updated: + continue + + # Delete activities that have been removed from the cost model. + delete_activities.append(activity) + + for activity, activity_in in update_activities: + activity.response_time_seconds = activity_in.response_time_seconds + activity.enabled = activity_in.enabled + activity.plugin_event = plugin_service.get_plugin_event_by_id( + db_session=db_session, plugin_event_id=activity_in.plugin_event.id + ) + + for activity in delete_activities: + cost_model_service.delete_cost_model_activity( + db_session=db_session, cost_model_activity_id=activity.id + ) + + # Create new activities. + for activity_in in cost_model_in.activities: + activity_out = cost_model_service.create_cost_model_activity( + db_session=db_session, cost_model_activity_in=activity_in + ) + + if not activity_out: + log.error("Failed to create cost model activity. Continuing.") + continue + + cost_model.activities.append(activity_out) + + db_session.commit() + return cost_model + + +def create(*, db_session, cost_model_in: CostModelCreate) -> CostModel: + """Creates a new cost model.""" + if not has_unique_plugin_event(cost_model_in): + raise KeyError("Unable to update cost model. Duplicate plugin event ids detected.") + + project = project_service.get_by_name_or_raise( + db_session=db_session, project_in=cost_model_in.project + ) + + cost_model = CostModel( + **cost_model_in.dict(exclude={"activities", "project"}), + activities=[], + project=project, + ) + + db_session.add(cost_model) + db_session.commit() + + # Create activities after the cost model is created. + # We need the cost model id to map to the activity. + if cost_model and cost_model_in.activities: + for activity_in in cost_model_in.activities: + activity_out = cost_model_service.create_cost_model_activity( + db_session=db_session, cost_model_activity_in=activity_in + ) + if not activity_out: + log.error("Failed to create cost model activity. Continuing.") + continue + + cost_model.activities.append(activity_out) + + db_session.commit() + return cost_model + + +def get_cost_model_by_id(*, db_session, cost_model_id: int) -> CostModel: + """Returns a cost model based on the given cost model id.""" + return db_session.query(CostModel).filter(CostModel.id == cost_model_id).one() diff --git a/src/dispatch/cost_model/views.py b/src/dispatch/cost_model/views.py new file mode 100644 index 000000000000..8d41b8e310a7 --- /dev/null +++ b/src/dispatch/cost_model/views.py @@ -0,0 +1,76 @@ +from fastapi import APIRouter, Depends, HTTPException, status +import logging +from sqlalchemy.exc import IntegrityError + +from dispatch.auth.permissions import SensitiveProjectActionPermission, PermissionsDependency +from dispatch.database.core import DbSession +from dispatch.database.service import CommonParameters, search_filter_sort_paginate +from dispatch.models import PrimaryKey + +from .models import ( + CostModelCreate, + CostModelPagination, + CostModelRead, + CostModelUpdate, +) +from .service import create, update, delete + +log = logging.getLogger(__name__) + +router = APIRouter() + + +@router.get("", response_model=CostModelPagination) +def get_cost_models(common: CommonParameters): + """Get all cost models, or only those matching a given search term.""" + return search_filter_sort_paginate(model="CostModel", **common) + + +@router.post( + "", + summary="Creates a new cost model.", + response_model=CostModelRead, + dependencies=[Depends(PermissionsDependency([SensitiveProjectActionPermission]))], +) +def create_cost_model( + db_session: DbSession, + cost_model_in: CostModelCreate, +): + """Create a cost model.""" + return create(db_session=db_session, cost_model_in=cost_model_in) + + +@router.put( + "/{cost_model_id}", + summary="Modifies an existing cost model.", + response_model=CostModelRead, + dependencies=[Depends(PermissionsDependency([SensitiveProjectActionPermission]))], +) +def update_cost_model( + cost_model_id: PrimaryKey, + db_session: DbSession, + cost_model_in: CostModelUpdate, +): + """Modifies an existing cost model.""" + return update(db_session=db_session, cost_model_in=cost_model_in) + + +@router.delete( + "/{cost_model_id}", + response_model=None, + summary="Deletes a cost model and its activities.", + dependencies=[Depends(PermissionsDependency([SensitiveProjectActionPermission]))], +) +def delete_cost_model( + cost_model_id: PrimaryKey, + db_session: DbSession, +): + """Deletes a cost model and its external resources.""" + try: + delete(cost_model_id=cost_model_id, db_session=db_session) + except IntegrityError as e: + log.exception(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=[{"msg": (f"Cost Model with id {cost_model_id} could not be deleted. ")}], + ) from None diff --git a/src/dispatch/database/core.py b/src/dispatch/database/core.py index 4410aa2ed789..fab5c47d3186 100644 --- a/src/dispatch/database/core.py +++ b/src/dispatch/database/core.py @@ -92,8 +92,9 @@ def _repr_attrs_str(self): for key in self.__repr_attrs__: if not hasattr(self, key): raise KeyError( - "{} has incorrect attribute '{}' in " - "__repr__attrs__".format(self.__class__, key) + "{} has incorrect attribute '{}' in " "__repr__attrs__".format( + self.__class__, key + ) ) value = getattr(self, key) wrap_in_quote = isinstance(value, str) diff --git a/src/dispatch/database/revisions/core/versions/2023-12-27_ed0b0388fa3f.py b/src/dispatch/database/revisions/core/versions/2023-12-27_ed0b0388fa3f.py new file mode 100644 index 000000000000..ee060a1c7ee5 --- /dev/null +++ b/src/dispatch/database/revisions/core/versions/2023-12-27_ed0b0388fa3f.py @@ -0,0 +1,57 @@ +"""Adds the plugin_event table. + +Revision ID: ed0b0388fa3f +Revises: 5c60513d6e5e +Create Date: 2023-12-27 13:44:17.960851 + +""" +from alembic import op +import sqlalchemy as sa +import sqlalchemy_utils + +# revision identifiers, used by Alembic. +revision = "ed0b0388fa3f" +down_revision = "5c60513d6e5e" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "plugin_event", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=True), + sa.Column("slug", sa.String(), nullable=True), + sa.Column("description", sa.String(), nullable=True), + sa.Column("plugin_id", sa.Integer(), nullable=True), + sa.Column("search_vector", sqlalchemy_utils.types.ts_vector.TSVectorType(), nullable=True), + sa.ForeignKeyConstraint( + ["plugin_id"], + ["dispatch_core.plugin.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("slug"), + schema="dispatch_core", + ) + op.create_index( + "plugin_event_search_vector_idx", + "plugin_event", + ["search_vector"], + unique=False, + schema="dispatch_core", + postgresql_using="gin", + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + "plugin_event_search_vector_idx", + table_name="plugin_event", + schema="dispatch_core", + postgresql_using="gin", + ) + op.drop_table("plugin_event", schema="dispatch_core") + # ### end Alembic commands ### diff --git a/src/dispatch/database/revisions/tenant/versions/2023-12-27_065c59f15267.py b/src/dispatch/database/revisions/tenant/versions/2023-12-27_065c59f15267.py new file mode 100644 index 000000000000..e17c726a2384 --- /dev/null +++ b/src/dispatch/database/revisions/tenant/versions/2023-12-27_065c59f15267.py @@ -0,0 +1,105 @@ +"""Adds cost model tables: cost_model, cost_model_activity, participant_activity + +Revision ID: 065c59f15267 +Revises: 6c1a250b1e4b +Create Date: 2023-12-27 13:44:18.845443 + +""" +from alembic import op +import sqlalchemy as sa +import sqlalchemy_utils + +# revision identifiers, used by Alembic. +revision = "065c59f15267" +down_revision = "6c1a250b1e4b" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "cost_model", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=True), + sa.Column("description", sa.String(), nullable=True), + sa.Column("enabled", sa.Boolean(), nullable=True), + sa.Column("search_vector", sqlalchemy_utils.types.ts_vector.TSVectorType(), nullable=True), + sa.Column("project_id", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["project_id"], ["project.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name", "project_id"), + ) + op.create_index( + "cost_model_search_vector_idx", + "cost_model", + ["search_vector"], + unique=False, + postgresql_using="gin", + ) + op.create_table( + "cost_model_activity", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("plugin_event_id", sa.Integer(), nullable=True), + sa.Column("response_time_seconds", sa.Integer(), nullable=True), + sa.Column("enabled", sa.Boolean(), nullable=True), + sa.ForeignKeyConstraint( + ["plugin_event_id"], ["dispatch_core.plugin_event.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "assoc_cost_model_activities", + sa.Column("cost_model_id", sa.Integer(), nullable=False), + sa.Column("cost_model_activity_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["cost_model_activity_id"], + ["cost_model_activity.id"], + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint(["cost_model_id"], ["cost_model.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("cost_model_id", "cost_model_activity_id"), + ) + op.create_table( + "participant_activity", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("plugin_event_id", sa.Integer(), nullable=True), + sa.Column("started_at", sa.DateTime(), nullable=True), + sa.Column("ended_at", sa.DateTime(), nullable=True), + sa.Column("participant_id", sa.Integer(), nullable=True), + sa.Column("incident_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["incident_id"], + ["incident.id"], + ), + sa.ForeignKeyConstraint( + ["participant_id"], + ["participant.id"], + ), + sa.ForeignKeyConstraint( + ["plugin_event_id"], + ["dispatch_core.plugin_event.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.add_column("incident", sa.Column("cost_model_id", sa.Integer(), nullable=True)) + op.create_foreign_key(None, "incident", "cost_model", ["cost_model_id"], ["id"]) + # # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, "incident", type_="foreignkey") + op.drop_column("incident", "cost_model_id") + op.drop_table("participant_activity") + op.drop_table("assoc_cost_model_activities") + op.drop_table("cost_model_activity") + op.drop_index( + "cost_model_search_vector_idx", + table_name="cost_model", + postgresql_using="gin", + ) + op.drop_table("cost_model") + # ### end Alembic commands ### diff --git a/src/dispatch/feedback/service/views.py b/src/dispatch/feedback/service/views.py index f094ca8207f0..2359f0c0228c 100644 --- a/src/dispatch/feedback/service/views.py +++ b/src/dispatch/feedback/service/views.py @@ -17,9 +17,9 @@ @router.get( - "", - response_model=ServiceFeedbackPagination, - dependencies=[Depends(PermissionsDependency([SensitiveProjectActionPermission]))], + "", + response_model=ServiceFeedbackPagination, + dependencies=[Depends(PermissionsDependency([SensitiveProjectActionPermission]))], ) def get_feedback_entries(commons: CommonParameters): """Get all feedback entries, or only those matching a given search term.""" @@ -27,9 +27,9 @@ def get_feedback_entries(commons: CommonParameters): @router.get( - "/{service_feedback_id}", - response_model=ServiceFeedbackRead, - dependencies=[Depends(PermissionsDependency([SensitiveProjectActionPermission]))], + "/{service_feedback_id}", + response_model=ServiceFeedbackRead, + dependencies=[Depends(PermissionsDependency([SensitiveProjectActionPermission]))], ) def get_feedback(db_session: DbSession, service_feedback_id: PrimaryKey): """Get a feedback entry by its id.""" diff --git a/src/dispatch/incident/flows.py b/src/dispatch/incident/flows.py index b46b82fe4e93..c3d4a9394085 100644 --- a/src/dispatch/incident/flows.py +++ b/src/dispatch/incident/flows.py @@ -687,7 +687,7 @@ def incident_update_flow( group_plugin = plugin_service.get_active_instance( db_session=db_session, project_id=incident.project.id, plugin_type="participant-group" ) - if group_plugin: + if group_plugin and incident.notifications_group: team_participant_emails = [x.email for x in team_participants] group_plugin.instance.add(incident.notifications_group.email, team_participant_emails) diff --git a/src/dispatch/incident/models.py b/src/dispatch/incident/models.py index 75b5dbb77036..0cb1a980cbcf 100644 --- a/src/dispatch/incident/models.py +++ b/src/dispatch/incident/models.py @@ -11,6 +11,7 @@ from dispatch.conference.models import ConferenceRead from dispatch.conversation.models import ConversationRead +from dispatch.cost_model.models import CostModelRead from dispatch.database.core import Base from dispatch.document.models import Document, DocumentRead from dispatch.enums import Visibility @@ -34,7 +35,10 @@ IncidentTypeRead, IncidentTypeReadMinimal, ) -from dispatch.incident_cost.models import IncidentCostRead, IncidentCostUpdate +from dispatch.incident_cost.models import ( + IncidentCostRead, + IncidentCostUpdate, +) from dispatch.messaging.strings import INCIDENT_RESOLUTION_DEFAULT from dispatch.models import ( DispatchBase, @@ -221,13 +225,19 @@ def last_executive_report(self): notifications_group_id = Column(Integer, ForeignKey("group.id")) notifications_group = relationship("Group", foreign_keys=[notifications_group_id]) + cost_model_id = Column(Integer, ForeignKey("cost_model.id"), nullable=True, default=None) + cost_model = relationship( + "CostModel", + foreign_keys=[cost_model_id], + ) + @hybrid_property def total_cost(self): + total_cost = 0 if self.incident_costs: - total_cost = 0 for cost in self.incident_costs: total_cost += cost.amount - return total_cost + return total_cost @observes("participants") def participant_observer(self, participants): @@ -286,6 +296,7 @@ def description_required(cls, v): class IncidentCreate(IncidentBase): commander: Optional[ParticipantUpdate] commander_email: Optional[str] + cost_model: Optional[CostModelRead] = None incident_priority: Optional[IncidentPriorityCreate] incident_severity: Optional[IncidentSeverityCreate] incident_type: Optional[IncidentTypeCreate] @@ -302,6 +313,7 @@ class IncidentReadMinimal(IncidentBase): closed_at: Optional[datetime] = None commander: Optional[ParticipantReadMinimal] commanders_location: Optional[str] + cost_model: Optional[CostModelRead] = None created_at: Optional[datetime] = None duplicates: Optional[List[IncidentReadMinimal]] = [] incident_costs: Optional[List[IncidentCostRead]] = [] @@ -327,6 +339,7 @@ class IncidentReadMinimal(IncidentBase): class IncidentUpdate(IncidentBase): cases: Optional[List[CaseRead]] = [] commander: Optional[ParticipantUpdate] + cost_model: Optional[CostModelRead] = None delay_executive_report_reminder: Optional[datetime] = None delay_tactical_report_reminder: Optional[datetime] = None duplicates: Optional[List[IncidentReadMinimal]] = [] @@ -364,6 +377,7 @@ class IncidentRead(IncidentBase): commanders_location: Optional[str] conference: Optional[ConferenceRead] = None conversation: Optional[ConversationRead] = None + cost_model: Optional[CostModelRead] = None created_at: Optional[datetime] = None delay_executive_report_reminder: Optional[datetime] = None delay_tactical_report_reminder: Optional[datetime] = None diff --git a/src/dispatch/incident/service.py b/src/dispatch/incident/service.py index 1048dc0b5e3d..3d5649784c2b 100644 --- a/src/dispatch/incident/service.py +++ b/src/dispatch/incident/service.py @@ -12,6 +12,7 @@ from dispatch.decorators import timer from dispatch.case import service as case_service +from dispatch.cost_model import service as cost_model_service from dispatch.database.core import SessionLocal from dispatch.event import service as event_service from dispatch.exceptions import NotFoundError @@ -169,6 +170,13 @@ def create(*, db_session, incident_in: IncidentCreate) -> Incident: project_id=project.id, ) + cost_model = None + if incident_in.cost_model: + cost_model = cost_model_service.get_cost_model_by_id( + db_session=db_session, + cost_model_id=incident_in.cost_model.id, + ) + visibility = incident_type.visibility if incident_in.visibility: visibility = incident_in.visibility @@ -188,6 +196,7 @@ def create(*, db_session, incident_in: IncidentCreate) -> Incident: tags=tag_objs, title=incident_in.title, visibility=visibility, + cost_model=cost_model, ) db_session.add(incident) @@ -328,6 +337,13 @@ def update(*, db_session, incident: Incident, incident_in: IncidentUpdate) -> In incident_priority_in=incident_in.incident_priority, ) + cost_model = None + if incident_in.cost_model and incident_in.cost_model.id != incident.cost_model_id: + cost_model = cost_model_service.get_cost_model_by_id( + db_session=db_session, + cost_model_id=incident_in.cost_model.id, + ) + cases = [] for c in incident_in.cases: cases.append(case_service.get(db_session=db_session, case_id=c.id)) @@ -358,6 +374,7 @@ def update(*, db_session, incident: Incident, incident_in: IncidentUpdate) -> In "cases", "commander", "duplicates", + "cost_model", "incident_costs", "incident_priority", "incident_severity", @@ -375,6 +392,7 @@ def update(*, db_session, incident: Incident, incident_in: IncidentUpdate) -> In setattr(incident, field, update_data[field]) incident.cases = cases + incident.cost_model = cost_model incident.duplicates = duplicates incident.incident_costs = incident_costs incident.incident_priority = incident_priority @@ -387,6 +405,10 @@ def update(*, db_session, incident: Incident, incident_in: IncidentUpdate) -> In db_session.commit() + # Update total incident reponse cost. + incident_cost_service.update_incident_response_cost( + incident_id=incident.id, db_session=db_session + ) return incident diff --git a/src/dispatch/incident/views.py b/src/dispatch/incident/views.py index 9924ecabb924..ddc90c6e51fc 100644 --- a/src/dispatch/incident/views.py +++ b/src/dispatch/incident/views.py @@ -1,14 +1,11 @@ import calendar -import json -import logging from datetime import date, datetime -from typing import Annotated, List - from dateutil.relativedelta import relativedelta - from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status +import json +import logging +from typing import Annotated, List from starlette.requests import Request - from sqlalchemy.exc import IntegrityError from dispatch.auth.permissions import ( @@ -22,14 +19,14 @@ from dispatch.common.utils.views import create_pydantic_include from dispatch.database.core import DbSession from dispatch.database.service import CommonParameters, search_filter_sort_paginate +from dispatch.event import flows as event_flows +from dispatch.event.models import EventUpdate, EventCreateMinimal from dispatch.incident.enums import IncidentStatus from dispatch.individual.models import IndividualContactRead from dispatch.models import OrganizationSlug, PrimaryKey from dispatch.participant.models import ParticipantUpdate from dispatch.report import flows as report_flows -from dispatch.event import flows as event_flows from dispatch.report.models import ExecutiveReportCreate, TacticalReportCreate -from dispatch.event.models import EventUpdate, EventCreateMinimal from .flows import ( incident_add_or_reactivate_participant_flow, @@ -63,7 +60,7 @@ def get_current_incident(db_session: DbSession, request: Request) -> Incident: if not incident: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=[{"msg": "An incident with this id does not existt."}], + detail=[{"msg": "An incident with this id does not exist."}], ) return incident diff --git a/src/dispatch/incident_cost/models.py b/src/dispatch/incident_cost/models.py index 03582a955d13..858565cb16e5 100644 --- a/src/dispatch/incident_cost/models.py +++ b/src/dispatch/incident_cost/models.py @@ -1,8 +1,7 @@ -from typing import List, Optional - from sqlalchemy import Column, ForeignKey, Integer, Numeric from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.orm import relationship +from typing import List, Optional from dispatch.database.core import Base from dispatch.incident_cost_type.models import IncidentCostTypeRead diff --git a/src/dispatch/incident_cost/scheduled.py b/src/dispatch/incident_cost/scheduled.py index 072ac8bba1c5..1558d12a63bb 100644 --- a/src/dispatch/incident_cost/scheduled.py +++ b/src/dispatch/incident_cost/scheduled.py @@ -67,7 +67,6 @@ def calculate_incidents_response_cost(db_session: SessionLocal, project: Project # we calculate the response cost amount amount = calculate_incident_response_cost(incident.id, db_session) - # we don't need to update the cost amount if it hasn't changed if incident_response_cost.amount == amount: continue diff --git a/src/dispatch/incident_cost/service.py b/src/dispatch/incident_cost/service.py index 4cb074ec98d9..186739c1e19f 100644 --- a/src/dispatch/incident_cost/service.py +++ b/src/dispatch/incident_cost/service.py @@ -1,19 +1,27 @@ +from datetime import datetime, timedelta, timezone +import logging import math -from datetime import datetime - from typing import List, Optional from dispatch.database.core import SessionLocal from dispatch.incident import service as incident_service from dispatch.incident.enums import IncidentStatus +from dispatch.incident.models import Incident from dispatch.incident_cost_type import service as incident_cost_type_service +from dispatch.incident_cost_type.models import IncidentCostTypeRead +from dispatch.participant import service as participant_service +from dispatch.participant.models import ParticipantRead +from dispatch.participant_activity import service as participant_activity_service +from dispatch.participant_activity.models import ParticipantActivityCreate from dispatch.participant_role.models import ParticipantRoleType +from dispatch.plugin import service as plugin_service from .models import IncidentCost, IncidentCostCreate, IncidentCostUpdate HOURS_IN_DAY = 24 SECONDS_IN_HOUR = 3600 +log = logging.getLogger(__name__) def get(*, db_session, incident_cost_id: int) -> Optional[IncidentCost]: @@ -104,14 +112,95 @@ def get_engagement_multiplier(participant_role: str): return engagement_mappings.get(participant_role) -def calculate_incident_response_cost( - incident_id: int, db_session: SessionLocal, incident_review=True -): - """Calculates the response cost of a given incident.""" - incident = incident_service.get(db_session=db_session, incident_id=incident_id) +def get_incident_review_hours(incident: Incident) -> int: + """Calculate the time spent in incident review related activities.""" + num_participants = len(incident.participants) + incident_review_prep = ( + 1 # we make the assumption that it takes an hour to prepare the incident review + ) + incident_review_meeting = ( + num_participants * 0.5 * 1 + ) # we make the assumption that only half of the incident participants will attend the 1-hour, incident review session + return incident_review_prep + incident_review_meeting + + +def calculate_incident_response_cost_with_cost_model( + incident: Incident, db_session: SessionLocal +) -> int: + """Calculates the cost of an incident using the incident's cost model.""" participants_total_response_time_seconds = 0 + # Get the cost model. Iterate through all the listed activities we want to record. + for activity in incident.cost_model.activities: + plugin_instance = plugin_service.get_active_instance_by_slug( + db_session=db_session, + slug=activity.plugin_event.plugin.slug, + project_id=incident.project.id, + ) + if not plugin_instance: + log.warning( + f"Cannot fetch cost model activity. Its associated plugin {activity.plugin_event.plugin.title} is not enabled." + ) + continue + + oldest = "0" + response_cost_type = incident_cost_type_service.get_default( + db_session=db_session, project_id=incident.project.id + ) + incident_response_cost = get_by_incident_id_and_incident_cost_type_id( + db_session=db_session, + incident_id=incident.id, + incident_cost_type_id=response_cost_type.id, + ) + if incident_response_cost: + oldest = incident_response_cost.updated_at.replace(tzinfo=timezone.utc).timestamp() + + # Array of sorted (timestamp, user_id) tuples. + incident_events = plugin_instance.instance.fetch_incident_events( + db_session=db_session, + subject=incident, + plugin_event_id=activity.plugin_event.id, + oldest=oldest, + ) + + for ts, user_id in incident_events: + participant = participant_service.get_by_incident_id_and_conversation_id( + db_session=db_session, + incident_id=incident.id, + user_conversation_id=user_id, + ) + if not participant: + log.warning("Cannot resolve participant.") + continue + + activity_in = ParticipantActivityCreate( + plugin_event=activity.plugin_event, + started_at=ts, + ended_at=ts + timedelta(seconds=activity.response_time_seconds), + participant=ParticipantRead(id=participant.id), + incident=incident, + ) + + if participant_response_time := participant_activity_service.create_or_update( + db_session=db_session, activity_in=activity_in + ): + participants_total_response_time_seconds += ( + participant_response_time.total_seconds() + ) + + # Calculate and round up the hourly rate. + hourly_rate = math.ceil( + incident.project.annual_employee_cost / incident.project.business_year_hours + ) + additional_incident_cost = math.ceil( + (participants_total_response_time_seconds / SECONDS_IN_HOUR) * hourly_rate + ) + return incident.total_cost + additional_incident_cost + + +def calculate_incident_response_cost_with_classic_model(incident: Incident, incident_review=True): + participants_total_response_time_seconds = 0 for participant in incident.participants: participant_total_roles_time_seconds = 0 @@ -173,28 +262,74 @@ def calculate_incident_response_cost( participant_total_roles_time_seconds += participant_role_time_seconds participants_total_response_time_seconds += participant_total_roles_time_seconds - - # we calculate the time spent in incident review related activities - incident_review_hours = 0 if incident_review: - num_participants = len(incident.participants) - incident_review_prep = ( - 1 # we make the assumption that it takes an hour to prepare the incident review - ) - incident_review_meeting = ( - num_participants * 0.5 * 1 - ) # we make the assumption that only half of the incident participants will attend the 1-hour, incident review session - incident_review_hours = incident_review_prep + incident_review_meeting - + incident_review_hours = get_incident_review_hours(incident) # we calculate and round up the hourly rate hourly_rate = math.ceil( incident.project.annual_employee_cost / incident.project.business_year_hours ) # we calculate and round up the incident cost - incident_cost = math.ceil( + return math.ceil( ((participants_total_response_time_seconds / SECONDS_IN_HOUR) + incident_review_hours) * hourly_rate ) - return incident_cost + +def calculate_incident_response_cost( + incident_id: int, db_session: SessionLocal, incident_review=True +) -> int: + """Calculates the response cost of a given incident.""" + incident = incident_service.get(db_session=db_session, incident_id=incident_id) + if not incident: + log.warning(f"Incident with id {incident_id} not found.") + return 0 + if incident.cost_model and incident.cost_model.enabled: + log.info(f"Calculating {incident.name} incident cost with model {incident.cost_model}.") + return calculate_incident_response_cost_with_cost_model( + incident=incident, db_session=db_session + ) + else: + log.info("No incident cost model found. Defaulting to classic incident cost model.") + return calculate_incident_response_cost_with_classic_model( + incident=incident, incident_review=incident_review + ) + + +def update_incident_response_cost(incident_id: int, db_session: SessionLocal) -> int: + """Updates the response cost of a given incident.""" + incident = incident_service.get(db_session=db_session, incident_id=incident_id) + response_cost_type = incident_cost_type_service.get_default( + db_session=db_session, project_id=incident.project.id + ) + + if response_cost_type is None: + log.warning( + f"A default cost type for response cost doesn't exist in the {incident.project.name} project and organization {incident.project.organization.name}. Response costs for incident {incident.name} won't be calculated." + ) + return 0 + + incident_response_cost = get_by_incident_id_and_incident_cost_type_id( + db_session=db_session, + incident_id=incident.id, + incident_cost_type_id=response_cost_type.id, + ) + if incident_response_cost is None: + # we create the response cost if it doesn't exist + incident_cost_type = IncidentCostTypeRead.from_orm(response_cost_type) + incident_cost_in = IncidentCostCreate( + incident_cost_type=incident_cost_type, project=incident.project + ) + incident_response_cost = create(db_session=db_session, incident_cost_in=incident_cost_in) + amount = calculate_incident_response_cost(incident_id=incident.id, db_session=db_session) + # we don't need to update the cost amount if it hasn't changed + if incident_response_cost.amount == amount: + return incident_response_cost.amount + + # we save the new incident cost amount + incident_response_cost.amount = amount + incident.incident_costs.append(incident_response_cost) + db_session.add(incident) + db_session.commit() + + return incident_response_cost.amount diff --git a/src/dispatch/incident_cost_type/service.py b/src/dispatch/incident_cost_type/service.py index 334fd2dc763f..7850546505a5 100644 --- a/src/dispatch/incident_cost_type/service.py +++ b/src/dispatch/incident_cost_type/service.py @@ -1,6 +1,5 @@ -from typing import List, Optional - from sqlalchemy.sql.expression import true +from typing import List, Optional from dispatch.project import service as project_service @@ -44,7 +43,7 @@ def get_by_name( def get_all(*, db_session) -> List[Optional[IncidentCostType]]: """Gets all incident cost types.""" - return db_session.query(IncidentCostType) + return db_session.query(IncidentCostType).all() def create(*, db_session, incident_cost_type_in: IncidentCostTypeCreate) -> IncidentCostType: diff --git a/src/dispatch/messaging/strings.py b/src/dispatch/messaging/strings.py index 5849375b5aed..1599dc369f59 100644 --- a/src/dispatch/messaging/strings.py +++ b/src/dispatch/messaging/strings.py @@ -64,14 +64,10 @@ class MessageType(DispatchEnum): ).strip() INCIDENT_FEEDBACK_DAILY_REPORT_DESCRIPTION = """ -This is a daily report of feedback about incidents handled by you.""".replace( - "\n", " " -).strip() +This is a daily report of feedback about incidents handled by you.""".replace("\n", " ").strip() INCIDENT_DAILY_REPORT_TITLE = """ -Incidents Daily Report""".replace( - "\n", " " -).strip() +Incidents Daily Report""".replace("\n", " ").strip() INCIDENT_DAILY_REPORT_DESCRIPTION = """ This is a daily report of incidents that are currently active and incidents that have been marked as stable or closed in the last 24 hours.""".replace( @@ -91,9 +87,7 @@ class MessageType(DispatchEnum): INCIDENT_COMMANDER_DESCRIPTION = """ The Incident Commander (IC) is responsible for knowing the full context of the incident. -Contact them about any questions or concerns.""".replace( - "\n", " " -).strip() +Contact them about any questions or concerns.""".replace("\n", " ").strip() INCIDENT_COMMANDER_READDED_DESCRIPTION = """ {{ commander_fullname }} (Incident Commander) has been re-added to the conversation. @@ -118,56 +112,40 @@ class MessageType(DispatchEnum): INCIDENT_CONVERSATION_DESCRIPTION = """ Private conversation for real-time discussion. All incident participants get added to it. -""".replace( - "\n", " " -).strip() +""".replace("\n", " ").strip() INCIDENT_CONVERSATION_REFERENCE_DOCUMENT_DESCRIPTION = """ Document containing the list of slash commands available to the Incident Commander (IC) -and participants in the incident conversation.""".replace( - "\n", " " -).strip() +and participants in the incident conversation.""".replace("\n", " ").strip() INCIDENT_CONFERENCE_DESCRIPTION = """ Video conference and phone bridge to be used throughout the incident. Password: {{conference_challenge if conference_challenge else 'N/A'}} -""".replace( - "\n", "" -).strip() +""".replace("\n", "").strip() STORAGE_DESCRIPTION = """ Common storage for all artifacts and documents. Add logs, screen captures, or any other data collected during the -investigation to this folder. It is shared with all participants.""".replace( - "\n", " " -).strip() +investigation to this folder. It is shared with all participants.""".replace("\n", " ").strip() INCIDENT_INVESTIGATION_DOCUMENT_DESCRIPTION = """ This is a document for all incident facts and context. All incident participants are expected to contribute to this document. -It is shared with all incident participants.""".replace( - "\n", " " -).strip() +It is shared with all incident participants.""".replace("\n", " ").strip() CASE_INVESTIGATION_DOCUMENT_DESCRIPTION = """ This is a document for all investigation facts and context. All case participants are expected to contribute to this document. -It is shared with all participants.""".replace( - "\n", " " -).strip() +It is shared with all participants.""".replace("\n", " ").strip() INCIDENT_INVESTIGATION_SHEET_DESCRIPTION = """ This is a sheet for tracking impacted assets. All incident participants are expected to contribute to this sheet. -It is shared with all incident participants.""".replace( - "\n", " " -).strip() +It is shared with all incident participants.""".replace("\n", " ").strip() INCIDENT_FAQ_DOCUMENT_DESCRIPTION = """ First time responding to an incident? This document answers common questions encountered when -helping us respond to an incident.""".replace( - "\n", " " -).strip() +helping us respond to an incident.""".replace("\n", " ").strip() INCIDENT_REVIEW_DOCUMENT_DESCRIPTION = """ This document will capture all lessons learned, questions, and action items raised during the incident.""".replace( @@ -191,33 +169,23 @@ class MessageType(DispatchEnum): INCIDENT_RESOLUTION_DEFAULT = """ Description of the actions taken to resolve the incident. -""".replace( - "\n", " " -).strip() +""".replace("\n", " ").strip() CASE_RESOLUTION_DEFAULT = """ Description of the actions taken to resolve the case. -""".replace( - "\n", " " -).strip() +""".replace("\n", " ").strip() INCIDENT_PARTICIPANT_WELCOME_DESCRIPTION = """ You\'ve been added to this incident, because we think you may be able to help resolve it. Please review the incident details below and -reach out to the incident commander if you have any questions.""".replace( - "\n", " " -).strip() +reach out to the incident commander if you have any questions.""".replace("\n", " ").strip() INCIDENT_PARTICIPANT_SUGGESTED_READING_DESCRIPTION = """ Dispatch thinks the following documents might be -relevant to this incident.""".replace( - "\n", " " -).strip() +relevant to this incident.""".replace("\n", " ").strip() INCIDENT_NOTIFICATION_PURPOSES_FYI = """ -This message is for notification purposes only.""".replace( - "\n", " " -).strip() +This message is for notification purposes only.""".replace("\n", " ").strip() INCIDENT_TACTICAL_REPORT_DESCRIPTION = """ The following conditions, actions, and needs summarize the current status of the incident.""".replace( @@ -246,9 +214,7 @@ class MessageType(DispatchEnum): ).strip() CASE_TRIAGE_REMINDER_DESCRIPTION = """The status of this case hasn't been updated recently. -Please ensure you triage the case based on its priority.""".replace( - "\n", " " -).strip() +Please ensure you triage the case based on its priority.""".replace("\n", " ").strip() CASE_CLOSE_REMINDER_DESCRIPTION = """The status of this case hasn't been updated recently. You can use the case 'Resolve' button if it has been resolved and can be closed.""".replace( @@ -273,9 +239,7 @@ class MessageType(DispatchEnum): INCIDENT_OPEN_TASKS_DESCRIPTION = """ Please resolve or transfer ownership of all the open incident tasks assigned to you in the incident documents or using the <{{dispatch_ui_url}}|Dispatch Web UI>, then wait about 30 seconds for Dispatch to update the tasks before leaving the incident conversation. -""".replace( - "\n", " " -).strip() +""".replace("\n", " ").strip() INCIDENT_MONITOR_CREATED_DESCRIPTION = """ A new monitor instance has been created. diff --git a/src/dispatch/participant/service.py b/src/dispatch/participant/service.py index e6d05367a034..0ea0d73965c1 100644 --- a/src/dispatch/participant/service.py +++ b/src/dispatch/participant/service.py @@ -1,5 +1,5 @@ from typing import List, Optional - +from dispatch.database.core import SessionLocal from dispatch.decorators import timer from dispatch.case import service as case_service from dispatch.incident import service as incident_service @@ -18,6 +18,14 @@ def get(*, db_session, participant_id: int) -> Optional[Participant]: return db_session.query(Participant).filter(Participant.id == participant_id).first() +def get_by_individual_contact_id(db_session: SessionLocal, individual_id: int) -> List[Participant]: + return ( + db_session.query(Participant) + .filter(Participant.individual_contact_id == individual_id) + .all() + ) + + def get_by_incident_id_and_role( *, db_session, incident_id: int, role: str ) -> Optional[Participant]: @@ -120,12 +128,12 @@ def get_by_case_id_and_conversation_id( def get_all(*, db_session) -> List[Optional[Participant]]: """Get all participants.""" - return db_session.query(Participant) + return db_session.query(Participant).all() def get_all_by_incident_id(*, db_session, incident_id: int) -> List[Optional[Participant]]: """Get all participants by incident id.""" - return db_session.query(Participant).filter(Participant.incident_id == incident_id) + return db_session.query(Participant).filter(Participant.incident_id == incident_id).all() def get_or_create( diff --git a/src/dispatch/participant_activity/__init__.py b/src/dispatch/participant_activity/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/dispatch/participant_activity/models.py b/src/dispatch/participant_activity/models.py new file mode 100644 index 000000000000..442fc232561e --- /dev/null +++ b/src/dispatch/participant_activity/models.py @@ -0,0 +1,48 @@ +from datetime import datetime +from sqlalchemy import Column, Integer, ForeignKey, DateTime +from sqlalchemy.orm import relationship +from typing import Optional + +from dispatch.database.core import Base +from dispatch.incident.models import IncidentRead +from dispatch.models import DispatchBase, PrimaryKey +from dispatch.participant.models import ParticipantRead +from dispatch.plugin.models import PluginEvent, PluginEventRead + + +# SQLAlchemy Models +class ParticipantActivity(Base): + id = Column(Integer, primary_key=True) + + plugin_event_id = Column(Integer, ForeignKey(PluginEvent.id)) + plugin_event = relationship(PluginEvent, foreign_keys=[plugin_event_id]) + + started_at = Column(DateTime, default=datetime.utcnow) + ended_at = Column(DateTime, default=datetime.utcnow) + + participant_id = Column(Integer, ForeignKey("participant.id")) + participant = relationship("Participant", foreign_keys=[participant_id]) + + incident_id = Column(Integer, ForeignKey("incident.id")) + incident = relationship("Incident", foreign_keys=[incident_id]) + + +# Pydantic Models +class ParticipantActivityBase(DispatchBase): + plugin_event: PluginEventRead + started_at: Optional[datetime] = None + ended_at: Optional[datetime] = None + participant: ParticipantRead + incident: IncidentRead + + +class ParticipantActivityRead(ParticipantActivityBase): + id: PrimaryKey + + +class ParticipantActivityCreate(ParticipantActivityBase): + pass + + +class ParticipantActivityUpdate(ParticipantActivityBase): + id: PrimaryKey diff --git a/src/dispatch/participant_activity/service.py b/src/dispatch/participant_activity/service.py new file mode 100644 index 000000000000..7dc702762d99 --- /dev/null +++ b/src/dispatch/participant_activity/service.py @@ -0,0 +1,164 @@ +from datetime import datetime, timedelta + +from dispatch.database.core import SessionLocal +from dispatch.participant import service as participant_service +from dispatch.plugin import service as plugin_service + +from .models import ( + ParticipantActivity, + ParticipantActivityRead, + ParticipantActivityCreate, + ParticipantActivityUpdate, +) + + +def get_all_incident_participant_activities_from_last_update( + db_session: SessionLocal, + incident_id: int, +) -> list[ParticipantActivityRead]: + """Fetches the most recent recorded participant incident activities for each participant for a given incident.""" + return ( + db_session.query(ParticipantActivity) + .distinct(ParticipantActivity.participant_id) + .filter(ParticipantActivity.incident_id == incident_id) + .order_by(ParticipantActivity.participant_id, ParticipantActivity.ended_at.desc()) + .all() + ) + + +def create(*, db_session: SessionLocal, activity_in: ParticipantActivityCreate): + """Creates a new record for a participant's activity.""" + activity = ParticipantActivity( + plugin_event_id=activity_in.plugin_event.id, + started_at=activity_in.started_at, + ended_at=activity_in.ended_at, + participant_id=activity_in.participant.id, + incident_id=activity_in.incident.id, + ) + + db_session.add(activity) + db_session.commit() + + return activity + + +def update( + *, + db_session: SessionLocal, + activity: ParticipantActivity, + activity_in: ParticipantActivityUpdate, +) -> ParticipantActivity: + """Updates an existing record for a participant's activity.""" + activity.ended_at = activity_in.ended_at + db_session.commit() + return activity + + +def get_last_participant_activity( + db_session: SessionLocal, incident_id: int +) -> ParticipantActivity: + """Returns the last recorded participant incident activity for a given incident.""" + return ( + db_session.query(ParticipantActivity) + .filter(ParticipantActivity.incident_id == incident_id) + .order_by(ParticipantActivity.ended_at.desc()) + .first() + ) + + +def get_all_incident_participant_activities_for_incident( + db_session: SessionLocal, + incident_id: int, +) -> list[ParticipantActivityRead]: + """Fetches all recorded participant incident activities for a given incident.""" + return ( + db_session.query(ParticipantActivity) + .filter(ParticipantActivity.incident_id == incident_id) + .all() + ) + + +def get_participant_activity_from_last_update( + db_session: SessionLocal, incident_id: int, participant_id: int +) -> ParticipantActivity: + """Fetches the most recent recorded participant incident activity for a given incident and participant.""" + return ( + db_session.query(ParticipantActivity) + .filter(ParticipantActivity.incident_id == incident_id) + .filter(ParticipantActivity.participant_id == participant_id) + .order_by(ParticipantActivity.ended_at.desc()) + .first() + ) + + +def create_or_update(db_session: SessionLocal, activity_in: ParticipantActivityCreate) -> timedelta: + """Creates or updates a participant activity. Returns the change of the participant's total incident response time.""" + delta = timedelta(seconds=0) + + prev_activity = get_participant_activity_from_last_update( + db_session=db_session, + incident_id=activity_in.incident.id, + participant_id=activity_in.participant.id, + ) + + # There's continuous participant activity. + if prev_activity and activity_in.started_at < prev_activity.ended_at: + # Continuation of current plugin event. + if activity_in.plugin_event.id == prev_activity.plugin_event.id: + delta = activity_in.ended_at - prev_activity.ended_at + prev_activity.ended_at = activity_in.ended_at + db_session.commit() + return delta + + # New activity is associated with a different plugin event. + delta += activity_in.started_at - prev_activity.ended_at + prev_activity.ended_at = activity_in.started_at + + create(db_session=db_session, activity_in=activity_in) + delta += activity_in.ended_at - activity_in.started_at + return delta + + +def get_participant_incident_activities_by_individual_contact( + db_session: SessionLocal, individual_contact_id: int +) -> list[ParticipantActivity]: + """Fetches all recorded participant incident activities across all incidents for a given individual.""" + participants = participant_service.get_by_individual_contact_id( + db_session=db_session, individual_id=individual_contact_id + ) + + return ( + db_session.query(ParticipantActivity) + .filter( + ParticipantActivity.participant_id.in_([participant.id for participant in participants]) + ) + .all() + ) + + +def get_all_recorded_incident_partcipant_activities_for_plugin( + db_session: SessionLocal, + incident_id: int, + plugin_id: int, + started_at: datetime = datetime.min, + ended_at: datetime = datetime.utcnow(), +) -> list[ParticipantActivityRead]: + """Fetches all recorded participant incident activities for a given plugin.""" + + plugin_events = plugin_service.get_all_events_for_plugin( + db_session=db_session, plugin_id=plugin_id + ) + participant_activities_for_plugin = [] + + for plugin_event in plugin_events: + event_activities = ( + db_session.query(ParticipantActivity) + .filter(ParticipantActivity.incident_id == incident_id) + .filter(ParticipantActivity.plugin_event_id == plugin_event.id) + .filter(ParticipantActivity.started_at >= started_at) + .filter(ParticipantActivity.ended_at <= ended_at) + .all() + ) + participant_activities_for_plugin.extend(event_activities) + + return participant_activities_for_plugin diff --git a/src/dispatch/plugin/models.py b/src/dispatch/plugin/models.py index ef0af2ec3020..205c49af113e 100644 --- a/src/dispatch/plugin/models.py +++ b/src/dispatch/plugin/models.py @@ -1,21 +1,19 @@ import logging -from typing import Any, List, Optional from pydantic import Field, SecretStr from pydantic.json import pydantic_encoder - -from sqlalchemy.orm import relationship -from sqlalchemy.ext.associationproxy import association_proxy +from typing import Any, List, Optional from sqlalchemy import Column, Integer, String, Boolean, ForeignKey +from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.orm import relationship from sqlalchemy_utils import TSVectorType, StringEncryptedType from sqlalchemy_utils.types.encrypted.encrypted_type import AesEngine - -from dispatch.database.core import Base from dispatch.config import DISPATCH_ENCRYPTION_KEY -from dispatch.models import DispatchBase, ProjectMixin, Pagination, PrimaryKey +from dispatch.database.core import Base +from dispatch.models import DispatchBase, ProjectMixin, Pagination, PrimaryKey, NameStr from dispatch.plugins.base import plugins from dispatch.project.models import ProjectRead @@ -64,6 +62,26 @@ def configuration_schema(self): return None +# SQLAlchemy Model +class PluginEvent(Base): + __table_args__ = {"schema": "dispatch_core"} + id = Column(Integer, primary_key=True) + name = Column(String) + slug = Column(String, unique=True) + description = Column(String) + plugin_id = Column(Integer, ForeignKey(Plugin.id)) + plugin = relationship(Plugin, foreign_keys=[plugin_id]) + + search_vector = Column( + TSVectorType( + "name", + "slug", + "description", + weights={"name": "A", "slug": "B", "description": "C"}, + ) + ) + + class PluginInstance(Base, ProjectMixin): id = Column(Integer, primary_key=True) enabled = Column(Boolean) @@ -148,6 +166,25 @@ class PluginRead(PluginBase): description: Optional[str] = Field(None, nullable=True) +class PluginEventBase(DispatchBase): + name: NameStr + slug: str + plugin: PluginRead + description: Optional[str] = Field(None, nullable=True) + + +class PluginEventRead(PluginEventBase): + id: PrimaryKey + + +class PluginEventCreate(PluginEventBase): + pass + + +class PluginEventPagination(Pagination): + items: List[PluginEventRead] = [] + + class PluginInstanceRead(PluginBase): id: PrimaryKey enabled: Optional[bool] diff --git a/src/dispatch/plugin/service.py b/src/dispatch/plugin/service.py index 818e88f79e2b..9334b12167cb 100644 --- a/src/dispatch/plugin/service.py +++ b/src/dispatch/plugin/service.py @@ -1,15 +1,20 @@ import logging - -from typing import List, Optional - from pydantic.error_wrappers import ErrorWrapper, ValidationError +from typing import List, Optional from dispatch.exceptions import InvalidConfigurationError from dispatch.plugins.bases import OncallPlugin from dispatch.project import service as project_service from dispatch.service import service as service_service -from .models import Plugin, PluginInstance, PluginInstanceCreate, PluginInstanceUpdate +from .models import ( + Plugin, + PluginInstance, + PluginInstanceCreate, + PluginInstanceUpdate, + PluginEvent, + PluginEventCreate, +) log = logging.getLogger(__name__) @@ -27,7 +32,7 @@ def get_by_slug(*, db_session, slug: str) -> Plugin: def get_all(*, db_session) -> List[Optional[Plugin]]: """Returns all plugins.""" - return db_session.query(Plugin) + return db_session.query(Plugin).all() def get_by_type(*, db_session, plugin_type: str) -> List[Optional[Plugin]]: @@ -168,3 +173,28 @@ def delete_instance(*, db_session, plugin_instance_id: int): """Deletes a plugin instance.""" db_session.query(PluginInstance).filter(PluginInstance.id == plugin_instance_id).delete() db_session.commit() + + +def get_plugin_event_by_id(*, db_session, plugin_event_id: int) -> Optional[PluginEvent]: + """Returns a plugin event based on the plugin event id.""" + return db_session.query(PluginEvent).filter(PluginEvent.id == plugin_event_id).one_or_none() + + +def get_plugin_event_by_slug(*, db_session, slug: str) -> Optional[PluginEvent]: + """Returns a project based on the plugin event slug.""" + return db_session.query(PluginEvent).filter(PluginEvent.slug == slug).one_or_none() + + +def get_all_events_for_plugin(*, db_session, plugin_id: int) -> List[Optional[PluginEvent]]: + """Returns all plugin events for a given plugin.""" + return db_session.query(PluginEvent).filter(PluginEvent.plugin_id == plugin_id).all() + + +def create_plugin_event(*, db_session, plugin_event_in: PluginEventCreate) -> PluginEvent: + """Creates a new plugin event.""" + plugin_event = PluginEvent(**plugin_event_in.dict(exclude={"plugin"})) + plugin_event.plugin = get(db_session=db_session, plugin_id=plugin_event_in.plugin.id) + db_session.add(plugin_event) + db_session.commit() + + return plugin_event diff --git a/src/dispatch/plugin/views.py b/src/dispatch/plugin/views.py index 40035e5875a7..e676396fb662 100644 --- a/src/dispatch/plugin/views.py +++ b/src/dispatch/plugin/views.py @@ -6,6 +6,7 @@ from dispatch.models import PrimaryKey from .models import ( + PluginEventPagination, PluginInstanceRead, PluginInstanceCreate, PluginInstanceUpdate, @@ -103,3 +104,9 @@ def delete_plugin_instances( detail=[{"msg": "A plugin instance with this id does not exist."}], ) delete_instance(db_session=db_session, plugin_instance_id=plugin_instance_id) + + +@router.get("/plugin_events", response_model=PluginEventPagination) +def get_plugin_events(common: CommonParameters): + """Get all plugins.""" + return search_filter_sort_paginate(model="PluginEvent", **common) diff --git a/src/dispatch/plugins/base/v1.py b/src/dispatch/plugins/base/v1.py index 8c021aea8696..0df441dc9684 100644 --- a/src/dispatch/plugins/base/v1.py +++ b/src/dispatch/plugins/base/v1.py @@ -19,6 +19,11 @@ class PluginConfiguration(BaseModel): pass +class IPluginEvent: + name: Optional[str] = None + description: Optional[str] = None + + # stolen from https://github.com/getsentry/sentry/ class PluginMount(type): def __new__(cls, name, bases, attrs): @@ -63,6 +68,7 @@ class IPlugin(local): commands: List[Any] = [] events: Any = None + plugin_events: Optional[List[IPluginEvent]] = [] # Global enabled state enabled: bool = False @@ -107,6 +113,14 @@ def get_resource_links(self) -> List[Any]: """ return self.resource_links + def get_event(self, event) -> Optional[IPluginEvent]: + for plugin_event in self.plugin_events: + if plugin_event.slug == event.slug: + return plugin_event + + def fetch_incident_events(self, **kwargs): + raise NotImplementedError + class Plugin(IPlugin): """ diff --git a/src/dispatch/plugins/dispatch_slack/case/interactive.py b/src/dispatch/plugins/dispatch_slack/case/interactive.py index eac76a770601..ec52e92fc3f9 100644 --- a/src/dispatch/plugins/dispatch_slack/case/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/case/interactive.py @@ -63,6 +63,7 @@ case_resolution_reason_select, case_status_select, case_type_select, + cost_model_select, description_input, entity_select, incident_priority_select, @@ -929,6 +930,7 @@ def escalate_button_click( project_id=case.project.id, ), incident_priority_select(db_session=db_session, project_id=case.project.id, optional=True), + cost_model_select(db_session=db_session, project_id=case.project.id, optional=True), ] modal = Modal( diff --git a/src/dispatch/plugins/dispatch_slack/events.py b/src/dispatch/plugins/dispatch_slack/events.py new file mode 100644 index 000000000000..81c4d63e8cf8 --- /dev/null +++ b/src/dispatch/plugins/dispatch_slack/events.py @@ -0,0 +1,64 @@ +import logging +from slack_sdk import WebClient +from typing import List + +from dispatch.plugins.base import IPluginEvent + +from .service import ( + get_channel_activity, + get_thread_activity, +) + +log = logging.getLogger(__name__) + + +class SlackPluginEvent(IPluginEvent): + def fetch_activity(self): + raise NotImplementedError + + +class ChannelActivityEvent(SlackPluginEvent): + name = "Slack Channel Activity" + slug = "slack-channel-activity" + description = "Analyzes incident/case activity within a specific Slack channel.\n \ + By periodically polling channel messages, this gathers insights into the \ + activity and engagement levels of each participant." + + def fetch_activity(client: WebClient, subject: None, oldest: str = "0") -> List: + if not subject: + log.warning("No subject provided. Cannot fetch channel activity.") + elif not subject.conversation: + log.warning("No conversation provided. Cannot fetch channel activity.") + elif not subject.conversation.channel_id: + log.warning("No channel id provided. Cannot fetch channel activity.") + else: + return get_channel_activity( + client, conversation_id=subject.conversation.channel_id, oldest=oldest + ) + return [] + + +class ThreadActivityEvent(SlackPluginEvent): + name = "Slack Thread Activity" + slug = "slack-thread-activity" + description = "Analyzes incident/case activity within a specific Slack thread.\n \ + By periodically polling thread replies, this gathers insights \ + into the activity and engagement levels of each participant." + + def fetch_activity(client: WebClient, subject: None, oldest: str = "0") -> List: + if not subject: + log.warning("No subject provided. Cannot fetch thread activity.") + elif not subject.conversation: + log.warning("No conversation provided. Cannot fetch thread activity.") + elif not subject.conversation.channel_id: + log.warning("No channel id provided. Cannot fetch thread activity.") + elif not subject.conversation.thread_id: + log.warning("No thread id provided. Cannot fetch thread activity.") + else: + return get_thread_activity( + client, + conversation_id=subject.conversation.channel_id, + ts=subject.conversation.thread_id, + oldest=oldest, + ) + return [] diff --git a/src/dispatch/plugins/dispatch_slack/fields.py b/src/dispatch/plugins/dispatch_slack/fields.py index 061fbad1c176..fc14725fd609 100644 --- a/src/dispatch/plugins/dispatch_slack/fields.py +++ b/src/dispatch/plugins/dispatch_slack/fields.py @@ -19,6 +19,7 @@ from dispatch.case.type import service as case_type_service from dispatch.case.priority import service as case_priority_service from dispatch.case.severity import service as case_severity_service +from dispatch.cost_model import service as cost_model_service from dispatch.entity import service as entity_service from dispatch.incident.enums import IncidentStatus from dispatch.incident.type import service as incident_type_service @@ -64,6 +65,9 @@ class DefaultBlockIds(DispatchEnum): # tags tags_multi_select = "tag-multi-select" + # cost models + cost_model_select = "cost-model-select" + class DefaultActionIds(DispatchEnum): date_picker_input = "date-picker-input" @@ -101,6 +105,9 @@ class DefaultActionIds(DispatchEnum): # tags tags_multi_select = "tag-multi-select" + # cost models + cost_model_select = "cost-model-select" + class TimezoneOptions(DispatchEnum): local = "Local Time (based on your Slack profile)" @@ -609,6 +616,31 @@ def case_type_select( ) +def cost_model_select( + db_session: SessionLocal, + action_id: str = DefaultActionIds.cost_model_select, + block_id: str = DefaultBlockIds.cost_model_select, + label: str = "Cost Model", + initial_option: dict = None, + project_id: int = None, + **kwargs, +): + cost_model_options = [ + {"text": cost_model.name, "value": cost_model.id} + for cost_model in cost_model_service.get_all(db_session=db_session, project_id=project_id) + ] + + return static_select_block( + placeholder="Select Cost Model", + options=cost_model_options, + initial_option=initial_option, + action_id=action_id, + block_id=block_id, + label=label, + **kwargs, + ) + + def entity_select( signal_id: int, db_session: SessionLocal, diff --git a/src/dispatch/plugins/dispatch_slack/incident/interactive.py b/src/dispatch/plugins/dispatch_slack/incident/interactive.py index aec662a4c029..5a990a23c382 100644 --- a/src/dispatch/plugins/dispatch_slack/incident/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/incident/interactive.py @@ -28,6 +28,7 @@ from dispatch.auth.models import DispatchUser from dispatch.config import DISPATCH_UI_URL +from dispatch.cost_model import service as cost_model_service from dispatch.database.service import search_filter_sort_paginate from dispatch.enums import Visibility, EventType from dispatch.event import service as event_service @@ -56,6 +57,7 @@ DefaultActionIds, DefaultBlockIds, TimezoneOptions, + cost_model_select, datetime_picker_block, description_input, incident_priority_select, @@ -317,6 +319,12 @@ def handle_update_incident_project_select_action( optional=True, initial_options=[t.name for t in incident.tags], ), + cost_model_select( + db_session=db_session, + initial_option={"text": incident.cost_model.name, "value": incident.cost_model.id}, + project_id=incident.project.id, + optional=True, + ), ] modal = Modal( @@ -1811,6 +1819,12 @@ def handle_update_incident_command( optional=True, initial_options=[{"text": t.name, "value": t.id} for t in incident.tags], ), + cost_model_select( + db_session=db_session, + initial_option={"text": incident.cost_model.name, "value": incident.cost_model.id}, + project_id=incident.project.id, + optional=True, + ), ] modal = Modal( @@ -1858,6 +1872,10 @@ def handle_update_incident_submission_event( tag = tag_service.get(db_session=db_session, tag_id=int(t["value"])) tags.append(tag) + cost_model = cost_model_service.get_cost_model_by_id( + db_session=db_session, + cost_model_id=int(form_data[DefaultBlockIds.cost_model_select]["value"]), + ) incident_in = IncidentUpdate( title=form_data[DefaultBlockIds.title_input], description=form_data[DefaultBlockIds.description_input], @@ -1867,6 +1885,7 @@ def handle_update_incident_submission_event( incident_priority={"name": form_data[DefaultBlockIds.incident_priority_select]["name"]}, status=form_data[DefaultBlockIds.incident_status_select]["name"], tags=tags, + cost_model=cost_model, ) previous_incident = IncidentRead.from_orm(incident) @@ -2035,6 +2054,13 @@ def handle_report_incident_submission_event( if form_data.get(DefaultBlockIds.incident_severity_select): incident_severity = {"name": form_data[DefaultBlockIds.incident_severity_select]["name"]} + cost_model = None + if form_data.get(DefaultBlockIds.cost_model_select): + cost_model = cost_model_service.get_cost_model_by_id( + db_session=db_session, + cost_model_id=int(form_data[DefaultBlockIds.cost_model_select]["value"]), + ) + incident_in = IncidentCreate( title=form_data[DefaultBlockIds.title_input], description=form_data[DefaultBlockIds.description_input], @@ -2044,6 +2070,7 @@ def handle_report_incident_submission_event( project=project, reporter=ParticipantUpdate(individual=IndividualContactRead(email=user.email)), tags=tags, + cost_model=cost_model, ) blocks = [ @@ -2125,6 +2152,7 @@ def handle_report_incident_project_select_action( incident_severity_select(db_session=db_session, project_id=project.id, optional=True), incident_priority_select(db_session=db_session, project_id=project.id, optional=True), tag_multi_select(optional=True), + cost_model_select(db_session=db_session, project_id=project.id, optional=True), ] modal = Modal( diff --git a/src/dispatch/plugins/dispatch_slack/plugin.py b/src/dispatch/plugins/dispatch_slack/plugin.py index 913cf2cfeaf6..aa0c96feffd2 100644 --- a/src/dispatch/plugins/dispatch_slack/plugin.py +++ b/src/dispatch/plugins/dispatch_slack/plugin.py @@ -5,16 +5,17 @@ :license: Apache, see LICENSE for more details. .. moduleauthor:: Kevin Glisson """ -import logging -from typing import List, Optional - from blockkit import Message +import logging +from typing import List, Optional, Any +from slack_sdk.errors import SlackApiError from sqlalchemy.orm import Session from dispatch.auth.models import DispatchUser from dispatch.case.models import Case from dispatch.conversation.enums import ConversationCommands from dispatch.decorators import apply, counter, timer +from dispatch.plugin import service as plugin_service from dispatch.plugins import dispatch_slack as slack_plugin from dispatch.plugins.bases import ContactPlugin, ConversationPlugin from dispatch.plugins.dispatch_slack.config import ( @@ -24,11 +25,16 @@ from dispatch.signal.enums import SignalEngagementStatus from dispatch.signal.models import SignalEngagement, SignalInstance -from .case.messages import create_case_message, create_signal_messages +from .case.messages import ( + create_case_message, + create_signal_messages, + create_signal_engagement_message, +) from .endpoints import router as slack_event_router +from .enums import SlackAPIErrorCode +from .events import ChannelActivityEvent, ThreadActivityEvent from .messaging import create_message_blocks -from .case.messages import create_signal_engagement_message from .service import ( add_conversation_bookmark, add_users_to_conversation, @@ -49,8 +55,7 @@ unarchive_conversation, update_message, ) -from slack_sdk.errors import SlackApiError -from .enums import SlackAPIErrorCode + logger = logging.getLogger(__name__) @@ -63,6 +68,7 @@ class SlackConversationPlugin(ConversationPlugin): description = "Uses Slack to facilitate conversations." version = slack_plugin.__version__ events = slack_event_router + plugin_events = [ChannelActivityEvent, ThreadActivityEvent] author = "Netflix" author_url = "https://github.com/netflix/dispatch.git" @@ -305,6 +311,29 @@ def get_command_name(self, command: str): } return command_mappings.get(command, []) + def fetch_incident_events( + self, db_session: Session, subject: Any, plugin_event_id: int, oldest: str = "0", **kwargs + ): + """Fetches incident events from the Slack plugin. + + Args: + subject: An Incident or Case object. + plugin_event_id: The plugin event id. + oldest: The oldest timestamp to fetch events from. + + Returns: + A sorted list of tuples (utc_dt, user_id). + """ + try: + client = create_slack_client(self.configuration) + plugin_event = plugin_service.get_plugin_event_by_id( + db_session=db_session, plugin_event_id=plugin_event_id + ) + return self.get_event(plugin_event).fetch_activity(client, subject, oldest=oldest) + except Exception as e: + logger.exception(e) + raise e + @apply(counter, exclude=["__init__"]) @apply(timer, exclude=["__init__"]) diff --git a/src/dispatch/plugins/dispatch_slack/service.py b/src/dispatch/plugins/dispatch_slack/service.py index 475b153d9400..80ff827c3317 100644 --- a/src/dispatch/plugins/dispatch_slack/service.py +++ b/src/dispatch/plugins/dispatch_slack/service.py @@ -1,15 +1,16 @@ +from blockkit import Message, Section +from datetime import datetime import functools +import heapq import logging -import time -from typing import Dict, List, Optional, NoReturn - -from tenacity import TryAgain, retry, retry_if_exception_type, stop_after_attempt - -from blockkit import Message, Section from slack_sdk.errors import SlackApiError from slack_sdk.web.client import WebClient from slack_sdk.web.slack_response import SlackResponse +import time +from tenacity import TryAgain, retry, retry_if_exception_type, stop_after_attempt +from typing import Dict, List, Optional, NoReturn + from .config import SlackConversationConfiguration from .enums import SlackAPIErrorCode, SlackAPIGetEndpoints, SlackAPIPostEndpoints @@ -376,3 +377,77 @@ def add_pin(client: WebClient, conversation_id: str, timestamp: str) -> SlackRes def is_user(config: SlackConversationConfiguration, user_id: str) -> bool: """Returns true if it's a regular user, false if Dispatch or Slackbot bot.""" return user_id != config.app_user_slug and user_id != "USLACKBOT" + + +def get_thread_activity( + client: WebClient, conversation_id: str, ts: str, oldest: str = "0" +) -> List: + """Gets all messages for a given Slack thread. + + Returns: + A sorted list of tuples (utc_dt, user_id) of each thread reply. + """ + result = [] + cursor = None + while True: + response = make_call( + client, + SlackAPIGetEndpoints.conversations_replies, + channel=conversation_id, + ts=ts, + cursor=cursor, + oldest=oldest, + ) + if not response["ok"] or "messages" not in response: + break + + for message in response["messages"]: + if "bot_id" in message: + continue + + # Resolves users for messages. + if "user" in message: + user_id = resolve_user(client, message["user"])["id"] + heapq.heappush(result, (datetime.utcfromtimestamp(float(message["ts"])), user_id)) + + if not response["has_more"]: + break + cursor = response["response_metadata"]["next_cursor"] + + return heapq.nsmallest(len(result), result) + + +def get_channel_activity(client: WebClient, conversation_id: str, oldest: str = "0") -> List: + """Gets all top-level messages for a given Slack channel. + + Returns: + A sorted list of tuples (utc_dt, user_id) of each message in the channel. + """ + result = [] + cursor = None + while True: + response = make_call( + client, + SlackAPIGetEndpoints.conversations_history, + channel=conversation_id, + cursor=cursor, + oldest=oldest, + ) + + if not response["ok"] or "messages" not in response: + break + + for message in response["messages"]: + if "bot_id" in message: + continue + + # Resolves users for messages. + if "user" in message: + user_id = resolve_user(client, message["user"])["id"] + heapq.heappush(result, (datetime.utcfromtimestamp(float(message["ts"])), user_id)) + + if not response["has_more"]: + break + cursor = response["response_metadata"]["next_cursor"] + + return heapq.nsmallest(len(result), result) diff --git a/src/dispatch/plugins/dispatch_test/conversation.py b/src/dispatch/plugins/dispatch_test/conversation.py index 8b2b49c66af0..0e10990a1d9d 100644 --- a/src/dispatch/plugins/dispatch_test/conversation.py +++ b/src/dispatch/plugins/dispatch_test/conversation.py @@ -1,9 +1,23 @@ +from datetime import datetime +from slack_sdk import WebClient +from typing import Any + from dispatch.plugins.bases import ConversationPlugin +from dispatch.plugins.dispatch_slack.events import ChannelActivityEvent + + +class TestWebClient(WebClient): + def api_call(self, *args, **kwargs): + return {"ok": True, "messages": [], "has_more": False} class TestConversationPlugin(ConversationPlugin): + id = 123 title = "Dispatch Test Plugin - Conversation" slug = "test-conversation" + configuration = {"api_bot_token": "123"} + type = "conversation" + plugin_events = [ChannelActivityEvent] def create(self, items, **kwargs): return @@ -13,3 +27,13 @@ def add(self, items, **kwargs): def send(self, items, **kwargs): return + + def fetch_incident_events(self, subject: Any, **kwargs): + client = TestWebClient() + for plugin_event in self.plugin_events: + plugin_event.fetch_activity(client=client, subject=subject) + return [ + (datetime.utcfromtimestamp(1512085950.000216), "0XDECAFBAD"), + (datetime.utcfromtimestamp(1512104434.000490), "0XDECAFBAD"), + (datetime.utcfromtimestamp(1512104534.000490), "0X8BADF00D"), + ] diff --git a/src/dispatch/static/dispatch/src/cost_model/CostModelActivityDialog.vue b/src/dispatch/static/dispatch/src/cost_model/CostModelActivityDialog.vue new file mode 100644 index 000000000000..f65d4f11f8b4 --- /dev/null +++ b/src/dispatch/static/dispatch/src/cost_model/CostModelActivityDialog.vue @@ -0,0 +1,177 @@ + + + diff --git a/src/dispatch/static/dispatch/src/cost_model/CostModelActivityInput.vue b/src/dispatch/static/dispatch/src/cost_model/CostModelActivityInput.vue new file mode 100644 index 000000000000..de1ec2266469 --- /dev/null +++ b/src/dispatch/static/dispatch/src/cost_model/CostModelActivityInput.vue @@ -0,0 +1,123 @@ + + + diff --git a/src/dispatch/static/dispatch/src/cost_model/CostModelCombobox.vue b/src/dispatch/static/dispatch/src/cost_model/CostModelCombobox.vue new file mode 100644 index 000000000000..b81268a51a70 --- /dev/null +++ b/src/dispatch/static/dispatch/src/cost_model/CostModelCombobox.vue @@ -0,0 +1,130 @@ + + + diff --git a/src/dispatch/static/dispatch/src/cost_model/DeleteDialog.vue b/src/dispatch/static/dispatch/src/cost_model/DeleteDialog.vue new file mode 100644 index 000000000000..5b1798ef9558 --- /dev/null +++ b/src/dispatch/static/dispatch/src/cost_model/DeleteDialog.vue @@ -0,0 +1,34 @@ + + + diff --git a/src/dispatch/static/dispatch/src/cost_model/NewEditSheet.vue b/src/dispatch/static/dispatch/src/cost_model/NewEditSheet.vue new file mode 100644 index 000000000000..ab76da57185b --- /dev/null +++ b/src/dispatch/static/dispatch/src/cost_model/NewEditSheet.vue @@ -0,0 +1,110 @@ + + + diff --git a/src/dispatch/static/dispatch/src/cost_model/Table.vue b/src/dispatch/static/dispatch/src/cost_model/Table.vue new file mode 100644 index 000000000000..3d2be4f502d6 --- /dev/null +++ b/src/dispatch/static/dispatch/src/cost_model/Table.vue @@ -0,0 +1,149 @@ + + + diff --git a/src/dispatch/static/dispatch/src/cost_model/api.js b/src/dispatch/static/dispatch/src/cost_model/api.js new file mode 100644 index 000000000000..c5f02204a58d --- /dev/null +++ b/src/dispatch/static/dispatch/src/cost_model/api.js @@ -0,0 +1,21 @@ +import API from "@/api" + +const resource = "/cost_models" + +export default { + getAll(options) { + return API.get(`/${resource}`, { + params: { ...options }, + }) + }, + + create(payload) { + return API.post(`/${resource}`, payload) + }, + update(costModelId, payload) { + return API.put(`/${resource}/${costModelId}`, payload) + }, + delete(costModelId) { + return API.delete(`/${resource}/${costModelId}`) + }, +} diff --git a/src/dispatch/static/dispatch/src/cost_model/store.js b/src/dispatch/static/dispatch/src/cost_model/store.js new file mode 100644 index 000000000000..ad99907a5385 --- /dev/null +++ b/src/dispatch/static/dispatch/src/cost_model/store.js @@ -0,0 +1,177 @@ +import { getField, updateField } from "vuex-map-fields" +import { debounce } from "lodash" + +import SearchUtils from "@/search/utils" +import CostModelApi from "@/cost_model/api" + +const getDefaultSelectedState = () => { + return { + id: null, + name: null, + enabled: null, + description: null, + created_at: null, + updated_at: null, + project: null, + loading: false, + activities: [], + } +} + +const state = { + selected: { + ...getDefaultSelectedState(), + }, + dialogs: { + showCreateEdit: false, + showRemove: false, + showActivity: false, + }, + table: { + rows: { + items: [], + total: null, + }, + options: { + q: "", + page: 1, + itemsPerPage: 25, + sortBy: ["CostModel.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 }, + "CostModel" + ) + return CostModelApi.getAll(params) + .then((response) => { + commit("SET_TABLE_LOADING", false) + commit("SET_TABLE_ROWS", response.data) + }) + .catch(() => { + commit("SET_TABLE_LOADING", false) + }) + }, 500), + createEditShow({ commit }, incidentCostModel) { + commit("SET_DIALOG_EDIT", true) + if (incidentCostModel) { + commit("SET_SELECTED", incidentCostModel) + } + }, + closeCreateEdit({ commit }) { + commit("SET_DIALOG_EDIT", false) + commit("RESET_SELECTED") + }, + createActivityShow({ commit }) { + commit("SET_DIALOG_ACTIVITY", true) + }, + closeActivity({ commit }) { + commit("SET_DIALOG_ACTIVITY", false) + }, + removeShow({ commit }, incidentCostModel) { + commit("SET_DIALOG_DELETE", true) + commit("SET_SELECTED", incidentCostModel) + }, + closeRemove({ commit }) { + commit("SET_DIALOG_DELETE", false) + commit("RESET_SELECTED") + }, + save({ commit, state, dispatch }) { + commit("SET_SELECTED_LOADING", true) + if (!state.selected.id) { + return CostModelApi.create(state.selected) + .then(() => { + commit("SET_SELECTED_LOADING", false) + dispatch("closeCreateEdit") + dispatch("getAll") + commit( + "notification_backend/addBeNotification", + { text: "Cost model created successfully.", type: "success" }, + { root: true } + ) + }) + .catch(() => { + commit("SET_SELECTED_LOADING", false) + }) + } else { + return CostModelApi.update(state.selected.id, state.selected) + .then(() => { + commit("SET_SELECTED_LOADING", false) + dispatch("closeCreateEdit") + dispatch("getAll") + commit( + "notification_backend/addBeNotification", + { text: "Cost model updated successfully.", type: "success" }, + { root: true } + ) + }) + .catch(() => { + commit("SET_SELECTED_LOADING", false) + }) + } + }, + remove({ commit, state, dispatch }) { + return CostModelApi.delete(state.selected.id).then(function () { + dispatch("closeRemove") + dispatch("getAll") + commit( + "notification_backend/addBeNotification", + { text: "Cost model deleted successfully.", type: "success" }, + { root: true } + ) + }) + }, +} + +const mutations = { + updateField, + SET_SELECTED(state, value) { + state.selected = Object.assign(state.selected, value) + }, + SET_SELECTED_LOADING(state, value) { + state.selected.loading = value + }, + SET_TABLE_LOADING(state, value) { + state.table.loading = value + }, + SET_TABLE_ROWS(state, value) { + state.table.rows = value + }, + SET_DIALOG_EDIT(state, value) { + state.dialogs.showCreateEdit = value + }, + SET_DIALOG_DELETE(state, value) { + state.dialogs.showRemove = value + }, + SET_DIALOG_ACTIVITY(state, value) { + state.dialogs.showActivity = value + }, + RESET_SELECTED(state) { + // do not reset project + let project = state.selected.project + state.selected = { ...getDefaultSelectedState() } + state.selected.project = project + }, +} + +export default { + namespaced: true, + state, + getters, + actions, + mutations, +} diff --git a/src/dispatch/static/dispatch/src/incident/DetailsTab.vue b/src/dispatch/static/dispatch/src/incident/DetailsTab.vue index 2fca289d1730..4aeb75cfb99d 100644 --- a/src/dispatch/static/dispatch/src/incident/DetailsTab.vue +++ b/src/dispatch/static/dispatch/src/incident/DetailsTab.vue @@ -102,6 +102,15 @@ :model-id="id" /> + + + @@ -120,6 +129,7 @@ import { required } from "@/util/form" import { mapFields } from "vuex-map-fields" import CaseFilterCombobox from "@/case/CaseFilterCombobox.vue" +import CostModelCombobox from "@/cost_model/CostModelCombobox.vue" import DateTimePickerMenu from "@/components/DateTimePickerMenu.vue" import IncidentFilterCombobox from "@/incident/IncidentFilterCombobox.vue" import IncidentPrioritySelect from "@/incident/priority/IncidentPrioritySelect.vue" @@ -140,6 +150,7 @@ export default { components: { CaseFilterCombobox, DateTimePickerMenu, + CostModelCombobox, IncidentFilterCombobox, IncidentPrioritySelect, IncidentSeveritySelect, @@ -169,6 +180,7 @@ export default { ...mapFields("incident", [ "selected.cases", "selected.commander", + "selected.cost_model", "selected.created_at", "selected.description", "selected.duplicates", diff --git a/src/dispatch/static/dispatch/src/incident/ReportSubmissionCard.vue b/src/dispatch/static/dispatch/src/incident/ReportSubmissionCard.vue index 048006077a2d..b9aa16daf408 100644 --- a/src/dispatch/static/dispatch/src/incident/ReportSubmissionCard.vue +++ b/src/dispatch/static/dispatch/src/incident/ReportSubmissionCard.vue @@ -84,6 +84,16 @@ :rules="[only_one]" /> + + + @@ -116,6 +126,7 @@ import { isNavigationFailure, NavigationFailureType } from "vue-router" import router from "@/router" +import CostModelCombobox from "@/cost_model/CostModelCombobox.vue" import DocumentApi from "@/document/api" import IncidentPrioritySelect from "@/incident/priority/IncidentPrioritySelect.vue" import IncidentTypeSelect from "@/incident/type/IncidentTypeSelect.vue" @@ -132,6 +143,7 @@ export default { name: "ReportSubmissionCard", components: { + CostModelCombobox, IncidentTypeSelect, IncidentPrioritySelect, ProjectSelect, @@ -158,6 +170,7 @@ export default { "selected.incident_priority", "selected.incident_type", "selected.commander_email", + "selected.cost_model", "selected.title", "selected.tags", "selected.description", diff --git a/src/dispatch/static/dispatch/src/incident/ReportSubmissionForm.vue b/src/dispatch/static/dispatch/src/incident/ReportSubmissionForm.vue index bcc63590bb39..f6d7342c4f09 100644 --- a/src/dispatch/static/dispatch/src/incident/ReportSubmissionForm.vue +++ b/src/dispatch/static/dispatch/src/incident/ReportSubmissionForm.vue @@ -49,6 +49,16 @@ model="incident" /> + + + @@ -58,6 +68,7 @@ import { mapFields } from "vuex-map-fields" import { required } from "@/util/form" +import CostModelCombobox from "@/cost_model/CostModelCombobox.vue" import IncidentPrioritySelect from "@/incident/priority/IncidentPrioritySelect.vue" import IncidentTypeSelect from "@/incident/type/IncidentTypeSelect.vue" import ProjectSelect from "@/project/ProjectSelect.vue" @@ -72,6 +83,7 @@ export default { name: "ReportSubmissionForm", components: { + CostModelCombobox, IncidentPrioritySelect, IncidentTypeSelect, ProjectSelect, @@ -98,6 +110,7 @@ export default { "selected.commander", "selected.conference", "selected.conversation", + "selected.cost_model", "selected.description", "selected.documents", "selected.id", diff --git a/src/dispatch/static/dispatch/src/incident/store.js b/src/dispatch/static/dispatch/src/incident/store.js index 691af12a3ee7..1ec8f4d0242a 100644 --- a/src/dispatch/static/dispatch/src/incident/store.js +++ b/src/dispatch/static/dispatch/src/incident/store.js @@ -13,6 +13,7 @@ const getDefaultSelectedState = () => { commander: null, conference: null, conversation: null, + cost_model: null, created_at: null, description: null, documents: null, diff --git a/src/dispatch/static/dispatch/src/plugin/PluginEventCombobox.vue b/src/dispatch/static/dispatch/src/plugin/PluginEventCombobox.vue new file mode 100644 index 000000000000..e320f188bffe --- /dev/null +++ b/src/dispatch/static/dispatch/src/plugin/PluginEventCombobox.vue @@ -0,0 +1,126 @@ + + + diff --git a/src/dispatch/static/dispatch/src/plugin/PluginInstanceCombobox.vue b/src/dispatch/static/dispatch/src/plugin/PluginInstanceCombobox.vue index 570a4026ca40..58183576bd61 100644 --- a/src/dispatch/static/dispatch/src/plugin/PluginInstanceCombobox.vue +++ b/src/dispatch/static/dispatch/src/plugin/PluginInstanceCombobox.vue @@ -57,6 +57,10 @@ export default { type: String, default: "Plugins", }, + requiresPluginEvents: { + type: Boolean, + default: false, + }, project: { type: [Object], default: null, @@ -81,8 +85,9 @@ export default { methods: { loadMore() { this.numItems = this.numItems + 5 + this.fetchData() }, - fetchData() { + async fetchData() { this.error = null this.loading = "error" let filter = { @@ -111,11 +116,25 @@ export default { }) } + // Only display plugins that have PluginEvents. + if (this.requiresPluginEvents) { + await PluginApi.getAllPluginEvents().then((response) => { + let plugin_events = response.data.items + + filter["and"].push({ + model: "Plugin", + field: "slug", + op: "in", + value: plugin_events.map((p) => p.plugin.slug), + }) + }) + } + let filterOptions = { q: this.search, sortBy: ["slug"], itemsPerPage: this.numItems, - filter: JSON.stringify(this.filter), + filter: JSON.stringify(filter), } PluginApi.getAllInstances(filterOptions).then((response) => { diff --git a/src/dispatch/static/dispatch/src/plugin/api.js b/src/dispatch/static/dispatch/src/plugin/api.js index 19d5462ac153..cfec542f3958 100644 --- a/src/dispatch/static/dispatch/src/plugin/api.js +++ b/src/dispatch/static/dispatch/src/plugin/api.js @@ -30,4 +30,10 @@ export default { deleteInstance(instanceId) { return API.delete(`/${resource}/instances/${instanceId}`) }, + + getAllPluginEvents(options) { + return API.get(`/${resource}/plugin_events`, { + params: { ...options }, + }) + }, } diff --git a/src/dispatch/static/dispatch/src/plugin/store.js b/src/dispatch/static/dispatch/src/plugin/store.js index 72ce57ec3875..ccf54148c07c 100644 --- a/src/dispatch/static/dispatch/src/plugin/store.js +++ b/src/dispatch/static/dispatch/src/plugin/store.js @@ -192,7 +192,11 @@ function convertToFormkit(json_schema) { const mutations = { updateField, SET_SELECTED(state, value) { - state.selected = Object.assign(state.selected, value) + Object.keys(value).forEach(function (key) { + if (value[key]) { + state.selected[key] = value[key] + } + }) state.selected.formkit_configuration_schema = convertToFormkit(value.configuration_schema) }, SET_SELECTED_LOADING(state, value) { diff --git a/src/dispatch/static/dispatch/src/router/config.js b/src/dispatch/static/dispatch/src/router/config.js index deedb66e7121..e87f1f474d51 100644 --- a/src/dispatch/static/dispatch/src/router/config.js +++ b/src/dispatch/static/dispatch/src/router/config.js @@ -407,6 +407,12 @@ export const protectedRoute = [ meta: { title: "Workflows", subMenu: "project", group: "general" }, component: () => import("@/workflow/Table.vue"), }, + { + path: "costModels", + name: "CostModelTable", + meta: { title: "Cost Models", subMenu: "project", group: "general" }, + component: () => import("@/cost_model/Table.vue"), + }, { path: "incidentTypes", name: "IncidentTypeTable", diff --git a/src/dispatch/static/dispatch/src/store.js b/src/dispatch/static/dispatch/src/store.js index aeb22e8886af..1aebfaf51ce1 100644 --- a/src/dispatch/static/dispatch/src/store.js +++ b/src/dispatch/static/dispatch/src/store.js @@ -6,6 +6,7 @@ import case_management from "@/case/store" import case_priority from "@/case/priority/store" import case_severity from "@/case/severity/store" import case_type from "@/case/type/store" +import cost_model from "@/cost_model/store" import definition from "@/definition/store" import document from "@/document/store" import entity from "@/entity/store" @@ -66,6 +67,7 @@ export default createStore({ forms_type, incident_feedback, incident, + cost_model, incident_cost_type, incident_priority, incident_severity, diff --git a/tests/conftest.py b/tests/conftest.py index adaabba106a1..2818ede5f8f4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -37,6 +37,8 @@ FeedbackFactory, GroupFactory, IncidentCostFactory, + CostModelFactory, + CostModelActivityFactory, IncidentCostTypeFactory, IncidentFactory, IncidentPriorityFactory, @@ -45,9 +47,11 @@ IndividualContactFactory, NotificationFactory, OrganizationFactory, + ParticipantActivityFactory, ParticipantFactory, ParticipantRoleFactory, PluginFactory, + PluginEventFactory, PluginInstanceFactory, ProjectFactory, RecommendationFactory, @@ -519,6 +523,11 @@ def incident(session): return IncidentFactory() +@pytest.fixture +def participant_activity(session): + return ParticipantActivityFactory() + + @pytest.fixture def event(session): return EventFactory() @@ -612,3 +621,18 @@ def workflow(session, workflow_plugin_instance): @pytest.fixture def workflow_instance(session): return WorkflowInstanceFactory() + + +@pytest.fixture +def plugin_event(session): + return PluginEventFactory() + + +@pytest.fixture +def cost_model(session): + return CostModelFactory() + + +@pytest.fixture +def cost_model_activity(session): + return CostModelActivityFactory() diff --git a/tests/cost_model/test_cost_model_service.py b/tests/cost_model/test_cost_model_service.py new file mode 100644 index 000000000000..2f19a5792390 --- /dev/null +++ b/tests/cost_model/test_cost_model_service.py @@ -0,0 +1,282 @@ +# Cost Model Activity Tests + + +def test_create_cost_model_activity(session, plugin_event): + from dispatch.cost_model.service import create_cost_model_activity + from dispatch.cost_model.models import CostModelActivityCreate + + cost_model_activity_in = CostModelActivityCreate( + plugin_event=plugin_event, + response_time_seconds=5, + enabled=True, + ) + + activity = create_cost_model_activity( + db_session=session, cost_model_activity_in=cost_model_activity_in + ) + + assert activity + + +def test_update_cost_model_activity(session, cost_model_activity): + from dispatch.cost_model.service import update_cost_model_activity + from dispatch.cost_model.models import CostModelActivityUpdate + + cost_model_activity_in = CostModelActivityUpdate( + id=cost_model_activity.id, + plugin_event=cost_model_activity.plugin_event, + response_time_seconds=cost_model_activity.response_time_seconds + 2, + enabled=cost_model_activity.enabled, + ) + + activity = update_cost_model_activity( + db_session=session, cost_model_activity_in=cost_model_activity_in + ) + + assert activity + assert activity.response_time_seconds == cost_model_activity_in.response_time_seconds + + +def test_delete_cost_model_activity(session, cost_model_activity): + from dispatch.cost_model.service import ( + delete_cost_model_activity, + get_cost_model_activity_by_id, + ) + from sqlalchemy.orm.exc import NoResultFound + + delete_cost_model_activity(db_session=session, cost_model_activity_id=cost_model_activity.id) + deleted = False + + try: + get_cost_model_activity_by_id( + db_session=session, cost_model_activity_id=cost_model_activity.id + ) + except NoResultFound: + deleted = True + + assert deleted + + +# Cost Model Tests + + +def test_create_cost_model(session, cost_model_activity, project): + """Tests that a cost model can be created.""" + from dispatch.cost_model.models import CostModelCreate + from datetime import datetime + from dispatch.cost_model.service import create, get_cost_model_by_id + + name = "model_name" + description = "model_description" + activities = [cost_model_activity] + enabled = False + + cost_model_in = CostModelCreate( + name=name, + description=description, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + activities=activities, + enabled=enabled, + project=project, + ) + cost_model = create(db_session=session, cost_model_in=cost_model_in) + + # Validate cost model creation + assert cost_model + assert cost_model.name == cost_model_in.name + assert cost_model.description == cost_model_in.description + assert cost_model.enabled == cost_model_in.enabled + assert len(cost_model.activities) == len(cost_model_in.activities) + + activity_out = cost_model.activities[0] + assert activity_out.response_time_seconds == cost_model_activity.response_time_seconds + assert activity_out.enabled == cost_model_activity.enabled + assert activity_out.plugin_event.id == cost_model_activity.plugin_event.id + + # Validate cost model retrieval + cost_model = get_cost_model_by_id(db_session=session, cost_model_id=cost_model.id) + assert cost_model.created_at == cost_model.created_at + assert cost_model.updated_at == cost_model.updated_at + assert cost_model.name == cost_model.name + assert cost_model.description == cost_model.description + assert cost_model.enabled == cost_model.enabled + assert len(cost_model.activities) == len(cost_model.activities) + + +def test_fail_create_cost_model(session, plugin_event, project): + """Tests that a cost model cannot be created with duplicate plugin events.""" + from dispatch.cost_model.models import CostModelCreate + from dispatch.cost_model.models import CostModelActivityCreate + from datetime import datetime + from dispatch.cost_model.service import create + + cost_model_activity_in = CostModelActivityCreate( + plugin_event=plugin_event, + response_time_seconds=5, + enabled=True, + ) + + name = "model_name" + description = "model_description" + activities = [cost_model_activity_in, cost_model_activity_in] + enabled = False + + cost_model_in = CostModelCreate( + name=name, + description=description, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + activities=activities, + enabled=enabled, + project=project, + ) + try: + create(db_session=session, cost_model_in=cost_model_in) + except KeyError as e: + assert "Duplicate plugin event ids" in str(e) + + +def test_update_cost_model(session, cost_model): + """Tests that a cost model and all its activities are updated. + + The update test cases are: + - Adding a new cost model activity to the existing cost model + - Modifying an existing cost model activity + - Deleting a cost model activity from the existing cost model + """ + import copy + from tests.factories import PluginEventFactory + + from dispatch.cost_model.service import update + from dispatch.cost_model.models import ( + CostModelActivityCreate, + CostModelActivityUpdate, + CostModelUpdate, + ) + + plugin_event_0 = PluginEventFactory() + plugin_event_1 = PluginEventFactory() + + # Update: adding new cost model activities + add_cost_model_activity_0 = CostModelActivityCreate( + plugin_event=plugin_event_0, response_time_seconds=1, enabled=True + ) + add_cost_model_activity_1 = CostModelActivityCreate( + plugin_event=plugin_event_1, + response_time_seconds=2, + enabled=True, + ) + add_update_cost_model_in = CostModelUpdate( + id=cost_model.id, + name="new name", + description="new description", + enabled=True, + project=cost_model.project, + activities=[add_cost_model_activity_0, add_cost_model_activity_1], + ) + + add_update_cost_model = update(db_session=session, cost_model_in=add_update_cost_model_in) + + assert add_update_cost_model.description == add_update_cost_model_in.description + assert add_update_cost_model.name == add_update_cost_model_in.name + assert add_update_cost_model.enabled == add_update_cost_model_in.enabled + assert len(add_update_cost_model.activities) == len(add_update_cost_model_in.activities) + for ( + actual, + expected, + ) in zip( + add_update_cost_model.activities, + add_update_cost_model_in.activities, + strict=True, + ): + assert actual.response_time_seconds == expected.response_time_seconds + assert actual.enabled == expected.enabled + assert actual.plugin_event.id == expected.plugin_event.id + + id_0 = add_update_cost_model.activities[0].id + id_1 = add_update_cost_model.activities[1].id + + # Update: modifying existing cost model activities + modify_cost_model_activity_0 = CostModelActivityUpdate( + plugin_event=plugin_event_0, response_time_seconds=3, enabled=True, id=id_0 + ) + modify_cost_model_activity_1 = CostModelActivityUpdate( + plugin_event=plugin_event_1, + response_time_seconds=4, + enabled=True, + id=id_1, + ) + modify_update_cost_model_in = CostModelUpdate( + id=cost_model.id, + name="new name", + description="new description", + enabled=True, + project=cost_model.project, + activities=[modify_cost_model_activity_0, modify_cost_model_activity_1], + ) + modify_update_cost_model = update( + db_session=session, + cost_model_in=copy.deepcopy(modify_update_cost_model_in), + ) + + assert modify_update_cost_model.description == modify_update_cost_model_in.description + assert modify_update_cost_model.name == modify_update_cost_model_in.name + assert modify_update_cost_model.enabled == modify_update_cost_model_in.enabled + assert len(modify_update_cost_model.activities) == len(modify_update_cost_model_in.activities) + for ( + actual, + expected, + ) in zip( + modify_update_cost_model.activities, + modify_update_cost_model_in.activities, + strict=True, + ): + assert actual.response_time_seconds == expected.response_time_seconds + assert actual.enabled == expected.enabled + assert actual.plugin_event.id == expected.plugin_event.id + + # Update: deleting existing cost model activities + retained_cost_model_activity = modify_cost_model_activity_1 + delete_update_cost_model_in = CostModelUpdate( + id=cost_model.id, + name=cost_model.name, + description=cost_model.description, + enabled=cost_model.enabled, + project=cost_model.project, + activities=[retained_cost_model_activity], + ) + delete_update_cost_model = update(db_session=session, cost_model_in=delete_update_cost_model_in) + assert len(delete_update_cost_model.activities) == 1 + assert ( + delete_update_cost_model.activities[0].plugin_event.id + == retained_cost_model_activity.plugin_event.id + ) + + +def test_delete_cost_model(session, cost_model, cost_model_activity): + """Tests that a cost model and all its activities are deleted.""" + from dispatch.cost_model.service import delete, get_cost_model_by_id + from dispatch.cost_model import ( + service as cost_model_service, + ) + from sqlalchemy.orm.exc import NoResultFound + + cost_model.activities.append(cost_model_activity) + delete(db_session=session, cost_model_id=cost_model.id) + deleted = False + + try: + get_cost_model_by_id(db_session=session, cost_model_id=cost_model.id) + except NoResultFound: + deleted = True + + try: + cost_model_service.get_cost_model_activity_by_id( + db_session=session, cost_model_activity_id=cost_model_activity.id + ) + except NoResultFound: + deleted = deleted and True + + # Fails if the cost model and all its activities are not deleted. + assert deleted diff --git a/tests/factories.py b/tests/factories.py index 7b8291e5699a..47b91f451365 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -34,6 +34,8 @@ from dispatch.incident.severity.models import IncidentSeverity from dispatch.incident.type.models import IncidentType from dispatch.incident_cost.models import IncidentCost +from dispatch.cost_model.models import CostModel, CostModelActivity +from dispatch.participant_activity.models import ParticipantActivity from dispatch.incident_cost_type.models import IncidentCostType from dispatch.incident_role.models import IncidentRole from dispatch.individual.models import IndividualContact @@ -41,7 +43,7 @@ from dispatch.organization.models import Organization from dispatch.participant.models import Participant from dispatch.participant_role.models import ParticipantRole -from dispatch.plugin.models import Plugin, PluginInstance +from dispatch.plugin.models import Plugin, PluginInstance, PluginEvent from dispatch.project.models import Project from dispatch.report.models import Report from dispatch.route.models import Recommendation, RecommendationMatch @@ -140,6 +142,32 @@ def organization(self, create, extracted, **kwargs): self.organization_id = extracted.id +class CostModelFactory(BaseFactory): + """Cost Model Factory.""" + + id = Sequence(lambda n: f"1{n}") + name = FuzzyText() + description = FuzzyText() + created_at = FuzzyDateTime(datetime(2020, 1, 1, tzinfo=UTC)) + updated_at = FuzzyDateTime(datetime(2020, 1, 1, tzinfo=UTC)) + enabled = Faker().pybool() + project = SubFactory(ProjectFactory) + + class Meta: + """Factory Configuration.""" + + model = CostModel + + @post_generation + def activities(self, create, extracted, **kwargs): + if not create: + return + + if extracted: + for activity in extracted: + self.activities.append(activity) + + class ResourceBaseFactory(TimeStampBaseFactory): """Resource Base Factory.""" @@ -489,6 +517,7 @@ class ParticipantFactory(BaseFactory): location = Sequence(lambda n: f"location{n}") added_reason = Sequence(lambda n: f"added_reason{n}") after_hours_notification = Faker().pybool() + user_conversation_id = FuzzyText() class Meta: """Factory Configuration.""" @@ -888,6 +917,8 @@ class IncidentFactory(BaseFactory): incident_priority = SubFactory(IncidentPriorityFactory) incident_severity = SubFactory(IncidentSeverityFactory) project = SubFactory(ProjectFactory) + cost_model = SubFactory(CostModelFactory) + conversation = SubFactory(ConversationFactory) class Meta: """Factory Configuration.""" @@ -1212,6 +1243,7 @@ class Meta: class PluginInstanceFactory(BaseFactory): """PluginInstance Factory.""" + # id = Sequence(lambda n: f"1{n}") enabled = True project = SubFactory(ProjectFactory) plugin = SubFactory(PluginFactory) @@ -1222,6 +1254,51 @@ class Meta: model = PluginInstance +class PluginEventFactory(BaseFactory): + """Plugin Event Factory.""" + + id = Sequence(lambda n: f"1{n}") + name = FuzzyText() + slug = Sequence(lambda n: f"1{n}") # Ensures unique slug + plugin = SubFactory(PluginFactory) + + class Meta: + """Factory Configuration.""" + + model = PluginEvent + + +class CostModelActivityFactory(BaseFactory): + """Cost Model Activity Factory.""" + + response_time_seconds = FuzzyInteger(low=1, high=10000) + enabled = Faker().pybool() + plugin_event = SubFactory(PluginEventFactory) + + class Meta: + """Factory Configuration.""" + + model = CostModelActivity + + +class ParticipantActivityFactory(BaseFactory): + """Participant Activity Factory.""" + + id = Sequence(lambda n: f"1{n}") + plugin_event = SubFactory(PluginEventFactory) + started_at = FuzzyDateTime( + start_dt=datetime(2020, 1, 1, tzinfo=UTC), end_dt=datetime(2020, 2, 1, tzinfo=UTC) + ) + ended_at = FuzzyDateTime(start_dt=datetime(2020, 2, 2, tzinfo=UTC)) + participant = SubFactory(ParticipantFactory) + incident = SubFactory(IncidentFactory) + + class Meta: + """Factory Configuration.""" + + model = ParticipantActivity + + class WorkflowFactory(BaseFactory): """Workflow Factory.""" diff --git a/tests/incident_cost/test_incident_cost_service.py b/tests/incident_cost/test_incident_cost_service.py index 0ae4ca4b2ae5..be44103c0954 100644 --- a/tests/incident_cost/test_incident_cost_service.py +++ b/tests/incident_cost/test_incident_cost_service.py @@ -45,3 +45,72 @@ def test_delete(session, incident_cost): delete(db_session=session, incident_cost_id=incident_cost.id) assert not get(db_session=session, incident_cost_id=incident_cost.id) + + +def test_calculate_incident_response_cost_with_cost_model( + session, + incident, + incident_cost_type, + cost_model_activity, + conversation_plugin_instance, + conversation, + participant, +): + """Tests that the incident cost is calculated correctly when a cost model is enabled.""" + from datetime import timedelta + import math + from dispatch.incident.service import get + from dispatch.incident_cost.service import ( + update_incident_response_cost, + ) + from dispatch.incident_cost_type import service as incident_cost_type_service + from dispatch.participant_activity.service import ( + get_all_incident_participant_activities_for_incident, + ) + from dispatch.plugins.dispatch_slack.events import ChannelActivityEvent + + SECONDS_IN_HOUR = 3600 + orig_total_incident_cost = incident.total_cost + + # Set incoming plugin events. + conversation_plugin_instance.project_id = incident.project.id + cost_model_activity.plugin_event.plugin = conversation_plugin_instance.plugin + participant.user_conversation_id = "0XDECAFBAD" + participant.incident = incident + + # Set up a default incident costs type. + for cost_type in incident_cost_type_service.get_all(db_session=session): + cost_type.default = False + incident_cost_type.default = True + incident_cost_type.project = incident.project + + # Set up incident. + incident = get(db_session=session, incident_id=incident.id) + cost_model_activity.plugin_event.slug = ChannelActivityEvent.slug + incident.cost_model.enabled = True + incident.cost_model.activities = [cost_model_activity] + incident.conversation = conversation + + # Calculates and updates the incident cost. + cost = update_incident_response_cost(incident_id=incident.id, db_session=session) + activities = get_all_incident_participant_activities_for_incident( + db_session=session, incident_id=incident.id + ) + assert activities + + # Evaluate expected incident cost. + participants_total_response_time_seconds = timedelta(seconds=0) + for activity in activities: + participants_total_response_time_seconds += activity.ended_at - activity.started_at + hourly_rate = math.ceil( + incident.project.annual_employee_cost / incident.project.business_year_hours + ) + expected_incident_cost = ( + math.ceil( + (participants_total_response_time_seconds.seconds / SECONDS_IN_HOUR) * hourly_rate + ) + + orig_total_incident_cost + ) + + assert cost + assert cost == expected_incident_cost == incident.total_cost diff --git a/tests/incident_cost_type/test_incident_cost_type_service.py b/tests/incident_cost_type/test_incident_cost_type_service.py index ca38f57788dc..a7c3bc9a70c0 100644 --- a/tests/incident_cost_type/test_incident_cost_type_service.py +++ b/tests/incident_cost_type/test_incident_cost_type_service.py @@ -8,7 +8,7 @@ def test_get(session, incident_cost_type): def test_get_all(session, incident_cost_types): from dispatch.incident_cost_type.service import get_all - t_incident_cost_types = get_all(db_session=session).all() + t_incident_cost_types = get_all(db_session=session) assert t_incident_cost_types diff --git a/tests/participant_activity/test_participant_activity_service.py b/tests/participant_activity/test_participant_activity_service.py new file mode 100644 index 000000000000..186c969df044 --- /dev/null +++ b/tests/participant_activity/test_participant_activity_service.py @@ -0,0 +1,212 @@ +def test_create_participant_activity(session, plugin_event, participant, incident): + from dispatch.participant_activity.service import ( + create, + get_all_incident_participant_activities_for_incident, + ) + from dispatch.participant_activity.models import ParticipantActivityCreate + + activity_in = ParticipantActivityCreate( + plugin_event=plugin_event, + participant=participant, + incident=incident, + ) + + activity_out = create(db_session=session, activity_in=activity_in) + assert activity_out + + activities = get_all_incident_participant_activities_for_incident( + db_session=session, incident_id=incident.id + ) + assert activities + assert activity_out in activities + + +def test_get_participant_incident_activities_by_individual_contact( + session, participant_activity, participant +): + """Tests that we can get all incident participant activities for an individual across all incidents.""" + from dispatch.participant_activity.service import ( + get_participant_incident_activities_by_individual_contact, + ) + + participant_activity.participant = participant + activities = get_participant_incident_activities_by_individual_contact( + db_session=session, + individual_contact_id=participant_activity.participant.individual_contact_id, + ) + + assert activities + assert participant_activity.id in [activity.id for activity in activities] + + +def test_create_or_update_participant_activity__same_plugin_no_overlap( + session, participant_activity +): + """Tests that a new participant activity is created when there is no time overlap with previously recorded activities.""" + from datetime import timedelta + from dispatch.participant_activity.models import ParticipantActivityCreate + from dispatch.participant_activity.service import ( + create_or_update, + get_all_incident_participant_activities_for_incident, + ) + + orig_activities = get_all_incident_participant_activities_for_incident( + db_session=session, incident_id=participant_activity.incident.id + ) + + started_at = participant_activity.ended_at + timedelta(seconds=1) + ended_at = participant_activity.ended_at + timedelta(seconds=10) + + activity_in = ParticipantActivityCreate( + plugin_event=participant_activity.plugin_event, + started_at=started_at, + ended_at=ended_at, + participant=participant_activity.participant, + incident=participant_activity.incident, + ) + + assert ended_at - started_at == create_or_update(db_session=session, activity_in=activity_in) + activities = get_all_incident_participant_activities_for_incident( + db_session=session, incident_id=participant_activity.incident.id + ) + assert activities + assert len(activities) == len(orig_activities) + 1 + assert participant_activity.id in [activity.id for activity in activities] + + +def test_create_or_update_participant_activity__new_plugin_no_overlap( + session, participant_activity, plugin_event +): + """Tests that a new participant activity is created when there is no time overlap with previously recorded activities.""" + from datetime import timedelta + from dispatch.participant_activity.models import ParticipantActivityCreate + from dispatch.participant_activity.service import ( + create_or_update, + get_all_incident_participant_activities_for_incident, + ) + + assert participant_activity.plugin_event.id != plugin_event.id + orig_activities = get_all_incident_participant_activities_for_incident( + db_session=session, incident_id=participant_activity.incident.id + ) + + started_at = participant_activity.ended_at + timedelta(seconds=1) + ended_at = started_at + timedelta(seconds=10) + + activity_in = ParticipantActivityCreate( + plugin_event=plugin_event, + started_at=started_at, + ended_at=ended_at, + participant=participant_activity.participant, + incident=participant_activity.incident, + ) + assert ended_at - started_at == create_or_update(db_session=session, activity_in=activity_in) + + activities = get_all_incident_participant_activities_for_incident( + db_session=session, incident_id=participant_activity.incident.id + ) + assert activities + assert len(activities) == len(orig_activities) + 1 + assert participant_activity.id in [activity.id for activity in activities] + + +def test_create_or_update_participant_activity__same_plugin_with_overlap( + session, participant_activity +): + """Tests only updating an existing participant activity. + + Tests that the previously recorded participant activity is updated when there is continuous activity with the same plugin event. + """ + from datetime import timedelta + from dispatch.participant_activity.models import ParticipantActivityCreate + from dispatch.participant_activity.service import ( + create_or_update, + get_all_incident_participant_activities_for_incident, + ) + + orig_activities = get_all_incident_participant_activities_for_incident( + db_session=session, incident_id=participant_activity.incident.id + ) + + # Start new incident activity in the middle of the existing recorded incident activity. + started_at = ( + participant_activity.started_at + + (participant_activity.ended_at - participant_activity.started_at) / 2 + ) + ended_at = participant_activity.ended_at + timedelta(seconds=10) + + activity_in = ParticipantActivityCreate( + plugin_event=participant_activity.plugin_event, + started_at=started_at, + ended_at=ended_at, + participant=participant_activity.participant, + incident=participant_activity.incident, + ) + + assert timedelta(seconds=10) == create_or_update(db_session=session, activity_in=activity_in) + activities = get_all_incident_participant_activities_for_incident( + db_session=session, incident_id=participant_activity.incident.id + ) + assert activities + assert len(activities) == len(orig_activities) + assert participant_activity.id in [activity.id for activity in activities] + + +def test_create_or_update_participant_activity__new_plugin_with_overlap( + session, participant_activity, plugin_event +): + """Tests updating an existing participant activity and creating a new participant activity. + + Tests that the previously recorded participant activity is updated and a new incident participant + activity is created when there is continuous participant activity coming from a different plugin event. + """ + from datetime import timedelta + from dispatch.participant_activity.models import ParticipantActivityCreate + from dispatch.participant_activity.service import ( + create_or_update, + get_all_incident_participant_activities_for_incident, + ) + + assert participant_activity.plugin_event.id != plugin_event.id + orig_activities = get_all_incident_participant_activities_for_incident( + db_session=session, incident_id=participant_activity.incident.id + ) + + # Start new incident activity in the middle of the existing recorded incident activity. + started_at = ( + participant_activity.started_at + + (participant_activity.ended_at - participant_activity.started_at) / 2 + ) + ended_at = participant_activity.ended_at + timedelta(seconds=10) + + activity_in = ParticipantActivityCreate( + plugin_event=plugin_event, + started_at=started_at, + ended_at=ended_at, + participant=participant_activity.participant, + incident=participant_activity.incident, + ) + + assert timedelta(seconds=10) == create_or_update(db_session=session, activity_in=activity_in) + activities = get_all_incident_participant_activities_for_incident( + db_session=session, incident_id=participant_activity.incident.id + ) + assert activities + assert len(activities) == len(orig_activities) + 1 + assert participant_activity.id in [activity.id for activity in activities] + + +def test_get_incidents_by_plugin(session, participant_activity): + """Tests retrieval of all of an incident's recorded participant activity from a specific plugin.""" + from dispatch.participant_activity.service import ( + get_all_recorded_incident_partcipant_activities_for_plugin, + ) + + activities = get_all_recorded_incident_partcipant_activities_for_plugin( + db_session=session, + incident_id=participant_activity.incident.id, + plugin_id=participant_activity.plugin_event.plugin.id, + ) + + assert activities + assert participant_activity.id in [activity.id for activity in activities] diff --git a/tests/plugin/test_plugin_service.py b/tests/plugin/test_plugin_service.py index 97ea3b343e09..099618fa1ce0 100644 --- a/tests/plugin/test_plugin_service.py +++ b/tests/plugin/test_plugin_service.py @@ -55,3 +55,58 @@ def test_delete_instance(session, plugin_instance): delete_instance(db_session=session, plugin_instance_id=plugin_instance.id) assert not get_instance(db_session=session, plugin_instance_id=plugin_instance.id) + + +def test_get_plugin_event_by_id(*, session, plugin_event): + """Returns a project based on the given project name.""" + from dispatch.plugin.service import get_plugin_event_by_id + + plugin_event_out = get_plugin_event_by_id(db_session=session, plugin_event_id=plugin_event.id) + assert plugin_event_out + assert plugin_event_out.id == plugin_event.id + + +def test_get_plugin_event_by_slug(*, session, plugin_event): + """Returns a project based on the given project name.""" + from dispatch.plugin.service import get_plugin_event_by_slug + + plugin_event_out = get_plugin_event_by_slug(db_session=session, slug=plugin_event.slug) + assert plugin_event_out + assert plugin_event_out.id == plugin_event.id + + +def test_register_plugin_event(session, plugin): + from dispatch.plugin.service import create_plugin_event, get_plugin_event_by_id + from dispatch.plugin.models import PluginEventCreate + + plugin_event = create_plugin_event( + db_session=session, + plugin_event_in=PluginEventCreate(name="foo", slug="bar", plugin=plugin), + ) + assert plugin_event + + plugin_event_out = get_plugin_event_by_id(db_session=session, plugin_event_id=plugin_event.id) + assert plugin_event_out + assert plugin_event_out.id == plugin_event.id + + +def test_get_all_events_for_plugin(*, session, plugin_event): + from dispatch.plugin.service import get_all_events_for_plugin + + plugin_events_out = get_all_events_for_plugin( + db_session=session, plugin_id=plugin_event.plugin.id + ) + + # Assert membership of plugin_event. + is_member = False + for plugin_event_out in plugin_events_out: + if ( + plugin_event_out.id == plugin_event.id + and plugin_event_out.name == plugin_event.name + and plugin_event_out.plugin.id == plugin_event.plugin.id + and plugin_event_out.description == plugin_event.description + and plugin_event_out.slug == plugin_event.slug + ): + is_member = True + break + assert is_member