diff --git a/.github/workflows/enforce-labels.yml b/.github/workflows/enforce-labels.yml index ddbdbea1c1ea..959e2ad987da 100644 --- a/.github/workflows/enforce-labels.yml +++ b/.github/workflows/enforce-labels.yml @@ -7,7 +7,7 @@ jobs: enforce-label: runs-on: ubuntu-latest steps: - - uses: yogevbd/enforce-label-action@2.1.0 + - uses: yogevbd/enforce-label-action@2.2.2 with: REQUIRED_LABELS_ANY: "bug,dependencies,documentation,enhancement,feature,skip-changelog,techdebt,tests" REQUIRED_LABELS_ANY_DESCRIPTION: "Select at least one label from the following list: bug, dependencies, documentation, enhancement, feature, skip-changelog, techdebt, tests" diff --git a/requirements-base.txt b/requirements-base.txt index 8a8dc465bb65..2aa616c95a12 100644 --- a/requirements-base.txt +++ b/requirements-base.txt @@ -442,7 +442,7 @@ starlette==0.32.0.post1 # starlette-testclient starlette-testclient==0.2.0 # via schemathesis -statsmodels==0.14.3 +statsmodels==0.14.4 # via -r requirements-base.in tabulate==0.9.0 # via -r requirements-base.in diff --git a/requirements-dev.txt b/requirements-dev.txt index 6094a1647efb..1c09b38458ae 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -32,7 +32,7 @@ executing==2.0.1 # stack-data factory-boy==3.3.1 # via -r requirements-dev.in -faker==30.0.0 +faker==30.3.0 # via # -r requirements-dev.in # factory-boy @@ -42,7 +42,7 @@ identify==2.5.33 # via pre-commit iniconfig==2.0.0 # via pytest -ipython==8.27.0 +ipython==8.28.0 # via -r requirements-dev.in jedi==0.19.1 # via ipython @@ -86,7 +86,7 @@ python-dateutil==2.9.0.post0 # via faker pyyaml==6.0.1 # via pre-commit -ruff==0.6.8 +ruff==0.6.9 # via -r requirements-dev.in six==1.16.0 # via @@ -99,10 +99,12 @@ traitlets==5.14.1 # ipython # matplotlib-inline typing-extensions==4.10.0 - # via ipython + # via + # faker + # ipython virtualenv==20.25.0 # via pre-commit -vulture==2.12 +vulture==2.13 # via -r requirements-dev.in wcwidth==0.2.12 # via prompt-toolkit diff --git a/src/dispatch/cli.py b/src/dispatch/cli.py index 9fe023421546..753020726107 100644 --- a/src/dispatch/cli.py +++ b/src/dispatch/cli.py @@ -5,6 +5,7 @@ import uvicorn from dispatch import __version__, config +from dispatch.config import DISPATCH_UI_URL from dispatch.enums import UserRoles from dispatch.plugin.models import PluginInstance @@ -636,6 +637,7 @@ def revision_database( def dispatch_scheduler(): """Container for all dispatch scheduler commands.""" # we need scheduled tasks to be imported + from .case.scheduled import case_close_reminder, case_triage_reminder # noqa from .case_cost.scheduled import ( calculate_cases_response_cost, # noqa ) @@ -656,7 +658,6 @@ def dispatch_scheduler(): ) from .term.scheduled import sync_terms # noqa from .workflow.scheduled import sync_workflows # noqa - from .case.scheduled import case_triage_reminder, case_close_reminder # noqa @dispatch_scheduler.command("list") @@ -806,10 +807,10 @@ def consume_signals(): None """ from dispatch.common.utils.cli import install_plugins - from dispatch.project import service as project_service - from dispatch.plugin import service as plugin_service + from dispatch.database.core import get_organization_session, get_session from dispatch.organization.service import get_all as get_all_organizations - from dispatch.database.core import get_session, get_organization_session + from dispatch.plugin import service as plugin_service + from dispatch.project import service as project_service install_plugins() @@ -883,6 +884,125 @@ def process_signals(): db_session.close() +@signals_group.command("perf-test") +@click.option("--num-instances", default=1, help="Number of signal instances to send.") +@click.option("--num-workers", default=1, help="Number of threads to use.") +@click.option( + "--api-endpoint", + default=f"{DISPATCH_UI_URL}/api/v1/default/signals/instances", + required=True, + help="API endpoint to send the signal instances to.", +) +@click.option( + "--api-token", + required=True, + help="API token to use.", +) +@click.option( + "--project", + default="Test", + required=True, + help="The Dispatch project to send the instances to.", +) +def perf_test( + num_instances: int, num_workers: int, api_endpoint: str, api_token: str, project: str +) -> None: + """Performance testing utility for creating signal instances.""" + + import concurrent.futures + import time + import uuid + + import requests + from fastapi import status + + NUM_SIGNAL_INSTANCES = num_instances + NUM_WORKERS = num_workers + + session = requests.Session() + session.headers.update( + { + "Content-Type": "application/json", + "Authorization": f"Bearer {api_token}", + } + ) + start_time = time.time() + + def _send_signal_instance( + api_endpoint: str, + api_token: str, + session: requests.Session, + signal_instance: dict[str, str], + ) -> None: + try: + r = session.post( + api_endpoint, + json=signal_instance, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {api_token}", + }, + ) + log.info(f"Response: {r.json()}") + if r.status_code == status.HTTP_401_UNAUTHORIZED: + raise PermissionError( + "Unauthorized. Please check your bearer token. You can find it in the Dev Tools under Request Headers -> Authorization." + ) + + r.raise_for_status() + + except requests.exceptions.RequestException as e: + log.error(f"Unable to send finding. Reason: {e} Response: {r.json() if r else 'N/A'}") + else: + log.info(f"{signal_instance.get('raw', {}).get('id')} created successfully") + + def send_signal_instances( + api_endpoint: str, api_token: str, signal_instances: list[dict[str, str]] + ): + with concurrent.futures.ThreadPoolExecutor(max_workers=NUM_WORKERS) as executor: + futures = [ + executor.submit( + _send_signal_instance, + api_endpoint=api_endpoint, + api_token=api_token, + session=session, + signal_instance=signal_instance, + ) + for signal_instance in signal_instances + ] + results = [future.result() for future in concurrent.futures.as_completed(futures)] + + log.info(f"\nSent {len(results)} of {NUM_SIGNAL_INSTANCES} signal instances") + + signal_instances = [ + { + "project": {"name": project}, + "raw": { + "id": str(uuid.uuid4()), + "name": "Test Signal", + "slug": "test-signal", + "canary": False, + "events": [ + { + "original": { + "dateint": 20240930, + "distinct_lookupkey_count": 95, + }, + }, + ], + "created_at": "2024-09-18T19:47:15Z", + "quiet_mode": False, + "external_id": "4ebbab36-c703-495f-ae47-7051bdc8b3ef", + }, + }, + ] * NUM_SIGNAL_INSTANCES + + send_signal_instances(api_endpoint, api_token, signal_instances) + + elapsed_time = time.time() - start_time + click.echo(f"Elapsed time: {elapsed_time:.2f} seconds") + + @dispatch_server.command("slack") @click.argument("organization") @click.argument("project") diff --git a/src/dispatch/data/alert/service.py b/src/dispatch/data/alert/service.py index 5e83267617ff..4a3f1cc5dd0d 100644 --- a/src/dispatch/data/alert/service.py +++ b/src/dispatch/data/alert/service.py @@ -1,9 +1,10 @@ from typing import Optional + from pydantic.error_wrappers import ErrorWrapper, ValidationError from dispatch.exceptions import NotFoundError -from .models import Alert, AlertCreate, AlertUpdate, AlertRead +from .models import Alert, AlertCreate, AlertRead, AlertUpdate def get(*, db_session, alert_id: int) -> Optional[Alert]: @@ -16,7 +17,7 @@ def get_by_name(*, db_session, name: str) -> Optional[Alert]: return db_session.query(Alert).filter(Alert.name == name).one_or_none() -def get_by_name_or_raise(*, db_session, alert_in=AlertRead) -> AlertRead: +def get_by_name_or_raise(*, db_session, alert_in: AlertRead) -> AlertRead: """Returns the alert specified or raises ValidationError.""" alert = get_by_name(db_session=db_session, name=alert_in.name) diff --git a/src/dispatch/database/revisions/tenant/versions/2024-09-27_f5107ce190fc.py b/src/dispatch/database/revisions/tenant/versions/2024-09-27_f5107ce190fc.py new file mode 100644 index 000000000000..158042b54159 --- /dev/null +++ b/src/dispatch/database/revisions/tenant/versions/2024-09-27_f5107ce190fc.py @@ -0,0 +1,34 @@ +"""Adds custom incident report card fields + +Revision ID: f5107ce190fc +Revises: 32652e0360dd +Create Date: 2024-09-27 12:33:17.418418 + +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "f5107ce190fc" +down_revision = "32652e0360dd" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("project", sa.Column("report_incident_instructions", sa.String(), nullable=True)) + op.add_column("project", sa.Column("report_incident_title_hint", sa.String(), nullable=True)) + op.add_column( + "project", sa.Column("report_incident_description_hint", sa.String(), nullable=True) + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("project", "report_incident_description_hint") + op.drop_column("project", "report_incident_title_hint") + op.drop_column("project", "report_incident_instructions") + # ### end Alembic commands ### diff --git a/src/dispatch/document/flows.py b/src/dispatch/document/flows.py index e1cb45d7ea61..7eb288a08b72 100644 --- a/src/dispatch/document/flows.py +++ b/src/dispatch/document/flows.py @@ -8,6 +8,7 @@ from dispatch.enums import DocumentResourceTypes from dispatch.event import service as event_service from dispatch.plugin import service as plugin_service +from dispatch.tag_type import service as tag_type_service from .models import Document, DocumentCreate from .service import create, delete @@ -170,17 +171,25 @@ def update_document(document: Document, project_id: int, db_session: Session): this would be the replaced text. Only create the source replacements if not null. For any tag types with multiple selected tags, replace with a comma-separated list. """ + # first ensure all tags types have a placeholder in the document template + tag_types = tag_type_service.get_all_by_project( + db_session=db_session, project_id=project_id + ) + for tag_type in tag_types: + document_kwargs[f"tag_{tag_type.name}"] = "N/A" + document_kwargs[f"tag_{tag_type.name}.source"] = "N/A" + # create document template placeholders for tags for tag in document.incident.tags: - if f"tag_{tag.tag_type.name}" in document_kwargs: - document_kwargs[f"tag_{tag.tag_type.name}"] += f", {tag.name}" - else: + if document_kwargs[f"tag_{tag.tag_type.name}"] == "N/A": document_kwargs[f"tag_{tag.tag_type.name}"] = tag.name + else: + document_kwargs[f"tag_{tag.tag_type.name}"] += f", {tag.name}" if tag.source: - if f"tag_{tag.tag_type.name}.source" in document_kwargs: - document_kwargs[f"tag_{tag.tag_type.name}.source"] += f", {tag.source}" - else: + if document_kwargs[f"tag_{tag.tag_type.name}.source"] == "N/A": document_kwargs[f"tag_{tag.tag_type.name}.source"] = tag.source + else: + document_kwargs[f"tag_{tag.tag_type.name}.source"] += f", {tag.source}" if document.resource_type == DocumentResourceTypes.review: document_kwargs["stable_at"] = document.incident.stable_at.strftime("%m/%d/%Y %H:%M:%S") diff --git a/src/dispatch/organization/service.py b/src/dispatch/organization/service.py index 6685699c8dc3..db93d2428717 100644 --- a/src/dispatch/organization/service.py +++ b/src/dispatch/organization/service.py @@ -44,7 +44,7 @@ def get_by_name(*, db_session, name: str) -> Optional[Organization]: return db_session.query(Organization).filter(Organization.name == name).one_or_none() -def get_by_name_or_raise(*, db_session, organization_in=OrganizationRead) -> Organization: +def get_by_name_or_raise(*, db_session, organization_in: OrganizationRead) -> Organization: """Returns the organization specified or raises ValidationError.""" organization = get_by_name(db_session=db_session, name=organization_in.name) @@ -67,7 +67,7 @@ def get_by_slug(*, db_session, slug: str) -> Optional[Organization]: return db_session.query(Organization).filter(Organization.slug == slug).one_or_none() -def get_by_slug_or_raise(*, db_session, organization_in=OrganizationRead) -> Organization: +def get_by_slug_or_raise(*, db_session, organization_in: OrganizationRead) -> Organization: """Returns the organization specified or raises ValidationError.""" organization = get_by_slug(db_session=db_session, slug=organization_in.slug) @@ -85,7 +85,7 @@ def get_by_slug_or_raise(*, db_session, organization_in=OrganizationRead) -> Org return organization -def get_by_name_or_default(*, db_session, organization_in=OrganizationRead) -> Organization: +def get_by_name_or_default(*, db_session, organization_in: OrganizationRead) -> Organization: """Returns a organization based on a name or the default if not specified.""" if organization_in.name: return get_by_name_or_raise(db_session=db_session, organization_in=organization_in) diff --git a/src/dispatch/plugins/dispatch_slack/case/interactive.py b/src/dispatch/plugins/dispatch_slack/case/interactive.py index 6aa2d160cb44..a69e4e25b868 100644 --- a/src/dispatch/plugins/dispatch_slack/case/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/case/interactive.py @@ -721,21 +721,6 @@ def handle_snooze_preview_event( ack(response_action="update", view=modal) -def ack_snooze_submission_event(ack: Ack, mfa_enabled: bool) -> None: - """Handles the add snooze submission event acknowledgement.""" - text = ( - "Adding snooze submission event..." - if mfa_enabled is False - else "Sending MFA push notification, please confirm to create Snooze filter..." - ) - modal = Modal( - title="Add Snooze", - close="Close", - blocks=[Section(text=text)], - ).build() - ack(response_action="update", view=modal) - - @app.view( SignalSnoozeActions.submit, middleware=[ @@ -774,8 +759,6 @@ def handle_snooze_submission_event( ) mfa_enabled = True if mfa_plugin else False - ack_snooze_submission_event(ack=ack, mfa_enabled=mfa_enabled) - def _create_snooze_filter( db_session: Session, subject: SubjectMetadata, @@ -878,7 +861,7 @@ def _create_snooze_filter( db_session=db_session, project_id=context["subject"].project_id, ) - ack_engagement_submission_event( + ack_mfa_required_submission_event( ack=ack, mfa_enabled=mfa_enabled, challenge_url=challenge_url ) @@ -2159,7 +2142,7 @@ def engagement_button_approve_click( client.views_open(trigger_id=body["trigger_id"], view=modal) -def ack_engagement_submission_event( +def ack_mfa_required_submission_event( ack: Ack, mfa_enabled: bool, challenge_url: str | None = None ) -> None: """Handles the add engagement submission event acknowledgement.""" @@ -2232,7 +2215,7 @@ def handle_engagement_submission_event( project_id=context["subject"].project_id, ) - ack_engagement_submission_event(ack=ack, mfa_enabled=mfa_enabled, challenge_url=challenge_url) + ack_mfa_required_submission_event(ack=ack, mfa_enabled=mfa_enabled, challenge_url=challenge_url) case = case_service.get(db_session=db_session, case_id=metadata["id"]) signal_instance = signal_service.get_signal_instance( @@ -2293,7 +2276,7 @@ def send_engagement_response( if response == MfaChallengeStatus.APPROVED: title = "Approve" text = "Confirmation... Success!" - message_text = f":white_check_mark: {engaged_user} confirmed the behavior as expected.\n\n *Context Provided* \n```{context_from_user}```" + message_text = f"{engaged_user} provided the following context:\n```{context_from_user}```" engagement_status = SignalEngagementStatus.approved else: title = "MFA Failed" diff --git a/src/dispatch/plugins/dispatch_slack/case/messages.py b/src/dispatch/plugins/dispatch_slack/case/messages.py index 5aa23d11a09f..03851c8b4c0d 100644 --- a/src/dispatch/plugins/dispatch_slack/case/messages.py +++ b/src/dispatch/plugins/dispatch_slack/case/messages.py @@ -14,17 +14,22 @@ from slack_sdk.web.client import WebClient from sqlalchemy.orm import Session -from dispatch.case.enums import CaseStatus +from dispatch.case import service as case_service +from dispatch.case.enums import CaseResolutionReason, CaseStatus from dispatch.case.models import Case from dispatch.config import DISPATCH_UI_URL from dispatch.messaging.strings import CASE_STATUS_DESCRIPTIONS, CASE_VISIBILITY_DESCRIPTIONS from dispatch.plugin import service as plugin_service +from dispatch.plugins.dispatch_slack import service as dispatch_slack_service from dispatch.plugins.dispatch_slack.case.enums import ( CaseNotificationActions, SignalEngagementActions, SignalNotificationActions, ) -from dispatch.plugins.dispatch_slack.config import MAX_SECTION_TEXT_LENGTH +from dispatch.plugins.dispatch_slack.config import ( + MAX_SECTION_TEXT_LENGTH, + SlackConversationConfiguration, +) from dispatch.plugins.dispatch_slack.models import ( CaseSubjects, EngagementMetadata, @@ -193,12 +198,15 @@ class EntityGroup(NamedTuple): related_case_count: int -def create_signal_messages(case_id: int, channel_id: str, db_session: Session) -> list[Message]: +def create_signal_message(case_id: int, channel_id: str, db_session: Session) -> list[Message]: """ - Creates signal messages for a given case. + Creates a signal message for a given case. + + This function generates a signal message for a specific case by fetching the first signal instance + associated with the case and creating metadata blocks for the message. Args: - case_id (int): The ID of the case for which to create signal messages. + case_id (int): The ID of the case for which to create the signal message. channel_id (str): The ID of the Slack channel where the message will be sent. db_session (Session): The database session to use for querying signal instances. @@ -209,6 +217,40 @@ def create_signal_messages(case_id: int, channel_id: str, db_session: Session) - instances = signal_service.get_instances_in_case(db_session=db_session, case_id=case_id) (first_instance_id, first_instance_signal) = instances.first() + case = case_service.get(db_session=db_session, case_id=case_id) + + # we create the signal metadata blocks + signal_metadata_blocks = [ + Section(text="*Alerts*"), + Section( + text=f"We observed <{DISPATCH_UI_URL}/{first_instance_signal.project.organization.slug}/cases/{case.name}/signal/{first_instance_id}|{instances.count()} alert(s)> in this case. The first alert for this case can be seen below." + ), + ] + + return Message(blocks=signal_metadata_blocks).build()["blocks"] + + +def create_action_buttons_message( + case: Case, channel_id: str, db_session: Session +) -> list[Message]: + """ + Creates a message with action buttons for a given case. + + This function generates a message containing action buttons for a specific case by fetching the first signal instance + associated with the case and creating metadata blocks for the message. + + Args: + case_id (int): The ID of the case for which to create the action buttons message. + channel_id (str): The ID of the Slack channel where the message will be sent. + db_session (Session): The database session to use for querying signal instances. + + Returns: + list[Message]: A list of Message objects representing the structure of the Slack messages. + """ + # we fetch the first instance to get the organization slug and project id + instances = signal_service.get_instances_in_case(db_session=db_session, case_id=case.id) + (first_instance_id, first_instance_signal) = instances.first() + organization_slug = first_instance_signal.project.organization.slug project_id = first_instance_signal.project.id button_metadata = SubjectMetadata( @@ -225,7 +267,7 @@ def create_signal_messages(case_id: int, channel_id: str, db_session: Session) - if first_instance_signal.external_url: elements.append( Button( - text="🔖 Response Plan", + text="🔖 View Response Plan", action_id="button-link", url=first_instance_signal.external_url, ) @@ -233,7 +275,7 @@ def create_signal_messages(case_id: int, channel_id: str, db_session: Session) - elements.append( Button( - text="💤 Snooze Alerts", + text="💤 Snooze Alert", action_id=SignalNotificationActions.snooze, value=button_metadata, ) @@ -241,26 +283,36 @@ def create_signal_messages(case_id: int, channel_id: str, db_session: Session) - # we create the signal metadata blocks signal_metadata_blocks = [ + Divider(), Section(text="*Actions*"), Actions(elements=elements), Divider(), - Section(text="*Alerts*"), - Section(text=f"We observed {instances.count()} alerts in this case."), ] return Message(blocks=signal_metadata_blocks).build()["blocks"] +def create_genai_signal_message_metadata_blocks( + signal_metadata_blocks: list[Block], message: str +) -> list[Block]: + signal_metadata_blocks.append( + Section(text=f":magic_wand: *GenAI Alert Analysis*\n\n{message}"), + ) + signal_metadata_blocks.append(Divider()) + return Message(blocks=signal_metadata_blocks).build()["blocks"] + + def create_genai_signal_analysis_message( case: Case, channel_id: str, db_session: Session, client: WebClient, + config: SlackConversationConfiguration, ) -> list[Block]: """ Creates a signal analysis using a generative AI plugin. - This function generates a analysis for a given case by leveraging historical context and + This function generates an analysis for a given case by leveraging historical context and a generative AI plugin. It fetches related cases, their resolutions, and relevant Slack messages to provide a comprehensive analysis. @@ -269,78 +321,99 @@ def create_genai_signal_analysis_message( channel_id (str): The ID of the Slack channel where the analysis will be sent. db_session (Session): The database session to use for querying signal instances and related cases. client (WebClient): The Slack WebClient to fetch threaded messages. + config (SlackConversationConfiguration): The Slack conversation configuration. Returns: list[Block]: A list of Block objects representing the structure of the Slack message. """ signal_metadata_blocks: list[Block] = [] + # we fetch the first instance id and signal (first_instance_id, first_instance_signal) = signal_service.get_instances_in_case( db_session=db_session, case_id=case.id ).first() - if not first_instance_id or not first_instance_signal: - log.warning("Unable to generate GenAI signal analysis. No signal instances found.") - return signal_metadata_blocks + signal_instance = signal_service.get_signal_instance( + db_session=db_session, signal_instance_id=first_instance_id + ) - # Fetch related cases - related_cases = ( - signal_service.get_cases_for_signal( - db_session=db_session, signal_id=first_instance_signal.id + # we check if GenAI is enabled for the detection + if not signal_instance.signal.genai_enabled: + message = "Unable to generate GenAI signal analysis. GenAI feature not enabled for this detection." + log.warning(message) + return create_genai_signal_message_metadata_blocks(signal_metadata_blocks, message) + + # we fetch related cases + related_cases = [] + for resolution_reason in CaseResolutionReason: + related_cases.extend( + signal_service.get_cases_for_signal_by_resolution_reason( + db_session=db_session, + signal_id=first_instance_signal.id, + resolution_reason=resolution_reason, + ) + .from_self() # NOTE: function deprecated in SQLAlchemy 1.4 and removed in 2.0 + .filter(Case.id != case.id) ) - .from_self() # NOTE: function deprecated in SQLAlchemy 1.4 and removed in 2.0 - .filter(Case.id != case.id) - ) - # Prepare historical context + # we prepare historical context historical_context = [] for related_case in related_cases: historical_context.append("") historical_context.append(f"{related_case.name}") - historical_context.append(f"{related_case.resolution}{related_case.resolution}{related_case.resolution_reason}" + f"{related_case.resolution_reason}" + ) + historical_context.append( + f"{related_case.signal_instances[0].raw}" ) - # Fetch Slack messages for the related case + # we fetch Slack messages for the related case if related_case.conversation and related_case.conversation.channel_id: try: - # Fetch threaded messages + # we fetch threaded messages thread_messages = client.conversations_replies( channel=related_case.conversation.channel_id, ts=related_case.conversation.thread_id, ) - - # Add relevant messages to the context (e.g., first 5 messages) - for message in thread_messages["messages"][:5]: - historical_context.append(f"{message['text']}") + for message in thread_messages["messages"]: + if dispatch_slack_service.is_user(config=config, user_id=message.get("user")): + # we only include messages from users + historical_context.append( + f"{message['text']}" + ) except SlackApiError as e: - log.error(f"Error fetching Slack messages for case {related_case.name}: {e}") + log.error( + f"Unable to generate GenAI signal analysis. Error fetching Slack messages for case {related_case.name}: {e}" + ) + message = "Unable to generate GenAI signal analysis. Error fetching Slack messages." + return create_genai_signal_message_metadata_blocks(signal_metadata_blocks, message) historical_context.append("") historical_context_str = "\n".join(historical_context) - signal_instance = signal_service.get_signal_instance( - db_session=db_session, signal_instance_id=first_instance_id - ) - + # we fetch the GenAI plugin genai_plugin = plugin_service.get_active_instance( db_session=db_session, project_id=case.project.id, plugin_type="artificial-intelligence" ) + # we check if the GenAI plugin is enabled if not genai_plugin: - log.warning( + message = ( "Unable to generate GenAI signal analysis. No artificial-intelligence plugin enabled." ) - return signal_metadata_blocks + log.warning(message) + return create_genai_signal_message_metadata_blocks(signal_metadata_blocks, message) + # we check if the GenAI plugin has a prompt if not signal_instance.signal.genai_prompt: - log.warning( - f"Unable to generate GenAI signal analysis. No GenAI prompt defined for {signal_instance.signal.name}" - ) - return signal_metadata_blocks + message = f"Unable to generate GenAI signal analysis. No GenAI prompt defined for {signal_instance.signal.name}" + log.warning(message) + return create_genai_signal_message_metadata_blocks(signal_metadata_blocks, message) + # we generate the analysis response = genai_plugin.instance.chat_completion( prompt=f""" @@ -352,23 +425,24 @@ def create_genai_signal_analysis_message( {str(signal_instance.raw)} + + {signal_instance.signal.runbook} + + {historical_context_str} - - {first_instance_signal.runbook} - """ ) message = response["choices"][0]["message"]["content"] - signal_metadata_blocks.append(Divider()) - signal_metadata_blocks.append( - Section(text=f":magic_wand: *GenAI Alert Analysis*\n\n{message}"), - ) - signal_metadata_blocks.append(Divider()) - return Message(blocks=signal_metadata_blocks).build()["blocks"] + # we check if the response is empty + if not message: + message = "Unable to generate GenAI signal analysis. We received an empty response from the artificial-intelligence plugin." + log.warning(message) + + return create_genai_signal_message_metadata_blocks(signal_metadata_blocks, message) def create_signal_engagement_message( @@ -406,9 +480,6 @@ def create_signal_engagement_message( username, _ = user_email.split("@") blocks = [ - Section( - text=f"@{username}, we could use your help to resolve this case. Please, see additional context below:" - ), Section( text=f"{engagement.message if engagement.message else 'No context provided for this alert.'}" ), @@ -449,7 +520,7 @@ def create_signal_engagement_message( blocks.extend( [ Section( - text=":warning: @{username} denied the behavior as expected. Please investigate the case and escalate to incident if necessary." + text=f":warning: @{username} denied the behavior as expected. Please investigate the case and escalate to incident if necessary." ), ] ) diff --git a/src/dispatch/plugins/dispatch_slack/plugin.py b/src/dispatch/plugins/dispatch_slack/plugin.py index 624d677b0a7b..8682958380dc 100644 --- a/src/dispatch/plugins/dispatch_slack/plugin.py +++ b/src/dispatch/plugins/dispatch_slack/plugin.py @@ -18,7 +18,6 @@ from dispatch.auth.models import DispatchUser from dispatch.case.models import Case -from dispatch.config import DISPATCH_UI_URL from dispatch.conversation.enums import ConversationCommands from dispatch.decorators import apply, counter, timer from dispatch.plugin import service as plugin_service @@ -32,10 +31,11 @@ from dispatch.signal.models import SignalEngagement, SignalInstance from .case.messages import ( + create_action_buttons_message, create_case_message, create_genai_signal_analysis_message, create_signal_engagement_message, - create_signal_messages, + create_signal_message, ) from .endpoints import router as slack_event_router from .enums import SlackAPIErrorCode @@ -93,51 +93,83 @@ def create_threaded(self, case: Case, conversation_id: str, db_session: Session) client = create_slack_client(self.configuration) blocks = create_case_message(case=case, channel_id=conversation_id) response = send_message(client=client, conversation_id=conversation_id, blocks=blocks) + response_timestamp = response["timestamp"] if case.signal_instances: - message = create_signal_messages( - case_id=case.id, channel_id=conversation_id, db_session=db_session - ) - signal_response = send_message( - client=client, - conversation_id=conversation_id, - ts=response["timestamp"], - blocks=message, + signal_response = None + + # we try to generate a GenAI signal analysis message + try: + if message := create_genai_signal_analysis_message( + case=case, + channel_id=conversation_id, + db_session=db_session, + client=client, + config=self.configuration, + ): + signal_response = send_message( + client=client, + conversation_id=conversation_id, + ts=response_timestamp, + blocks=message, + ) + except Exception as e: + logger.exception(f"Error generating GenAI signal analysis message: {e}") + + case.signal_thread_ts = ( + signal_response.get("timestamp") if signal_response else response_timestamp ) - case.signal_thread_ts = signal_response.get("timestamp") + # we try to generate a signal message + try: + message = create_signal_message( + case_id=case.id, channel_id=conversation_id, db_session=db_session + ) + signal_response = send_message( + client=client, + conversation_id=conversation_id, + ts=case.signal_thread_ts, + blocks=message, + ) + if signal_response: + case.signal_thread_ts = signal_response.get("timestamp") + except Exception as e: + logger.exception(f"Error generating signal message: {e}") + + # we try to upload the alert JSON to the case thread try: client.files_upload( channels=conversation_id, thread_ts=case.signal_thread_ts, - initial_comment=f"First alert for this case. View all alerts in <{DISPATCH_UI_URL}/{case.project.organization.slug}/cases/{case.name}|Dispatch>:", filetype="json", file=io.BytesIO(json.dumps(case.signal_instances[0].raw, indent=4).encode()), ) except SlackApiError as e: if e.response["error"] == SlackAPIErrorCode.MISSING_SCOPE: - logger.exception( - f"Error uploading alert JSON to the case thread due to a missing scope: {e}" + exception_message = ( + "Error uploading alert JSON to the case thread due to a missing scope" ) else: - logger.exception(f"Error uploading alert JSON to the case thread: {e}") + exception_message = "Error uploading alert JSON to the case thread" + logger.exception(f"{exception_message}: {e}") + except Exception as e: logger.exception(f"Error uploading alert JSON to the case thread: {e}") + # we try to generate action buttons try: + message = create_action_buttons_message( + case=case, channel_id=conversation_id, db_session=db_session + ) send_message( client=client, conversation_id=conversation_id, ts=case.signal_thread_ts, - blocks=create_genai_signal_analysis_message( - case=case, - channel_id=conversation_id, - db_session=db_session, - client=client, - ), + blocks=message, ) except Exception as e: - logger.exception(f"Error generating GenAI signal summary: {e}") + logger.exception(f"Error generating action buttons message: {e}") + db_session.commit() return response @@ -194,7 +226,7 @@ def update_signal_message( ): """Updates the signal message.""" client = create_slack_client(self.configuration) - blocks = create_signal_messages( + blocks = create_signal_message( case_id=case_id, channel_id=conversation_id, db_session=db_session ) return update_message( diff --git a/src/dispatch/plugins/dispatch_slack/service.py b/src/dispatch/plugins/dispatch_slack/service.py index 4c323e7b220e..433023af5d84 100644 --- a/src/dispatch/plugins/dispatch_slack/service.py +++ b/src/dispatch/plugins/dispatch_slack/service.py @@ -1,27 +1,25 @@ -from datetime import datetime import functools import heapq import logging -from requests import Timeout +from datetime import datetime +from typing import Dict, List, Optional from blockkit import Message, Section +from requests import Timeout from slack_sdk.errors import SlackApiError from slack_sdk.web.client import WebClient from slack_sdk.web.slack_response import SlackResponse from tenacity import ( + RetryCallState, retry, retry_if_exception, - RetryCallState, - wait_exponential, stop_after_attempt, + wait_exponential, ) -from typing import Dict, List, Optional - from .config import SlackConversationConfiguration from .enums import SlackAPIErrorCode, SlackAPIGetEndpoints, SlackAPIPostEndpoints - Conversation = dict[str, str] log = logging.getLogger(__name__) diff --git a/src/dispatch/project/models.py b/src/dispatch/project/models.py index f550e8b3957e..b6620c8a7fe0 100644 --- a/src/dispatch/project/models.py +++ b/src/dispatch/project/models.py @@ -65,6 +65,11 @@ class Project(Base): # when true, folder and incident docs will be created with the title of the incident storage_use_title = Column(Boolean, default=False, server_default=false()) + # allows customized instructions for reporting incidents + report_incident_instructions = Column(String, nullable=True) + report_incident_title_hint = Column(String, nullable=True) + report_incident_description_hint = Column(String, nullable=True) + @hybrid_property def slug(self): return slugify(self.name) @@ -94,6 +99,9 @@ class ProjectBase(DispatchBase): storage_use_title: Optional[bool] = Field(False, nullable=True) allow_self_join: Optional[bool] = Field(True, nullable=True) select_commander_visibility: Optional[bool] = Field(True, nullable=True) + report_incident_instructions: Optional[str] = Field(None, nullable=True) + report_incident_title_hint: Optional[str] = Field(None, nullable=True) + report_incident_description_hint: Optional[str] = Field(None, nullable=True) class ProjectCreate(ProjectBase): diff --git a/src/dispatch/project/service.py b/src/dispatch/project/service.py index 15aa24c04f17..1c620432cac0 100644 --- a/src/dispatch/project/service.py +++ b/src/dispatch/project/service.py @@ -2,12 +2,12 @@ from pydantic import ValidationError from pydantic.error_wrappers import ErrorWrapper -from dispatch.exceptions import NotFoundError - from sqlalchemy.orm import Session from sqlalchemy.sql.expression import true -from .models import Project, ProjectCreate, ProjectUpdate, ProjectRead +from dispatch.exceptions import NotFoundError + +from .models import Project, ProjectCreate, ProjectRead, ProjectUpdate def get(*, db_session: Session, project_id: int) -> Project | None: @@ -42,7 +42,7 @@ def get_by_name(*, db_session: Session, name: str) -> Optional[Project]: return db_session.query(Project).filter(Project.name == name).one_or_none() -def get_by_name_or_raise(*, db_session: Session, project_in=ProjectRead) -> Project: +def get_by_name_or_raise(*, db_session: Session, project_in: ProjectRead) -> Project: """Returns the project specified or raises ValidationError.""" project = get_by_name(db_session=db_session, name=project_in.name) @@ -60,7 +60,7 @@ def get_by_name_or_raise(*, db_session: Session, project_in=ProjectRead) -> Proj return project -def get_by_name_or_default(*, db_session, project_in=ProjectRead) -> Project: +def get_by_name_or_default(*, db_session, project_in: ProjectRead) -> Project: """Returns a project based on a name or the default if not specified.""" if project_in: if project_in.name: diff --git a/src/dispatch/service/service.py b/src/dispatch/service/service.py index 16bc8f8e0152..27424ccb9c62 100644 --- a/src/dispatch/service/service.py +++ b/src/dispatch/service/service.py @@ -8,7 +8,7 @@ from dispatch.project.models import ProjectRead from dispatch.search_filter import service as search_filter_service -from .models import Service, ServiceCreate, ServiceUpdate, ServiceRead +from .models import Service, ServiceCreate, ServiceRead, ServiceUpdate def get(*, db_session, service_id: int) -> Optional[Service]: @@ -36,7 +36,7 @@ def get_by_name(*, db_session, project_id: int, name: str) -> Optional[Service]: ) -def get_by_name_or_raise(*, db_session, project_id, service_in=ServiceRead) -> ServiceRead: +def get_by_name_or_raise(*, db_session, project_id, service_in: ServiceRead) -> ServiceRead: """Returns the service specified or raises ValidationError.""" source = get_by_name(db_session=db_session, project_id=project_id, name=service_in.name) diff --git a/src/dispatch/signal/flows.py b/src/dispatch/signal/flows.py index fdda25f909ca..4010cb1f104c 100644 --- a/src/dispatch/signal/flows.py +++ b/src/dispatch/signal/flows.py @@ -1,7 +1,7 @@ import logging +import time from datetime import timedelta from queue import Queue -import time from cachetools import TTLCache from email_validator import EmailNotValidError, validate_email @@ -15,6 +15,7 @@ from dispatch.database.core import get_organization_session, get_session from dispatch.entity import service as entity_service from dispatch.entity_type import service as entity_type_service +from dispatch.entity_type.models import EntityScopeEnum from dispatch.exceptions import DispatchException from dispatch.organization.service import get_all as get_all_organizations from dispatch.plugin import service as plugin_service @@ -25,7 +26,6 @@ from dispatch.signal.enums import SignalEngagementStatus from dispatch.signal.models import SignalFilterAction, SignalInstance, SignalInstanceCreate from dispatch.workflow import flows as workflow_flows -from dispatch.entity_type.models import EntityScopeEnum log = logging.getLogger(__name__) diff --git a/src/dispatch/signal/models.py b/src/dispatch/signal/models.py index dc7fdce4932e..a0fc51e5ce8d 100644 --- a/src/dispatch/signal/models.py +++ b/src/dispatch/signal/models.py @@ -1,15 +1,15 @@ import uuid from datetime import datetime -from typing import List, Optional, Any +from typing import Any, List, Optional from pydantic import Field from sqlalchemy import ( + JSON, Boolean, Column, DateTime, ForeignKey, Integer, - JSON, PrimaryKeyConstraint, String, Table, @@ -24,23 +24,21 @@ from dispatch.case.priority.models import CasePriority, CasePriorityRead from dispatch.case.type.models import CaseType, CaseTypeRead from dispatch.data.source.models import SourceBase -from dispatch.entity_type.models import EntityType -from dispatch.project.models import ProjectRead - from dispatch.database.core import Base from dispatch.entity.models import EntityRead -from dispatch.entity_type.models import EntityTypeRead -from dispatch.tag.models import TagRead +from dispatch.entity_type.models import EntityType, EntityTypeRead from dispatch.enums import DispatchEnum from dispatch.models import ( DispatchBase, EvergreenMixin, NameStr, + Pagination, PrimaryKey, ProjectMixin, TimeStampMixin, - Pagination, ) +from dispatch.project.models import ProjectRead +from dispatch.tag.models import TagRead from dispatch.workflow.models import WorkflowRead @@ -290,6 +288,10 @@ class SignalEngagementRead(SignalEngagementBase): id: PrimaryKey +class SignalEngagementUpdate(SignalEngagementBase): + id: PrimaryKey + + class SignalEngagementPagination(Pagination): items: List[SignalEngagementRead] diff --git a/src/dispatch/signal/service.py b/src/dispatch/signal/service.py index 1123bbc07e23..751201594093 100644 --- a/src/dispatch/signal/service.py +++ b/src/dispatch/signal/service.py @@ -36,6 +36,7 @@ SignalEngagement, SignalEngagementCreate, SignalEngagementRead, + SignalEngagementUpdate, SignalFilter, SignalFilterAction, SignalFilterCreate, @@ -51,32 +52,6 @@ log = logging.getLogger(__name__) -def create_signal_engagement( - *, db_session: Session, creator: DispatchUser, signal_engagement_in: SignalEngagementCreate -) -> SignalEngagement: - """Creates a new signal filter.""" - project = project_service.get_by_name_or_raise( - db_session=db_session, project_in=signal_engagement_in.project - ) - - entity_type = entity_type_service.get( - db_session=db_session, entity_type_id=signal_engagement_in.entity_type.id - ) - - signal_engagement = SignalEngagement( - name=signal_engagement_in.name, - description=signal_engagement_in.description, - message=signal_engagement_in.message, - require_mfa=signal_engagement_in.require_mfa, - entity_type=entity_type, - creator=creator, - project=project, - ) - db_session.add(signal_engagement) - db_session.commit() - return signal_engagement - - def get_signal_engagement( *, db_session: Session, signal_engagement_id: int ) -> Optional[SignalEngagement]: @@ -88,18 +63,6 @@ def get_signal_engagement( ) -def get_all_by_entity_type(*, db_session: Session, entity_type_id: int) -> list[SignalInstance]: - """Fetches all signal instances associated with a given entity type.""" - return ( - db_session.query(SignalInstance) - .join(SignalInstance.signal) - .join(assoc_signal_entity_types) - .join(EntityType) - .filter(assoc_signal_entity_types.c.entity_type_id == entity_type_id) - .all() - ) - - def get_signal_engagement_by_name( *, db_session, project_id: int, name: str ) -> Optional[SignalEngagement]: @@ -113,8 +76,9 @@ def get_signal_engagement_by_name( def get_signal_engagement_by_name_or_raise( - *, db_session: Session, project_id: int, signal_engagement_in=SignalEngagementRead + *, db_session: Session, project_id: int, signal_engagement_in: SignalEngagementRead ) -> SignalEngagement: + """Gets a signal engagement by its name or raises an error if not found.""" signal_engagement = get_signal_engagement_by_name( db_session=db_session, project_id=project_id, name=signal_engagement_in.name ) @@ -124,7 +88,7 @@ def get_signal_engagement_by_name_or_raise( [ ErrorWrapper( NotFoundError( - msg="Signal Engagement not found.", + msg="Signal engagement not found.", signal_engagement=signal_engagement_in.name, ), loc="signalEngagement", @@ -135,6 +99,66 @@ def get_signal_engagement_by_name_or_raise( return signal_engagement +def create_signal_engagement( + *, db_session: Session, creator: DispatchUser, signal_engagement_in: SignalEngagementCreate +) -> SignalEngagement: + """Creates a new signal engagement.""" + project = project_service.get_by_name_or_raise( + db_session=db_session, project_in=signal_engagement_in.project + ) + + entity_type = entity_type_service.get( + db_session=db_session, entity_type_id=signal_engagement_in.entity_type.id + ) + + signal_engagement = SignalEngagement( + name=signal_engagement_in.name, + description=signal_engagement_in.description, + message=signal_engagement_in.message, + require_mfa=signal_engagement_in.require_mfa, + entity_type=entity_type, + creator=creator, + project=project, + ) + db_session.add(signal_engagement) + db_session.commit() + return signal_engagement + + +def update_signal_engagement( + *, + db_session: Session, + signal_engagement: SignalEngagement, + signal_engagement_in: SignalEngagementUpdate, +) -> SignalEngagement: + """Updates an existing signal engagement.""" + signal_engagement_data = signal_engagement.dict() + update_data = signal_engagement_in.dict( + skip_defaults=True, + exclude={}, + ) + + for field in signal_engagement_data: + if field in update_data: + setattr(signal_engagement, field, update_data[field]) + + db_session.add(signal_engagement) + db_session.commit() + return signal_engagement + + +def get_all_by_entity_type(*, db_session: Session, entity_type_id: int) -> list[SignalInstance]: + """Fetches all signal instances associated with a given entity type.""" + return ( + db_session.query(SignalInstance) + .join(SignalInstance.signal) + .join(assoc_signal_entity_types) + .join(EntityType) + .filter(assoc_signal_entity_types.c.entity_type_id == entity_type_id) + .all() + ) + + def create_signal_instance(*, db_session: Session, signal_instance_in: SignalInstanceCreate): """Creates a new signal instance.""" project = project_service.get_by_name_or_default( @@ -226,7 +250,7 @@ def delete_signal_filter(*, db_session: Session, signal_filter_id: int) -> int: def get_signal_filter_by_name_or_raise( - *, db_session: Session, project_id: int, signal_filter_in=SignalFilterRead + *, db_session: Session, project_id: int, signal_filter_in: SignalFilterRead ) -> SignalFilter: signal_filter = get_signal_filter_by_name( db_session=db_session, project_id=project_id, name=signal_filter_in.name @@ -346,6 +370,7 @@ def create(*, db_session: Session, signal_in: SignalCreate) -> Signal: exclude={ "case_priority", "case_type", + "engagements", "entity_types", "filters", "oncall_service", @@ -371,9 +396,9 @@ def create(*, db_session: Session, signal_in: SignalCreate) -> Signal: signal.entity_types = entity_types engagements = [] - for eng in signal_in.engagements: + for signal_engagement_in in signal_in.engagements: signal_engagement = get_signal_engagement_by_name( - db_session=db_session, project_id=project.id, signal_engagement_in=eng + db_session=db_session, project_id=project.id, name=signal_engagement_in.name ) engagements.append(signal_engagement) @@ -455,9 +480,11 @@ def update(*, db_session: Session, signal: Signal, signal_in: SignalUpdate) -> S if signal_in.engagements: engagements = [] - for eng in signal_in.engagements: + for signal_engagement_in in signal_in.engagements: signal_engagement = get_signal_engagement_by_name_or_raise( - db_session=db_session, project_id=signal.project.id, signal_engagement_in=eng + db_session=db_session, + project_id=signal.project.id, + signal_engagement_in=signal_engagement_in, ) engagements.append(signal_engagement) @@ -761,6 +788,16 @@ def get_unprocessed_signal_instance_ids(session: Session) -> list[int]: def get_instances_in_case(db_session: Session, case_id: int) -> Query: + """ + Retrieves signal instances associated with a given case. + + Args: + db_session (Session): The database session. + case_id (int): The ID of the case. + + Returns: + Query: A SQLAlchemy query object for the signal instances associated with the case. + """ return ( db_session.query(SignalInstance, Signal) .join(Signal) @@ -771,10 +808,46 @@ def get_instances_in_case(db_session: Session, case_id: int) -> Query: def get_cases_for_signal(db_session: Session, signal_id: int, limit: int = 10) -> Query: + """ + Retrieves cases associated with a given signal. + + Args: + db_session (Session): The database session. + signal_id (int): The ID of the signal. + limit (int, optional): The maximum number of cases to retrieve. Defaults to 10. + + Returns: + Query: A SQLAlchemy query object for the cases associated with the signal. + """ + return ( + db_session.query(Case) + .join(SignalInstance) + .filter(SignalInstance.signal_id == signal_id) + .order_by(desc(Case.created_at)) + .limit(limit) + ) + + +def get_cases_for_signal_by_resolution_reason( + db_session: Session, signal_id: int, resolution_reason: str, limit: int = 10 +) -> Query: + """ + Retrieves cases associated with a given signal and resolution reason. + + Args: + db_session (Session): The database session. + signal_id (int): The ID of the signal. + resolution_reason (str): The resolution reason to filter cases by. + limit (int, optional): The maximum number of cases to retrieve. Defaults to 10. + + Returns: + Query: A SQLAlchemy query object for the cases associated with the signal and resolution reason. + """ return ( db_session.query(Case) .join(SignalInstance) .filter(SignalInstance.signal_id == signal_id) + .filter(Case.resolution_reason == resolution_reason) .order_by(desc(Case.created_at)) .limit(limit) ) diff --git a/src/dispatch/signal/views.py b/src/dispatch/signal/views.py index 9ac621f04f29..6b38f20c5568 100644 --- a/src/dispatch/signal/views.py +++ b/src/dispatch/signal/views.py @@ -1,12 +1,11 @@ import logging from typing import Union -from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Response, status, Depends +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response, status from pydantic.error_wrappers import ErrorWrapper, ValidationError - from sqlalchemy.exc import IntegrityError -from dispatch.auth.permissions import SensitiveProjectActionPermission, PermissionsDependency +from dispatch.auth.permissions import PermissionsDependency, SensitiveProjectActionPermission from dispatch.auth.service import CurrentUser from dispatch.database.core import DbSession from dispatch.database.service import CommonParameters, search_filter_sort_paginate @@ -21,6 +20,7 @@ SignalEngagementCreate, SignalEngagementPagination, SignalEngagementRead, + SignalEngagementUpdate, SignalFilterCreate, SignalFilterPagination, SignalFilterRead, @@ -40,8 +40,10 @@ delete_signal_filter, get, get_by_primary_or_external_id, + get_signal_engagement, get_signal_filter, update, + update_signal_engagement, update_signal_filter, ) @@ -127,12 +129,14 @@ def get_signal_engagements(common: CommonParameters): @router.get("/engagements/{engagement_id}", response_model=SignalEngagementRead) -def get_signal_engagement( +def get_engagement( db_session: DbSession, signal_engagement_id: PrimaryKey, ): """Gets a signal engagement by its id.""" - engagement = get(db_session=db_session, signal_engagement_id=signal_engagement_id) + engagement = get_signal_engagement( + db_session=db_session, signal_engagement_id=signal_engagement_id + ) if not engagement: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -164,6 +168,46 @@ def create_engagement( ) from None +@router.put( + "/engagements/{signal_engagement_id}", + response_model=SignalEngagementRead, + dependencies=[Depends(PermissionsDependency([SensitiveProjectActionPermission]))], +) +def update_engagement( + db_session: DbSession, + signal_engagement_id: PrimaryKey, + signal_engagement_in: SignalEngagementUpdate, +): + """Updates an existing signal engagement.""" + signal_engagement = get_signal_engagement( + db_session=db_session, signal_engagement_id=signal_engagement_id + ) + if not signal_engagement: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=[{"msg": "A signal engagement with this id does not exist."}], + ) + + try: + signal_engagement = update_signal_engagement( + db_session=db_session, + signal_engagement=signal_engagement, + signal_engagement_in=signal_engagement_in, + ) + except IntegrityError: + raise ValidationError( + [ + ErrorWrapper( + ExistsError(msg="A signal engagement with this name already exists."), + loc="name", + ) + ], + model=SignalEngagementUpdate, + ) from None + + return signal_engagement + + @router.post("/filters", response_model=SignalFilterRead) def create_filter( db_session: DbSession, @@ -188,7 +232,7 @@ def create_filter( @router.put( "/filters/{signal_filter_id}", - response_model=SignalRead, + response_model=SignalFilterRead, dependencies=[Depends(PermissionsDependency([SensitiveProjectActionPermission]))], ) def update_filter( diff --git a/src/dispatch/static/dispatch/package-lock.json b/src/dispatch/static/dispatch/package-lock.json index cfb0fd9c2a22..937db1de2907 100644 --- a/src/dispatch/static/dispatch/package-lock.json +++ b/src/dispatch/static/dispatch/package-lock.json @@ -1396,9 +1396,9 @@ } }, "node_modules/@tiptap/core": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.7.4.tgz", - "integrity": "sha512-1VTQdNQChgxdVC8+b8QEW6cUxPSD9EDTzg9YRSLWtTtUDQ09sRSVs7eHIn1LcRHVs6PwcAsNgKE4FSjBw0sRlg==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.8.0.tgz", + "integrity": "sha512-xsqDI4BNzYRWRtBq7+/38ThhqEr7uG9Njip1x+9/wgR3vWPBFnBkYJTz6jSxS35NRE6BSnERm4/B/vrLuY1Hdw==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1408,9 +1408,9 @@ } }, "node_modules/@tiptap/extension-blockquote": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.7.4.tgz", - "integrity": "sha512-N6rhiwVRpsxRz4Qt8cvKgpqjBxdi8GTbU/v2MV/BTONWb7Ch9ajv9HO6koEDdOeb77JVhpWztzYysTjJo2KTyQ==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.8.0.tgz", + "integrity": "sha512-m3CKrOIvV7fY1Ak2gYf5LkKiz6AHxHpg6wxfVaJvdBqXgLyVtHo552N+A4oSHOSRbB4AG9EBQ2NeBM8cdEQ4MA==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1420,9 +1420,9 @@ } }, "node_modules/@tiptap/extension-bold": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.7.4.tgz", - "integrity": "sha512-Yq2ErekgpsOLCGYfQc1H3tUdmecKHDBWTPesVtqg0ct/3ZbKskhFoR6bPQWZH/ZRXQb1ARA+aMp/iqM/hqm+KQ==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.8.0.tgz", + "integrity": "sha512-U1YkZBxDkSLNvPNiqxB5g42IeJHr27C7zDb/yGQN2xL4UBeg4O9xVhCFfe32f6tLwivSL0dar4ScElpaCJuqow==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1432,9 +1432,9 @@ } }, "node_modules/@tiptap/extension-bubble-menu": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.7.4.tgz", - "integrity": "sha512-Vx9gFFgz/6R+FIzOCbUNFOJAy4lKr/vbG2l1Ujn4PKber8OWV1JUHXF5MKhMKUupr+Yvu5h3ctBcOe1tZt/NIA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.8.0.tgz", + "integrity": "sha512-swg+myJPN60LduQvLMF4hVBqP5LOIN01INZBzBI8egz8QufqtSyRCgXl7Xcma0RT5xIXnZSG9XOqNFf2rtkjKA==", "dependencies": { "tippy.js": "^6.3.7" }, @@ -1448,21 +1448,23 @@ } }, "node_modules/@tiptap/extension-bullet-list": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.7.4.tgz", - "integrity": "sha512-uO08vui6uEgLEgLIYJSLrUb2An3u0If8XRW0Z0kB13zpwQ9pq0S1JOc0KwPTDPeIrgLQ7OOH87/bM9rGUFC3AQ==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.8.0.tgz", + "integrity": "sha512-H4O2X0ozbc/ce9/XF1H98sqWVUdtt7jzy7hMBunwmY8ZxI4dHtcRkeg81CZbpKTqOqRrMCLWjE3M2tgiDXrDkA==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0" + "@tiptap/core": "^2.7.0", + "@tiptap/extension-list-item": "^2.7.0", + "@tiptap/extension-text-style": "^2.7.0" } }, "node_modules/@tiptap/extension-code": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.7.4.tgz", - "integrity": "sha512-GB7gR8tV1fz+70wcSN+hLVm1qET/YmkxIaOfczHEOLLH7Td0C3kyQ5Q+eQ8KN0Ds7NBHFXn3zn051Q8gk9+5tw==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.8.0.tgz", + "integrity": "sha512-VSFn3sFF6qPpOGkXFhik8oYRH5iByVJpFEFd/duIEftmS0MdPzkbSItOpN3mc9xsJ5dCX80LYaResSj5hr5zkA==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1472,9 +1474,9 @@ } }, "node_modules/@tiptap/extension-code-block": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.7.4.tgz", - "integrity": "sha512-jRKVAEdy3G0SMphWXCTk9SnMuTmJE6blXglU66H89j9R+hG+G0dHfOWhlubhUy6nI2BLy8jJ/isnOzg97iZuQw==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.8.0.tgz", + "integrity": "sha512-POuA5Igx+Dto0DTazoBFAQTj/M/FCdkqRVD9Uhsxhv49swPyANTJRr05vgbgtHB+NDDsZfCawVh7pI0IAD/O0w==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1485,9 +1487,9 @@ } }, "node_modules/@tiptap/extension-document": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.7.4.tgz", - "integrity": "sha512-Vsq9e/uW7k/5l1K9bCmuccBSrHhK3i0fbfnTp33G1byTCizheUo3UWFl8MSDammlhRkW/soIZFGdflsj5AJWog==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.8.0.tgz", + "integrity": "sha512-mp7Isx1sVc/ifeW4uW/PexGQ9exN3NRUOebSpnLfqXeWYk4y1RS1PA/3+IHkOPVetbnapgPjFx/DswlCP3XLjA==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1497,9 +1499,9 @@ } }, "node_modules/@tiptap/extension-dropcursor": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.7.4.tgz", - "integrity": "sha512-hhE0RTluEEFxfqh8/jpmQRgy5AipTcd+WMK5cBw2zCa9If/qhY0EvysydEPwDU7yDEa13NDqV63x5oN9GKv2pg==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.8.0.tgz", + "integrity": "sha512-rAFvx44YuT6dtS1c+ALw0ROAGI16l5L1HxquL4hR1gtxDcTieST5xhw5bkshXlmrlfotZXPrhokzqA7qjhZtJw==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1510,9 +1512,9 @@ } }, "node_modules/@tiptap/extension-floating-menu": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.7.4.tgz", - "integrity": "sha512-EGDq9eVT/fIJo6AOvXtRIWurAbGx0Fv2hIjQobX8AmCoOp3KjYalbQPbo1d3cyqanXG7sNRhBehIXc8Nv+AUWA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.8.0.tgz", + "integrity": "sha512-H4QT61CrkLqisnGGC7zgiYmsl2jXPHl89yQCbdlkQN7aw11H7PltcJS2PJguL0OrRVJS/Mv/VTTUiMslmsEV5g==", "dependencies": { "tippy.js": "^6.3.7" }, @@ -1526,9 +1528,9 @@ } }, "node_modules/@tiptap/extension-gapcursor": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.7.4.tgz", - "integrity": "sha512-1HTaCR6kcw5PvUJWEGKQ/Eh2HPXUmN6k1LK0rgJC4CxqiFxNNnPKGED9LcYheJbyMYk0Fz3rtaulxd3ipdIOsQ==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.8.0.tgz", + "integrity": "sha512-Be1LWCmvteQInOnNVN+HTqc1XWsj1bCl+Q7et8qqNjtGtTaCbdCp8ppcH1SKJxNTM/RLUtPyJ8FDgOTj51ixCA==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1539,9 +1541,9 @@ } }, "node_modules/@tiptap/extension-hard-break": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.7.4.tgz", - "integrity": "sha512-ut81vNPQyDYi8LhOzPfFZGnPToYGQbBR6bvFE0e8WY9sRfvUZHr/GvkMjPuWuA8M5sBMqS5cLNyqPrI8h4R7Jg==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.8.0.tgz", + "integrity": "sha512-vqiIfviNiCmy/pJTHuDSCAGL2O4QDEdDmAvGJu8oRmElUrnlg8DbJUfKvn6DWQHNSQwRb+LDrwWlzAYj1K9u6A==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1551,9 +1553,9 @@ } }, "node_modules/@tiptap/extension-heading": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.7.4.tgz", - "integrity": "sha512-ZLFHhFvmDD6YKPf4wftZd4wtT510yHjzG90A14wyKCpm0Bq9wOYzx4Q+owvlp5vMwenqHuq3KGz4Sf3w6N5gkw==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.8.0.tgz", + "integrity": "sha512-4inWgrTPiqlivPmEHFOM5ck2UsmOsbKKPtqga6bALvWPmCv24S6/EBwFp8Jz4YABabXDnkviihmGu0LpP9D69w==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1563,9 +1565,9 @@ } }, "node_modules/@tiptap/extension-history": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.7.4.tgz", - "integrity": "sha512-xRgGXNrtjDGVOeLeZzGqw4/OtwIoloLU3QLn/qaOggVS7jr1HVTqMHw4nZVcUJfnB/UQ90yl53hBKZ8z3AxcCA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.8.0.tgz", + "integrity": "sha512-u5YS0J5Egsxt8TUWMMAC3QhPZaak+IzQeyHch4gtqxftx96tprItY7AD/A3pGDF2uCSnN+SZrk6yVexm6EncDw==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1576,9 +1578,9 @@ } }, "node_modules/@tiptap/extension-horizontal-rule": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.7.4.tgz", - "integrity": "sha512-6mKkiGK9O+eGDeewpUHGyM2Xjlp69Oy+N/0o5zdzfN84YqVPqLV+Y7ub6fMxZUvmRt6L0kuv/ZoDoxeUk+QNKg==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.8.0.tgz", + "integrity": "sha512-Sn/MI8WVFBoIYSIHA9NJryJIyCEzZdRysau8pC5TFnfifre0QV1ksPz2bgF+DyCD69ozQiRdBBHDEwKe47ZbfQ==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1589,9 +1591,9 @@ } }, "node_modules/@tiptap/extension-italic": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.7.4.tgz", - "integrity": "sha512-j/86hNMRd2PbJX6DOs7CbrYgFJSXvZMnWkYRRol7XEELvEuIWoAgyJrW5HkDbVxmGfWPnLlqsoW7iTHml7P+Bg==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.8.0.tgz", + "integrity": "sha512-PwwSE2LTYiHI47NJnsfhBmPiLE8IXZYqaSoNPU6flPrk1KxEzqvRI1joKZBmD9wuqzmHJ93VFIeZcC+kfwi8ZA==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1601,9 +1603,9 @@ } }, "node_modules/@tiptap/extension-list-item": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.7.4.tgz", - "integrity": "sha512-2EiXAtkZdCUHCfYRQsslniQhUzvo8zEm+M6JHcsIRBRf27iE+nXrD6jq1WH2ZIUNLDUs4JsJhtc89aoSYkJGKw==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.8.0.tgz", + "integrity": "sha512-o7OGymGxB0B9x3x2prp3KBDYFuBYGc5sW69O672jk8G52DqhzzndgPnkk0qUn8nXAUKuDGbJmpmHVA2kagqnRg==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1613,21 +1615,23 @@ } }, "node_modules/@tiptap/extension-ordered-list": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.7.4.tgz", - "integrity": "sha512-Y7fnw3lTyOd1h6t5hKSkYqbJXteafIviRdmrQ/ERRayojV934DjRPBeMQnYcArE6nI178/wLI9YMt1HSMJklRw==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.8.0.tgz", + "integrity": "sha512-sCvNbcTS1+5QTTXwUPFa10vf5I1pr8sGcOTIh0G+a5ZkS5+6FxT12k7VLzPt39QyNbOi+77U2o4Xr4XyaEkfSg==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0" + "@tiptap/core": "^2.7.0", + "@tiptap/extension-list-item": "^2.7.0", + "@tiptap/extension-text-style": "^2.7.0" } }, "node_modules/@tiptap/extension-paragraph": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.7.4.tgz", - "integrity": "sha512-Pv3zsyuE+RItlkZVFcjcnz+Omp/UCEO03n9daeHljMUl7Rt775fXtcTNKPqO65f2B2MPBxrSdJpTsoMK0bbcjA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.8.0.tgz", + "integrity": "sha512-XgxxNNbuBF48rAGwv7/s6as92/xjm/lTZIGTq9aG13ClUKFtgdel7C33SpUCcxg3cO2WkEyllXVyKUiauFZw/A==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1637,9 +1641,9 @@ } }, "node_modules/@tiptap/extension-placeholder": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.7.1.tgz", - "integrity": "sha512-CU7vbbLhu6SKaHHpLH9ILI1k9xXssiQsbk71nMCrJdhWQ8ZNKPu4bfss36bOU6zy0aEx8DFaoZVFWkWVJJBGqA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.8.0.tgz", + "integrity": "sha512-BMqv/C9Tcjd7L1/OphUAJTZhWfpWs0rTQJ0bs3RRGsC8L+K20Fg+li45vw7M0teojpfrw57zwJogJd/m23Zr1Q==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1650,9 +1654,9 @@ } }, "node_modules/@tiptap/extension-strike": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.7.4.tgz", - "integrity": "sha512-ELMFUCE9MlF0qsGzHJl0AxzGUVyS9rglk6pzidoB0iU1LuzUa/K1el5ID2ksSFdq2+STK17rOWQxUiv3X8C7gw==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.8.0.tgz", + "integrity": "sha512-ezkDiXxQ3ME/dDMMM7tAMkKRi6UWw7tIu+Mx7Os0z8HCGpVBk1gFhLlhEd8I5rJaPZr4tK1wtSehMA9bscFGQw==", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1662,9 +1666,22 @@ } }, "node_modules/@tiptap/extension-text": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.7.4.tgz", - "integrity": "sha512-1bF9LdfUumqXOz0A6xnOo7UHx+YLshxjMnjoMXjv7cOFOjdHbLmwKNTKGd2ltoCy3bSajoCPhPZL2Id89XDZfQ==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.8.0.tgz", + "integrity": "sha512-EDAdFFzWOvQfVy7j3qkKhBpOeE5thkJaBemSWfXI93/gMVc0ZCdLi24mDvNNgUHlT+RjlIoQq908jZaaxLKN2A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text-style": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.8.0.tgz", + "integrity": "sha512-jJp0vcZ2Ty7RvIL0VU6dm1y+fTfXq1lN2GwtYzYM0ueFuESa+Qo8ticYOImyWZ3wGJGVrjn7OV9r0ReW0/NYkQ==", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1674,9 +1691,9 @@ } }, "node_modules/@tiptap/pm": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.7.4.tgz", - "integrity": "sha512-YXjgPLN6/msTkKakuzgBm6Dd/Li3ORtysSki3fHnOFcy8R4c5JZLkYECQk6aJHsxvl/vGvNgaJy5yCDbhnaTAg==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.8.0.tgz", + "integrity": "sha512-eMGpRooUMvKz/vOpnKKppApMSoNM325HxTdAJvTlVAmuHp5bOY5kyY1kfUlePRiVx1t1UlFcXs3kecFwkkBD3Q==", "dependencies": { "prosemirror-changeset": "^2.2.1", "prosemirror-collab": "^1.3.1", @@ -1703,30 +1720,30 @@ } }, "node_modules/@tiptap/starter-kit": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.7.4.tgz", - "integrity": "sha512-ALOphzdSZ+ZgOllc0gKxn7iDQ3c3BEBJzc5dQE1pJMeDHrGu/fAGXtffJOyJsVoBGTB14TXK6decMNUUwBApiA==", - "dependencies": { - "@tiptap/core": "^2.7.4", - "@tiptap/extension-blockquote": "^2.7.4", - "@tiptap/extension-bold": "^2.7.4", - "@tiptap/extension-bullet-list": "^2.7.4", - "@tiptap/extension-code": "^2.7.4", - "@tiptap/extension-code-block": "^2.7.4", - "@tiptap/extension-document": "^2.7.4", - "@tiptap/extension-dropcursor": "^2.7.4", - "@tiptap/extension-gapcursor": "^2.7.4", - "@tiptap/extension-hard-break": "^2.7.4", - "@tiptap/extension-heading": "^2.7.4", - "@tiptap/extension-history": "^2.7.4", - "@tiptap/extension-horizontal-rule": "^2.7.4", - "@tiptap/extension-italic": "^2.7.4", - "@tiptap/extension-list-item": "^2.7.4", - "@tiptap/extension-ordered-list": "^2.7.4", - "@tiptap/extension-paragraph": "^2.7.4", - "@tiptap/extension-strike": "^2.7.4", - "@tiptap/extension-text": "^2.7.4", - "@tiptap/pm": "^2.7.4" + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.8.0.tgz", + "integrity": "sha512-r7UwaTrECkQoheWVZKFDqtL5tBx07x7IFT+prfgnsVlYFutGWskVVqzCDvD3BDmrg5PzeCWYZrQGlPaLib7tjg==", + "dependencies": { + "@tiptap/core": "^2.8.0", + "@tiptap/extension-blockquote": "^2.8.0", + "@tiptap/extension-bold": "^2.8.0", + "@tiptap/extension-bullet-list": "^2.8.0", + "@tiptap/extension-code": "^2.8.0", + "@tiptap/extension-code-block": "^2.8.0", + "@tiptap/extension-document": "^2.8.0", + "@tiptap/extension-dropcursor": "^2.8.0", + "@tiptap/extension-gapcursor": "^2.8.0", + "@tiptap/extension-hard-break": "^2.8.0", + "@tiptap/extension-heading": "^2.8.0", + "@tiptap/extension-history": "^2.8.0", + "@tiptap/extension-horizontal-rule": "^2.8.0", + "@tiptap/extension-italic": "^2.8.0", + "@tiptap/extension-list-item": "^2.8.0", + "@tiptap/extension-ordered-list": "^2.8.0", + "@tiptap/extension-paragraph": "^2.8.0", + "@tiptap/extension-strike": "^2.8.0", + "@tiptap/extension-text": "^2.8.0", + "@tiptap/pm": "^2.8.0" }, "funding": { "type": "github", @@ -1734,12 +1751,12 @@ } }, "node_modules/@tiptap/vue-3": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/@tiptap/vue-3/-/vue-3-2.7.4.tgz", - "integrity": "sha512-Z75XzP7J3yFAB9qNvso8cX/f3R2PjKxGGk53RR2dhUT+Bsa7K5U8k6QB+ANl2RY+1t/p/LkQvaYLe6CBx9t1Ug==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@tiptap/vue-3/-/vue-3-2.8.0.tgz", + "integrity": "sha512-bJLoNQAkKXcWfRuX1YGu7SQFF+xY40YkpDagBXKXtmAfwIAwjO52grj4dpZoEtM12ZyxsC2RAHYYxBVPPYRSnw==", "dependencies": { - "@tiptap/extension-bubble-menu": "^2.7.4", - "@tiptap/extension-floating-menu": "^2.7.4" + "@tiptap/extension-bubble-menu": "^2.8.0", + "@tiptap/extension-floating-menu": "^2.8.0" }, "funding": { "type": "github", @@ -2620,9 +2637,9 @@ } }, "node_modules/apexcharts": { - "version": "3.53.0", - "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.53.0.tgz", - "integrity": "sha512-QESZHZY3w9LPQ64PGh1gEdfjYjJ5Jp+Dfy0D/CLjsLOPTpXzdxwlNMqRj+vPbTcP0nAHgjWv1maDqcEq6u5olw==", + "version": "3.54.0", + "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.54.0.tgz", + "integrity": "sha512-ZgI/seScffjLpwNRX/gAhIkAhpCNWiTNsdICv7qxnF0xisI23XSsaENUKIcMlyP1rbe8ECgvybDnp7plZld89A==", "dependencies": { "@yr/monotone-cubic-spline": "^1.0.3", "svg.draggable.js": "^2.2.2", diff --git a/src/dispatch/static/dispatch/src/incident/ReportSubmissionCard.vue b/src/dispatch/static/dispatch/src/incident/ReportSubmissionCard.vue index 04f04d0c6a8e..ce4f35b0175c 100644 --- a/src/dispatch/static/dispatch/src/incident/ReportSubmissionCard.vue +++ b/src/dispatch/static/dispatch/src/incident/ReportSubmissionCard.vue @@ -18,7 +18,10 @@ -

+

+ {{ project?.report_incident_instructions }} +

+

If you suspect an incident and need help, please fill out this form to the best of your abilities.

@@ -35,7 +38,11 @@ + Report incident card settings + + + + + + + + +
@@ -207,6 +238,9 @@ export default { "selected.storage_use_title", "selected.allow_self_join", "selected.select_commander_visibility", + "selected.report_incident_instructions", + "selected.report_incident_title_hint", + "selected.report_incident_description_hint", "dialogs.showCreateEdit", ]), }, diff --git a/src/dispatch/tag/service.py b/src/dispatch/tag/service.py index 867d33c516d4..e0c6439cc0ea 100644 --- a/src/dispatch/tag/service.py +++ b/src/dispatch/tag/service.py @@ -1,11 +1,12 @@ from typing import Optional + from pydantic.error_wrappers import ErrorWrapper, ValidationError from dispatch.exceptions import NotFoundError from dispatch.project import service as project_service from dispatch.tag_type import service as tag_type_service -from .models import Tag, TagCreate, TagUpdate, TagRead +from .models import Tag, TagCreate, TagRead, TagUpdate def get(*, db_session, tag_id: int) -> Optional[Tag]: @@ -23,7 +24,7 @@ def get_by_name(*, db_session, project_id: int, name: str) -> Optional[Tag]: ) -def get_by_name_or_raise(*, db_session, project_id: int, tag_in=TagRead) -> TagRead: +def get_by_name_or_raise(*, db_session, project_id: int, tag_in: TagRead) -> TagRead: """Returns the tag specified or raises ValidationError.""" tag = get_by_name(db_session=db_session, project_id=project_id, name=tag_in.name) diff --git a/src/dispatch/tag_type/service.py b/src/dispatch/tag_type/service.py index 05b62a3ab03e..7aeff15e8f63 100644 --- a/src/dispatch/tag_type/service.py +++ b/src/dispatch/tag_type/service.py @@ -1,8 +1,8 @@ from typing import Optional from pydantic.error_wrappers import ErrorWrapper, ValidationError -from dispatch.exceptions import NotFoundError +from dispatch.exceptions import NotFoundError from dispatch.project import service as project_service from .models import TagType, TagTypeCreate, TagTypeRead, TagTypeUpdate @@ -33,7 +33,7 @@ def get_storage_tag_type_for_project(*, db_session, project_id) -> TagType | Non ) -def get_by_name_or_raise(*, db_session, project_id: int, tag_type_in=TagTypeRead) -> TagType: +def get_by_name_or_raise(*, db_session, project_id: int, tag_type_in: TagTypeRead) -> TagType: """Returns the tag_type specified or raises ValidationError.""" tag_type = get_by_name(db_session=db_session, project_id=project_id, name=tag_type_in.name) @@ -56,6 +56,11 @@ def get_all(*, db_session): return db_session.query(TagType) +def get_all_by_project(*, db_session, project_id: int): + """Gets all tag types by project.""" + return db_session.query(TagType).filter(TagType.project_id == project_id) + + def create(*, db_session, tag_type_in: TagTypeCreate) -> TagType: """Creates a new tag type.""" project = project_service.get_by_name_or_raise( diff --git a/src/dispatch/workflow/service.py b/src/dispatch/workflow/service.py index 8a4d47b3a1eb..8161fe20ed85 100644 --- a/src/dispatch/workflow/service.py +++ b/src/dispatch/workflow/service.py @@ -1,10 +1,9 @@ from typing import List, Optional +from pydantic.error_wrappers import ErrorWrapper, ValidationError from sqlalchemy.orm import Session from sqlalchemy.sql.expression import true -from pydantic.error_wrappers import ErrorWrapper, ValidationError - from dispatch.case import service as case_service from dispatch.config import DISPATCH_UI_URL from dispatch.document import service as document_service @@ -18,12 +17,12 @@ from .models import ( Workflow, - WorkflowInstance, WorkflowCreate, - WorkflowRead, - WorkflowUpdate, + WorkflowInstance, WorkflowInstanceCreate, WorkflowInstanceUpdate, + WorkflowRead, + WorkflowUpdate, ) @@ -37,7 +36,7 @@ def get_by_name(*, db_session, name: str) -> Optional[Workflow]: return db_session.query(Workflow).filter(Workflow.name == name).one_or_none() -def get_by_name_or_raise(*, db_session: Session, workflow_in=WorkflowRead) -> Workflow: +def get_by_name_or_raise(*, db_session: Session, workflow_in: WorkflowRead) -> Workflow: workflow = get_by_name(db_session=db_session, name=workflow_in.name) if not workflow: