diff --git a/.coveragerc b/.coveragerc index b1c1c93c3598..0181948d2bf9 100644 --- a/.coveragerc +++ b/.coveragerc @@ -19,4 +19,3 @@ omit = src/dispatch/api.py src/dispatch/extensions.py src/dispatch/scheduler.py - diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 37d7474cb362..69a57adfa98e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,15 +7,31 @@ # To Skip Checks: # # git commit --no-verify +# git push --no-verify +# test +# +# To update all hooks automatically: +# +# pre-commit autoupdate fail_fast: false default_language_version: python: python3.11.2 repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: no-commit-to-branch # prevent direct commits to the `main` branch + - id: check-yaml + args: ["--unsafe"] + - id: check-toml + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/astral-sh/ruff-pre-commit # ruff version. - rev: v0.7.0 + rev: v0.11.12 hooks: # Run the linter. # @@ -26,19 +42,27 @@ repos: # Run the formatter. - id: ruff-format - # Typos + # typos - repo: https://github.com/crate-ci/typos - rev: v1.26.1 + rev: v1.33.1 hooks: - id: typos - exclude: ^(data/dispatch-sample-data.dump|src/dispatch/static/dispatch/src/|src/dispatch/database/revisions/) + exclude: | + (?x)^( + src/dispatch/database/revisions/core/versions/| + src/dispatch/database/revisions/tenant/versions/| + src/dispatch/static/dispatch/src/assets/icons/| + data/dispatch-sample-data.dump| + src/dispatch/plugins/dispatch_zoom/client.py + ) - # Pytest + # pytest - repo: local hooks: - id: tests name: run tests - entry: pytest -v tests/ + entry: pytest -v tests language: system - types: [python] - stages: [push] + pass_filenames: false + always_run: true + stages: [pre-push] diff --git a/docker/Dockerfile b/docker/Dockerfile index fcab6e531578..580ef3c5d644 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -106,7 +106,7 @@ RUN buildDeps="" \ && rm -rf /var/lib/apt/lists/* \ # mjml has to be installed differently here because # after node 14, docker will install npm files at the - # root directoy and fail, so we have to create a new + # root directory and fail, so we have to create a new # directory and use it for the install then copy the # files to the root directory to maintain backwards # compatibility for email generation diff --git a/src/dispatch/auth/service.py b/src/dispatch/auth/service.py index a965222b6d83..fe5b0bd597bf 100644 --- a/src/dispatch/auth/service.py +++ b/src/dispatch/auth/service.py @@ -152,7 +152,8 @@ def create(*, db_session, organization: str, user_in: (UserRegister | UserCreate # create the user user = DispatchUser( - **user_in.model_dump(exclude={"password", "organizations", "projects", "role"}), password=password + **user_in.model_dump(exclude={"password", "organizations", "projects", "role"}), + password=password, ) org = organization_service.get_by_slug_or_raise( diff --git a/src/dispatch/case/flows.py b/src/dispatch/case/flows.py index 38381bd26e4c..304912a2fa39 100644 --- a/src/dispatch/case/flows.py +++ b/src/dispatch/case/flows.py @@ -42,7 +42,7 @@ send_case_rating_feedback_message, send_case_update_notifications, send_event_paging_message, - send_event_update_prompt_reminder + send_event_update_prompt_reminder, ) from .models import Case from .service import get diff --git a/src/dispatch/case/messaging.py b/src/dispatch/case/messaging.py index 8242614ad531..12dd1136d5dc 100644 --- a/src/dispatch/case/messaging.py +++ b/src/dispatch/case/messaging.py @@ -421,7 +421,7 @@ def send_event_update_prompt_reminder(case: Case, db_session: Session) -> None: "text": {"type": "plain_text", "text": "Update Case"}, "action_id": CaseNotificationActions.update, "style": "primary", - "value": button_metadata + "value": button_metadata, } ], }, @@ -430,6 +430,7 @@ def send_event_update_prompt_reminder(case: Case, db_session: Session) -> None: log.debug(f"Security Event update reminder sent to {case.assignee.individual.email}.") + def send_event_paging_message(case: Case, db_session: Session, oncall_name: str) -> None: """ Sends a message to the case conversation channel to notify the reporter that they can engage diff --git a/src/dispatch/case/models.py b/src/dispatch/case/models.py index 0254f17a0b46..4315ab3088e2 100644 --- a/src/dispatch/case/models.py +++ b/src/dispatch/case/models.py @@ -336,6 +336,7 @@ class CaseReadMinimal(CaseBase): assignee: ParticipantReadMinimal | None = None case_costs: list[CaseCostReadMinimal] = [] + class CaseReadMinimalWithExtras(CaseBase): """Pydantic model for reading minimal case data.""" @@ -451,11 +452,13 @@ class CasePagination(Pagination): items: list[CaseReadMinimal] = [] + class CasePaginationMinimalWithExtras(Pagination): """Pydantic model for paginated minimal case results.""" items: list[CaseReadMinimalWithExtras] = [] + class CaseExpandedPagination(Pagination): """Pydantic model for paginated expanded case results.""" diff --git a/src/dispatch/case/service.py b/src/dispatch/case/service.py index e2ce5ca40ed9..da4a623c2c93 100644 --- a/src/dispatch/case/service.py +++ b/src/dispatch/case/service.py @@ -375,8 +375,7 @@ def update(*, db_session, case: Case, case_in: CaseUpdate, current_user: Dispatc db_session=db_session, source="Dispatch Core App", description=( - f"Case visibility changed to {case_in.visibility.lower()} " - f"by {current_user.email}" + f"Case visibility changed to {case_in.visibility.lower()} by {current_user.email}" ), dispatch_user_id=current_user.id, case_id=case.id, diff --git a/src/dispatch/case/type/models.py b/src/dispatch/case/type/models.py index 9e6db2b12229..9c0746f4dde4 100644 --- a/src/dispatch/case/type/models.py +++ b/src/dispatch/case/type/models.py @@ -1,4 +1,5 @@ """Models for case types and related entities in the Dispatch application.""" + from pydantic import field_validator, AnyHttpUrl from sqlalchemy import JSON, Boolean, Column, ForeignKey, Integer, String @@ -19,6 +20,7 @@ class CaseType(ProjectMixin, Base): """SQLAlchemy model for case types, representing different types of cases in the system.""" + __table_args__ = (UniqueConstraint("name", "project_id"),) id = Column(Integer, primary_key=True) name = Column(String) @@ -66,6 +68,7 @@ def get_meta(self, slug): # Pydantic models class Document(DispatchBase): """Pydantic model for a document related to a case type.""" + id: PrimaryKey description: str | None = None name: NameStr @@ -76,6 +79,7 @@ class Document(DispatchBase): class IncidentType(DispatchBase): """Pydantic model for an incident type related to a case type.""" + id: PrimaryKey description: str | None = None name: NameStr @@ -84,6 +88,7 @@ class IncidentType(DispatchBase): class Service(DispatchBase): """Pydantic model for a service related to a case type.""" + id: PrimaryKey description: str | None = None external_id: str @@ -94,6 +99,7 @@ class Service(DispatchBase): class CaseTypeBase(DispatchBase): """Base Pydantic model for case types, used for shared fields.""" + case_template_document: Document | None = None conversation_target: str | None = None default: bool | None = False @@ -118,19 +124,23 @@ def replace_none_with_empty_list(cls, value): class CaseTypeCreate(CaseTypeBase): """Pydantic model for creating a new case type.""" + pass class CaseTypeUpdate(CaseTypeBase): """Pydantic model for updating an existing case type.""" + id: PrimaryKey | None = None class CaseTypeRead(CaseTypeBase): """Pydantic model for reading a case type from the database.""" + id: PrimaryKey class CaseTypePagination(Pagination): """Pydantic model for paginated case type results.""" + items: list[CaseTypeRead] = [] diff --git a/src/dispatch/case/views.py b/src/dispatch/case/views.py index 7b57456de8cb..e23b89131833 100644 --- a/src/dispatch/case/views.py +++ b/src/dispatch/case/views.py @@ -39,7 +39,15 @@ case_update_flow, get_case_participants_flow, ) -from .models import Case, CaseCreate, CaseExpandedPagination, CasePagination, CasePaginationMinimalWithExtras, CaseRead, CaseUpdate +from .models import ( + Case, + CaseCreate, + CaseExpandedPagination, + CasePagination, + CasePaginationMinimalWithExtras, + CaseRead, + CaseUpdate, +) from .service import create, delete, get, get_participants, update log = logging.getLogger(__name__) @@ -144,6 +152,7 @@ def get_cases_minimal( return json.loads(CasePaginationMinimalWithExtras(**pagination).json()) + @router.post("", response_model=CaseRead, summary="Creates a new case.") def create_case( db_session: DbSession, diff --git a/src/dispatch/case_cost_type/service.py b/src/dispatch/case_cost_type/service.py index 96fd2f278925..5de43d56f5cb 100644 --- a/src/dispatch/case_cost_type/service.py +++ b/src/dispatch/case_cost_type/service.py @@ -16,9 +16,7 @@ def get(*, db_session, case_cost_type_id: int) -> CaseCostType | None: return db_session.query(CaseCostType).filter(CaseCostType.id == case_cost_type_id).one_or_none() -def get_response_cost_type( - *, db_session, project_id: int, model_type: str -) -> CaseCostType | None: +def get_response_cost_type(*, db_session, project_id: int, model_type: str) -> CaseCostType | None: """Gets the default response cost type.""" return ( db_session.query(CaseCostType) @@ -52,9 +50,7 @@ def get_or_create_response_cost_type( return case_cost_type -def get_all_response_case_cost_types( - *, db_session, project_id: int -) -> list[CaseCostType | None]: +def get_all_response_case_cost_types(*, db_session, project_id: int) -> list[CaseCostType | None]: """Returns all response case cost types. This function queries the database for all case cost types that are marked as the response cost type. diff --git a/src/dispatch/conference/service.py b/src/dispatch/conference/service.py index a65b8eb2591c..7e2384a33330 100644 --- a/src/dispatch/conference/service.py +++ b/src/dispatch/conference/service.py @@ -1,4 +1,3 @@ - from .models import Conference, ConferenceCreate diff --git a/src/dispatch/conversation/models.py b/src/dispatch/conversation/models.py index e1e93e2b73e1..94754c35c73d 100644 --- a/src/dispatch/conversation/models.py +++ b/src/dispatch/conversation/models.py @@ -11,6 +11,7 @@ class Conversation(Base, ResourceMixin): """SQLAlchemy model for conversation resources.""" + id = Column(Integer, primary_key=True) channel_id = Column(String) thread_id = Column(String) @@ -22,22 +23,26 @@ class Conversation(Base, ResourceMixin): # Pydantic models... class ConversationBase(ResourceBase): """Base Pydantic model for conversation resources.""" + channel_id: str | None = None thread_id: str | None = None class ConversationCreate(ConversationBase): """Pydantic model for creating a conversation resource.""" + pass class ConversationUpdate(ConversationBase): """Pydantic model for updating a conversation resource.""" + pass class ConversationRead(ConversationBase): """Pydantic model for reading a conversation resource.""" + id: PrimaryKey description: str | None = None @@ -50,4 +55,5 @@ def set_description(cls, _): class ConversationNested(ConversationBase): """Pydantic model for a nested conversation resource.""" + pass diff --git a/src/dispatch/data/alert/service.py b/src/dispatch/data/alert/service.py index f144f187223a..c232d43224a7 100644 --- a/src/dispatch/data/alert/service.py +++ b/src/dispatch/data/alert/service.py @@ -1,4 +1,3 @@ - from pydantic import ValidationError diff --git a/src/dispatch/data/query/service.py b/src/dispatch/data/query/service.py index 8fe6c9f554a6..d5655ddba126 100644 --- a/src/dispatch/data/query/service.py +++ b/src/dispatch/data/query/service.py @@ -27,14 +27,16 @@ def get_by_name_or_raise(*, db_session, query_in: QueryRead, project_id: int) -> query = get_by_name(db_session=db_session, name=query_in.name, project_id=project_id) if not query: - raise ValidationError([ - { - "loc": ("query",), - "msg": f"Query not found: {query_in.name}", - "type": "value_error", - "input": query_in.name, - } - ]) + raise ValidationError( + [ + { + "loc": ("query",), + "msg": f"Query not found: {query_in.name}", + "type": "value_error", + "input": query_in.name, + } + ] + ) return query diff --git a/src/dispatch/data/source/data_format/service.py b/src/dispatch/data/source/data_format/service.py index 77a278c4fc3c..406030097c1c 100644 --- a/src/dispatch/data/source/data_format/service.py +++ b/src/dispatch/data/source/data_format/service.py @@ -38,14 +38,16 @@ def get_by_name_or_raise( ) if not data_format: - raise ValidationError([ - { - "loc": ("dataFormat",), - "msg": f"SourceDataFormat not found: {source_data_format_in.name}", - "type": "value_error", - "input": source_data_format_in.name, - } - ]) + raise ValidationError( + [ + { + "loc": ("dataFormat",), + "msg": f"SourceDataFormat not found: {source_data_format_in.name}", + "type": "value_error", + "input": source_data_format_in.name, + } + ] + ) return data_format diff --git a/src/dispatch/data/source/environment/service.py b/src/dispatch/data/source/environment/service.py index 8c131c5e58bb..b507098310cd 100644 --- a/src/dispatch/data/source/environment/service.py +++ b/src/dispatch/data/source/environment/service.py @@ -40,14 +40,16 @@ def get_by_name_or_raise( ) if not source: - raise ValidationError([ - { - "loc": ("source",), - "msg": f"Source environment not found: {source_environment_in.name}", - "type": "value_error", - "input": source_environment_in.name, - } - ]) + raise ValidationError( + [ + { + "loc": ("source",), + "msg": f"Source environment not found: {source_environment_in.name}", + "type": "value_error", + "input": source_environment_in.name, + } + ] + ) return source diff --git a/src/dispatch/data/source/service.py b/src/dispatch/data/source/service.py index eaf76f7c2ffe..bfad43af4b11 100644 --- a/src/dispatch/data/source/service.py +++ b/src/dispatch/data/source/service.py @@ -35,14 +35,16 @@ def get_by_name_or_raise(*, db_session, project_id, source_in: SourceRead) -> So source = get_by_name(db_session=db_session, project_id=project_id, name=source_in.name) if not source: - raise ValidationError([ - { - "loc": ("source",), - "msg": f"Source not found: {source_in.name}", - "type": "value_error", - "input": source_in.name, - } - ]) + raise ValidationError( + [ + { + "loc": ("source",), + "msg": f"Source not found: {source_in.name}", + "type": "value_error", + "input": source_in.name, + } + ] + ) return source diff --git a/src/dispatch/data/source/status/service.py b/src/dispatch/data/source/status/service.py index 114271ea352f..e3f3ea2bc143 100644 --- a/src/dispatch/data/source/status/service.py +++ b/src/dispatch/data/source/status/service.py @@ -32,14 +32,16 @@ def get_by_name_or_raise( status = get_by_name(db_session=db_session, project_id=project_id, name=source_status_in.name) if not status: - raise ValidationError([ - { - "loc": ("status",), - "msg": f"SourceStatus not found: {source_status_in.name}", - "type": "value_error", - "input": source_status_in.name, - } - ]) + raise ValidationError( + [ + { + "loc": ("status",), + "msg": f"SourceStatus not found: {source_status_in.name}", + "type": "value_error", + "input": source_status_in.name, + } + ] + ) return status diff --git a/src/dispatch/data/source/transport/service.py b/src/dispatch/data/source/transport/service.py index ba5106ccb9fb..2b6fe62c8e0a 100644 --- a/src/dispatch/data/source/transport/service.py +++ b/src/dispatch/data/source/transport/service.py @@ -38,14 +38,16 @@ def get_by_name_or_raise( ) if not source: - raise ValidationError([ - { - "loc": ("source",), - "msg": f"SourceTransport not found: {source_transport_in.name}", - "type": "value_error", - "input": source_transport_in.name, - } - ]) + raise ValidationError( + [ + { + "loc": ("source",), + "msg": f"SourceTransport not found: {source_transport_in.name}", + "type": "value_error", + "input": source_transport_in.name, + } + ] + ) return source diff --git a/src/dispatch/data/source/type/service.py b/src/dispatch/data/source/type/service.py index 34e9bfda7054..91843b3d9b10 100644 --- a/src/dispatch/data/source/type/service.py +++ b/src/dispatch/data/source/type/service.py @@ -32,14 +32,16 @@ def get_by_name_or_raise( source = get_by_name(db_session=db_session, project_id=project_id, name=source_type_in.name) if not source: - raise ValidationError([ - { - "loc": ("source",), - "msg": f"SourceType not found: {source_type_in.name}", - "type": "value_error", - "input": source_type_in.name, - } - ]) + raise ValidationError( + [ + { + "loc": ("source",), + "msg": f"SourceType not found: {source_type_in.name}", + "type": "value_error", + "input": source_type_in.name, + } + ] + ) return source diff --git a/src/dispatch/database/core.py b/src/dispatch/database/core.py index a68726db472b..aeeb0b224d1d 100644 --- a/src/dispatch/database/core.py +++ b/src/dispatch/database/core.py @@ -94,6 +94,7 @@ def resolve_attr(obj, attr, default=None): class Base(DeclarativeBase): """Base class for all SQLAlchemy models.""" + __repr_attrs__ = [] __repr_max_length__ = 15 @@ -146,6 +147,8 @@ def __repr__(self): id_str, " " + self._repr_attrs_str if self._repr_attrs_str else "", ) + + make_searchable(Base.metadata) diff --git a/src/dispatch/database/manage.py b/src/dispatch/database/manage.py index f56d4b5db279..903ca252e2fd 100644 --- a/src/dispatch/database/manage.py +++ b/src/dispatch/database/manage.py @@ -34,7 +34,7 @@ def version_schema(script_location: str): alembic_command.stamp(alembic_cfg, "head") -def get_core_tables() -> list[Table]: +def get_core_tables() -> list[Table]: """Fetches tables that belong to the 'dispatch_core' schema.""" core_tables: list[Table] = [] for _, table in Base.metadata.tables.items(): diff --git a/src/dispatch/database/revisions/core/README b/src/dispatch/database/revisions/core/README index 98e4f9c44eff..2500aa1bcf72 100644 --- a/src/dispatch/database/revisions/core/README +++ b/src/dispatch/database/revisions/core/README @@ -1 +1 @@ -Generic single-database configuration. \ No newline at end of file +Generic single-database configuration. diff --git a/src/dispatch/database/revisions/core/env.py b/src/dispatch/database/revisions/core/env.py index 0ae70a6c01f2..a7b867588e21 100644 --- a/src/dispatch/database/revisions/core/env.py +++ b/src/dispatch/database/revisions/core/env.py @@ -34,6 +34,7 @@ def run_migrations_online(): and associate a connection with the context. """ + def process_revision_directives(context, revision, directives): script = directives[0] if script.upgrade_ops.is_empty(): diff --git a/src/dispatch/database/revisions/tenant/README b/src/dispatch/database/revisions/tenant/README index 98e4f9c44eff..2500aa1bcf72 100644 --- a/src/dispatch/database/revisions/tenant/README +++ b/src/dispatch/database/revisions/tenant/README @@ -1 +1 @@ -Generic single-database configuration. \ No newline at end of file +Generic single-database configuration. diff --git a/src/dispatch/database/service.py b/src/dispatch/database/service.py index 96de7519a00d..0be92b6c80c0 100644 --- a/src/dispatch/database/service.py +++ b/src/dispatch/database/service.py @@ -273,8 +273,8 @@ def get_query_models(query): # Try to get the statement from the query stmt = query.statement - # Extract entities from the statement's froms - for from_obj in stmt.froms: + # Extract entities from the statement's forms + for from_obj in stmt.forms: if hasattr(from_obj, "entity"): # For select statements with an entity if from_obj.entity not in models: diff --git a/src/dispatch/definition/service.py b/src/dispatch/definition/service.py index 958d10ac3dbb..f603d85e1bdf 100644 --- a/src/dispatch/definition/service.py +++ b/src/dispatch/definition/service.py @@ -1,4 +1,3 @@ - from dispatch.project import service as project_service from dispatch.term import service as term_service diff --git a/src/dispatch/definition/views.py b/src/dispatch/definition/views.py index 9831d5fffe41..ceb50cb7ae1c 100644 --- a/src/dispatch/definition/views.py +++ b/src/dispatch/definition/views.py @@ -39,12 +39,14 @@ def create_definition(db_session: DbSession, definition_in: DefinitionCreate): """Create a new definition.""" definition = get_by_text(db_session=db_session, text=definition_in.text) if definition: - raise ValidationError([ - { - "msg": "A description with this text already exists.", - "loc": "text", - } - ]) + raise ValidationError( + [ + { + "msg": "A description with this text already exists.", + "loc": "text", + } + ] + ) return create(db_session=db_session, definition_in=definition_in) diff --git a/src/dispatch/document/service.py b/src/dispatch/document/service.py index 21deb5866fe5..90f4cc8b865b 100644 --- a/src/dispatch/document/service.py +++ b/src/dispatch/document/service.py @@ -89,12 +89,14 @@ def create(*, db_session, document_in: DocumentCreate) -> Document: .one_or_none() ) if faq_doc: - raise ValidationError([ - { - "msg": "FAQ document already defined for this project.", - "loc": "document", - } - ]) + raise ValidationError( + [ + { + "msg": "FAQ document already defined for this project.", + "loc": "document", + } + ] + ) if document_in.resource_type == DocumentResourceTemplateTypes.forms: forms_doc = ( @@ -104,12 +106,14 @@ def create(*, db_session, document_in: DocumentCreate) -> Document: .one_or_none() ) if forms_doc: - raise ValidationError([ - { - "msg": "Forms export template document already defined for this project.", - "loc": "document", - } - ]) + raise ValidationError( + [ + { + "msg": "Forms export template document already defined for this project.", + "loc": "document", + } + ] + ) filters = [ search_filter_service.get(db_session=db_session, search_filter_id=f.id) diff --git a/src/dispatch/entity_type/views.py b/src/dispatch/entity_type/views.py index 162600fbf88c..ce951096a050 100644 --- a/src/dispatch/entity_type/views.py +++ b/src/dispatch/entity_type/views.py @@ -1,4 +1,3 @@ - from fastapi import APIRouter, HTTPException, status from pydantic import ValidationError from sqlalchemy.exc import IntegrityError diff --git a/src/dispatch/event/service.py b/src/dispatch/event/service.py index aa1d7852843a..52032e5ce73a 100644 --- a/src/dispatch/event/service.py +++ b/src/dispatch/event/service.py @@ -492,7 +492,7 @@ def export_timeline( } ) - # Formating for date rows + # Formatting for date rows if text == "\t": insert_data_request.append( { @@ -518,7 +518,7 @@ def export_timeline( } ) - # Formating for time column + # Formatting for time column if row_idx % num_columns == 0: insert_data_request.append( { diff --git a/src/dispatch/feedback/incident/service.py b/src/dispatch/feedback/incident/service.py index 8d8d45d8c8c9..6e077b46ef11 100644 --- a/src/dispatch/feedback/incident/service.py +++ b/src/dispatch/feedback/incident/service.py @@ -68,9 +68,9 @@ def create(*, db_session, feedback_in: FeedbackCreate) -> Feedback: participant = None if feedback_in.participant: from dispatch.participant.service import get as get_participant + participant = get_participant( - db_session=db_session, - participant_id=feedback_in.participant.id + db_session=db_session, participant_id=feedback_in.participant.id ) # Create feedback with the actual ORM objects, not the Pydantic models @@ -80,7 +80,7 @@ def create(*, db_session, feedback_in: FeedbackCreate) -> Feedback: incident=incident, case=case, project=project, - participant=participant + participant=participant, ) db_session.add(feedback) db_session.commit() diff --git a/src/dispatch/feedback/service/service.py b/src/dispatch/feedback/service/service.py index 75b0c7f2794d..18ef42fd2b57 100644 --- a/src/dispatch/feedback/service/service.py +++ b/src/dispatch/feedback/service/service.py @@ -1,4 +1,3 @@ - from sqlalchemy.orm import Session from .models import ServiceFeedback, ServiceFeedbackCreate, ServiceFeedbackUpdate diff --git a/src/dispatch/group/models.py b/src/dispatch/group/models.py index 58e962ec45cf..2327df1b4e0a 100644 --- a/src/dispatch/group/models.py +++ b/src/dispatch/group/models.py @@ -1,4 +1,5 @@ """Models for group resources in the Dispatch application.""" + from pydantic import field_validator, EmailStr from sqlalchemy import Column, Integer, String, ForeignKey @@ -11,6 +12,7 @@ class Group(Base, ResourceMixin): """SQLAlchemy model for group resources.""" + id = Column(Integer, primary_key=True) name = Column(String) email = Column(String) @@ -21,22 +23,26 @@ class Group(Base, ResourceMixin): # Pydantic models... class GroupBase(ResourceBase): """Base Pydantic model for group resources.""" + name: NameStr email: EmailStr class GroupCreate(GroupBase): """Pydantic model for creating a group resource.""" + pass class GroupUpdate(GroupBase): """Pydantic model for updating a group resource.""" + id: PrimaryKey | None = None class GroupRead(GroupBase): """Pydantic model for reading a group resource.""" + id: PrimaryKey description: str | None = None diff --git a/src/dispatch/group/service.py b/src/dispatch/group/service.py index be889044bc3f..e55fc5d36fdc 100644 --- a/src/dispatch/group/service.py +++ b/src/dispatch/group/service.py @@ -1,4 +1,3 @@ - from .models import Group, GroupCreate, GroupUpdate diff --git a/src/dispatch/incident/messaging.py b/src/dispatch/incident/messaging.py index 7fa74e3873ee..5c8d4b1edc79 100644 --- a/src/dispatch/incident/messaging.py +++ b/src/dispatch/incident/messaging.py @@ -822,7 +822,7 @@ def send_incident_participant_has_role_ephemeral_message( ) if not plugin: log.warning( - "Unabled to send incident participant has role message, no conversation plugin enabled." + "Unable to send incident participant has role message, no conversation plugin enabled." ) return @@ -862,7 +862,7 @@ def send_incident_participant_role_not_assigned_ephemeral_message( ) if not plugin: log.warning( - "Unabled to send incident participant role not assigned message, no conversation plugin enabled." # noqa + "Unable to send incident participant role not assigned message, no conversation plugin enabled." # noqa ) return diff --git a/src/dispatch/incident/service.py b/src/dispatch/incident/service.py index 34c3c20189c3..29da216a9185 100644 --- a/src/dispatch/incident/service.py +++ b/src/dispatch/incident/service.py @@ -95,12 +95,14 @@ def get_by_name_or_raise( incident = get_by_name(db_session=db_session, project_id=project_id, name=incident_in.name) if not incident: - raise ValidationError([ - { - "msg": "Incident not found.", - "loc": "name", - } - ]) + raise ValidationError( + [ + { + "msg": "Incident not found.", + "loc": "name", + } + ] + ) return incident diff --git a/src/dispatch/incident/severity/models.py b/src/dispatch/incident/severity/models.py index dc3ba1efe9d1..d5215317a575 100644 --- a/src/dispatch/incident/severity/models.py +++ b/src/dispatch/incident/severity/models.py @@ -9,8 +9,10 @@ from dispatch.models import DispatchBase, NameStr, ProjectMixin, PrimaryKey, Pagination from dispatch.project.models import ProjectRead + class IncidentSeverity(Base, ProjectMixin): """SQLAlchemy model for incident severity resources.""" + __table_args__ = (UniqueConstraint("name", "project_id"),) id = Column(Integer, primary_key=True) name = Column(String) @@ -39,6 +41,7 @@ class IncidentSeverity(Base, ProjectMixin): # Pydantic models class IncidentSeverityBase(DispatchBase): """Base Pydantic model for incident severity resources.""" + color: str | None = None default: bool | None = None description: str | None = None @@ -51,21 +54,25 @@ class IncidentSeverityBase(DispatchBase): class IncidentSeverityCreate(IncidentSeverityBase): """Pydantic model for creating an incident severity resource.""" + pass class IncidentSeverityUpdate(IncidentSeverityBase): """Pydantic model for updating an incident severity resource.""" + pass class IncidentSeverityRead(IncidentSeverityBase): """Pydantic model for reading an incident severity resource.""" + id: PrimaryKey class IncidentSeverityReadMinimal(DispatchBase): """Pydantic model for reading a minimal incident severity resource.""" + id: PrimaryKey color: str | None = None default: bool | None = None @@ -77,4 +84,5 @@ class IncidentSeverityReadMinimal(DispatchBase): class IncidentSeverityPagination(Pagination): """Pydantic model for paginated incident severity results.""" + items: list[IncidentSeverityRead] = [] diff --git a/src/dispatch/incident/type/models.py b/src/dispatch/incident/type/models.py index d147838203c5..26f3f0b1c9e7 100644 --- a/src/dispatch/incident/type/models.py +++ b/src/dispatch/incident/type/models.py @@ -19,8 +19,10 @@ from dispatch.project.models import ProjectRead from dispatch.service.models import ServiceRead + class IncidentType(ProjectMixin, Base): """SQLAlchemy model for incident type resources.""" + __table_args__ = (UniqueConstraint("name", "project_id"),) id = Column(Integer, primary_key=True) name = Column(String) @@ -99,6 +101,7 @@ def get_task_meta(self, slug): class Document(DispatchBase): """Pydantic model for a document related to an incident type.""" + id: PrimaryKey name: NameStr resource_type: str | None = None @@ -110,6 +113,7 @@ class Document(DispatchBase): # Pydantic models... class IncidentTypeBase(DispatchBase): """Base Pydantic model for incident type resources.""" + name: NameStr visibility: str | None = None description: str | None = None @@ -138,21 +142,25 @@ def replace_none_with_empty_list(cls, value): class IncidentTypeCreate(IncidentTypeBase): """Pydantic model for creating an incident type resource.""" + pass class IncidentTypeUpdate(IncidentTypeBase): """Pydantic model for updating an incident type resource.""" + id: PrimaryKey | None = None class IncidentTypeRead(IncidentTypeBase): """Pydantic model for reading an incident type resource.""" + id: PrimaryKey class IncidentTypeReadMinimal(DispatchBase): """Pydantic model for reading a minimal incident type resource.""" + id: PrimaryKey name: NameStr visibility: str | None = None @@ -164,4 +172,5 @@ class IncidentTypeReadMinimal(DispatchBase): class IncidentTypePagination(Pagination): """Pydantic model for paginated incident type results.""" + items: list[IncidentTypeRead] = [] diff --git a/src/dispatch/incident_cost_type/models.py b/src/dispatch/incident_cost_type/models.py index 29228e47ecd2..66408dd4fd5b 100644 --- a/src/dispatch/incident_cost_type/models.py +++ b/src/dispatch/incident_cost_type/models.py @@ -20,6 +20,7 @@ # SQLAlchemy Model class IncidentCostType(Base, TimeStampMixin, ProjectMixin): """SQLAlchemy model for incident cost type resources.""" + # columns id = Column(Integer, primary_key=True) name = Column(String) @@ -41,6 +42,7 @@ class IncidentCostType(Base, TimeStampMixin, ProjectMixin): # Pydantic Models class IncidentCostTypeBase(DispatchBase): """Base Pydantic model for incident cost type resources.""" + name: NameStr description: str | None = None category: str | None = None @@ -51,20 +53,24 @@ class IncidentCostTypeBase(DispatchBase): class IncidentCostTypeCreate(IncidentCostTypeBase): """Pydantic model for creating an incident cost type.""" + project: ProjectRead class IncidentCostTypeUpdate(IncidentCostTypeBase): """Pydantic model for updating an incident cost type.""" + id: PrimaryKey | None = None class IncidentCostTypeRead(IncidentCostTypeBase): """Pydantic model for reading an incident cost type.""" + id: PrimaryKey created_at: datetime class IncidentCostTypePagination(Pagination): """Pydantic model for paginated incident cost type results.""" + items: list[IncidentCostTypeRead] = [] diff --git a/src/dispatch/incident_cost_type/service.py b/src/dispatch/incident_cost_type/service.py index bc6bdeb0bb12..6d6eb7483da4 100644 --- a/src/dispatch/incident_cost_type/service.py +++ b/src/dispatch/incident_cost_type/service.py @@ -50,9 +50,7 @@ def create(*, db_session, incident_cost_type_in: IncidentCostTypeCreate) -> Inci project = project_service.get_by_name_or_raise( db_session=db_session, project_in=incident_cost_type_in.project ) - incident_cost_type = IncidentCostType( - **incident_cost_type_in.dict(exclude={"project"}) - ) + incident_cost_type = IncidentCostType(**incident_cost_type_in.dict(exclude={"project"})) incident_cost_type.project = project # type: ignore[attr-defined] db_session.add(incident_cost_type) db_session.commit() diff --git a/src/dispatch/incident_role/service.py b/src/dispatch/incident_role/service.py index 53e21619cf5f..4b5ba4de3677 100644 --- a/src/dispatch/incident_role/service.py +++ b/src/dispatch/incident_role/service.py @@ -84,7 +84,7 @@ def create_or_update( "msg": "Incident role not found.", "input": role_policy_in.name, } - ] + ], ) else: diff --git a/src/dispatch/individual/service.py b/src/dispatch/individual/service.py index 0a664db10a8a..5edc691c151b 100644 --- a/src/dispatch/individual/service.py +++ b/src/dispatch/individual/service.py @@ -53,14 +53,16 @@ def get_by_email_and_project_id_or_raise( ) if not individual_contact: - raise ValidationError([ - { - "loc": ("individual",), - "msg": "Individual not found.", - "type": "value_error", - "input": individual_contact_in.email, - } - ]) + raise ValidationError( + [ + { + "loc": ("individual",), + "msg": "Individual not found.", + "type": "value_error", + "input": individual_contact_in.email, + } + ] + ) return individual_contact diff --git a/src/dispatch/individual/views.py b/src/dispatch/individual/views.py index 5d362955a7e0..3214fd2bf692 100644 --- a/src/dispatch/individual/views.py +++ b/src/dispatch/individual/views.py @@ -36,7 +36,7 @@ def get_individual(db_session: DbSession, individual_contact_id: PrimaryKey): "msg": "Individual not found.", "input": individual_contact_id, } - ] + ], ) return individual @@ -56,12 +56,14 @@ def create_individual(db_session: DbSession, individual_contact_in: IndividualCo project_id=individual_contact_in.project.id, ) if individual: - raise ValidationError([ - { - "msg": "An individual with this email already exists.", - "loc": "email", - } - ]) + raise ValidationError( + [ + { + "msg": "An individual with this email already exists.", + "loc": "email", + } + ] + ) return create(db_session=db_session, individual_contact_in=individual_contact_in) @@ -88,7 +90,7 @@ def update_individual( "msg": "Individual not found.", "input": individual_contact_id, } - ] + ], ) return update( db_session=db_session, @@ -116,6 +118,6 @@ def delete_individual(db_session: DbSession, individual_contact_id: PrimaryKey): "msg": "Individual not found.", "input": individual_contact_id, } - ] + ], ) delete(db_session=db_session, individual_contact_id=individual_contact_id) diff --git a/src/dispatch/messaging/strings.py b/src/dispatch/messaging/strings.py index a791e7e2c3c4..5dcb194393f7 100644 --- a/src/dispatch/messaging/strings.py +++ b/src/dispatch/messaging/strings.py @@ -78,36 +78,24 @@ 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() CASE_FEEDBACK_DAILY_REPORT_DESCRIPTION = """ -This is a daily report of feedback about cases handled by you.""".replace( - "\n", " " -).strip() +This is a daily report of feedback about cases handled by you.""".replace("\n", " ").strip() INCIDENT_WEEKLY_REPORT_TITLE = """ -Incidents Weekly Report""".replace( - "\n", " " -).strip() +Incidents Weekly Report""".replace("\n", " ").strip() INCIDENT_WEEKLY_REPORT_DESCRIPTION = """ This is an AI-generated weekly summary of incidents that have been marked as closed in the last week. NOTE: These summaries may contain errors or inaccuracies. -Please verify the information before relying on it.""".replace( - "\n", " " -).strip() +Please verify the information before relying on it.""".replace("\n", " ").strip() INCIDENT_WEEKLY_REPORT_NO_INCIDENTS_DESCRIPTION = """ -No open visibility incidents have been closed in the last week.""".replace( - "\n", " " -).strip() +No open visibility incidents have been closed in the last week.""".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( @@ -128,9 +116,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. @@ -155,75 +141,53 @@ 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() CASE_CONVERSATION_REFERENCE_DOCUMENT_DESCRIPTION = """ Document containing the list of slash commands available to the Assignee -and participants in the case conversation.""".replace( - "\n", " " -).strip() +and participants in the case conversation.""".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() CASE_CONFERENCE_DESCRIPTION = """ Video conference and phone bridge to be used throughout the case. Password: {{conference_challenge if conference_challenge else 'N/A'}} -""".replace( - "\n", "" -).strip() +""".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() CASE_FAQ_DOCUMENT_DESCRIPTION = """ First time responding to a case? This document answers common questions encountered when -helping us respond to a case.""".replace( - "\n", " " -).strip() +helping us respond to a case.""".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( @@ -247,15 +211,11 @@ 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_COMPLETED_FORM_DESCRIPTION = """ A new {{form_type}} form related to incident {{name}} has been @@ -263,34 +223,24 @@ class MessageType(DispatchEnum): aspects related to potential legal implications. You can review the detailed report by clicking on the link below. Please note, the information contained in this report is confidential. -""".replace( - "\n", " " -).strip() +""".replace("\n", " ").strip() CASE_PARTICIPANT_WELCOME_DESCRIPTION = """ You\'ve been added to this case, because we think you may be able to help resolve it. Please review the case details below and -reach out to the assignee if you have any questions.""".replace( - "\n", " " -).strip() +reach out to the assignee if you have any questions.""".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() 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( @@ -319,9 +269,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( @@ -346,9 +294,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_TASK_ADD_TO_INCIDENT_DESCRIPTION = """ You have been added to this incident because you were assigned a task related to it. View all tasks for this incident using the <{{dispatch_ui_url}}|Dispatch Web UI> @@ -874,9 +820,7 @@ class MessageType(DispatchEnum): CASE_ASSIGNEE_DESCRIPTION = """ The Case Assignee is responsible for knowing the full context of the case. -Contact them about any questions or concerns.""".replace( - "\n", " " -).strip() +Contact them about any questions or concerns.""".replace("\n", " ").strip() CASE_REPORTER_DESCRIPTION = """ The person who reported the case. Contact them if the report details need clarification.""".replace( diff --git a/src/dispatch/models.py b/src/dispatch/models.py index 19822c9e7cc9..9d1cc684b739 100644 --- a/src/dispatch/models.py +++ b/src/dispatch/models.py @@ -85,7 +85,10 @@ class EvergreenMixin(object): def overdue(self): """Returns True if the evergreen reminder is overdue.""" now = datetime.now(timezone.utc) - if self.evergreen_last_reminder_at is not None and self.evergreen_reminder_interval is not None: + if ( + self.evergreen_last_reminder_at is not None + and self.evergreen_reminder_interval is not None + ): next_reminder = self.evergreen_last_reminder_at + timedelta( days=self.evergreen_reminder_interval ) @@ -112,6 +115,7 @@ class FeedbackMixin(object): # Pydantic models... class DispatchBase(BaseModel): """Base Pydantic model with shared config for Dispatch models.""" + model_config: ClassVar[ConfigDict] = ConfigDict( from_attributes=True, validate_assignment=True, @@ -127,6 +131,7 @@ class DispatchBase(BaseModel): class Pagination(DispatchBase): """Pydantic model for paginated results.""" + itemsPerPage: int page: int total: int @@ -134,11 +139,13 @@ class Pagination(DispatchBase): class PrimaryKeyModel(BaseModel): """Pydantic model for a primary key field.""" + id: PrimaryKey class EvergreenBase(DispatchBase): """Base Pydantic model for evergreen resources.""" + evergreen: bool | None = False evergreen_owner: EmailStr | None = None evergreen_reminder_interval: int | None = 90 @@ -147,6 +154,7 @@ class EvergreenBase(DispatchBase): class ResourceBase(DispatchBase): """Base Pydantic model for resource-related fields.""" + resource_type: str | None = None resource_id: str | None = None weblink: str | None = None @@ -154,6 +162,7 @@ class ResourceBase(DispatchBase): class ContactBase(DispatchBase): """Base Pydantic model for contact-related fields.""" + email: EmailStr name: str | None = None is_active: bool | None = True diff --git a/src/dispatch/monitor/service.py b/src/dispatch/monitor/service.py index 2c19c453b9d9..3e941aa0e502 100644 --- a/src/dispatch/monitor/service.py +++ b/src/dispatch/monitor/service.py @@ -1,4 +1,3 @@ - from sqlalchemy.sql.expression import true from dispatch.incident import service as incident_service from dispatch.plugin import service as plugin_service diff --git a/src/dispatch/organization/models.py b/src/dispatch/organization/models.py index 8455f9daf117..41b543f1ef0c 100644 --- a/src/dispatch/organization/models.py +++ b/src/dispatch/organization/models.py @@ -12,6 +12,7 @@ class Organization(Base): """SQLAlchemy model for organization resources.""" + __table_args__ = {"schema": "dispatch_core"} id = Column(Integer, primary_key=True) @@ -39,6 +40,7 @@ def generate_slug(target, value, oldvalue, initiator): class OrganizationBase(DispatchBase): """Base Pydantic model for organization resources.""" + id: PrimaryKey | None = None name: NameStr description: str | None = None @@ -50,11 +52,13 @@ class OrganizationBase(DispatchBase): class OrganizationCreate(OrganizationBase): """Pydantic model for creating an organization resource.""" + pass class OrganizationUpdate(DispatchBase): """Pydantic model for updating an organization resource.""" + id: PrimaryKey | None = None description: str | None = None default: bool | None = False @@ -65,10 +69,12 @@ class OrganizationUpdate(DispatchBase): class OrganizationRead(OrganizationBase): """Pydantic model for reading an organization resource.""" + id: PrimaryKey | None = None slug: OrganizationSlug | None = None class OrganizationPagination(Pagination): """Pydantic model for paginated organization results.""" + items: list[OrganizationRead] = [] diff --git a/src/dispatch/participant/service.py b/src/dispatch/participant/service.py index ea873a4bd253..355523342fea 100644 --- a/src/dispatch/participant/service.py +++ b/src/dispatch/participant/service.py @@ -60,9 +60,7 @@ def get_by_incident_id_and_role( ) -def get_by_case_id_and_role( - *, db_session: Session, case_id: int, role: str -) -> Participant | None: +def get_by_case_id_and_role(*, db_session: Session, case_id: int, role: str) -> Participant | None: """Get a participant by case id and role name.""" return ( db_session.query(Participant) diff --git a/src/dispatch/plugin/service.py b/src/dispatch/plugin/service.py index c87e2600801d..fc5c1064bd5a 100644 --- a/src/dispatch/plugin/service.py +++ b/src/dispatch/plugin/service.py @@ -152,12 +152,14 @@ def update_instance( db_session=db_session, service_type=plugin_instance.plugin.slug, is_active=True ) if oncall_services: - raise ValidationError([ - { - "msg": "Cannot disable plugin instance: {plugin_instance.plugin.title}. One or more oncall services depend on it. ", - "loc": "plugin_instance", - } - ]) + raise ValidationError( + [ + { + "msg": "Cannot disable plugin instance: {plugin_instance.plugin.title}. One or more oncall services depend on it. ", + "loc": "plugin_instance", + } + ] + ) for field in plugin_instance_data: if field in update_data: @@ -185,9 +187,7 @@ def get_plugin_event_by_slug(*, db_session: Session, slug: str) -> PluginEvent | return db_session.query(PluginEvent).filter(PluginEvent.slug == slug).one_or_none() -def get_all_events_for_plugin( - *, db_session: Session, plugin_id: int -) -> list[PluginEvent | None]: +def get_all_events_for_plugin(*, db_session: Session, plugin_id: int) -> list[PluginEvent | None]: """Returns all plugin events for a given plugin.""" return db_session.query(PluginEvent).filter(PluginEvent.plugin_id == plugin_id).all() diff --git a/src/dispatch/plugins/dispatch_core/plugin.py b/src/dispatch/plugins/dispatch_core/plugin.py index 43acb74e1e7d..b9f0df525ad2 100644 --- a/src/dispatch/plugins/dispatch_core/plugin.py +++ b/src/dispatch/plugins/dispatch_core/plugin.py @@ -181,7 +181,11 @@ class AwsAlbAuthProviderPlugin(AuthenticationProviderPlugin): author_url = "https://manypets.com/" configuration_schema = None - @cached(cache=TTLCache(maxsize=1024, ttl=DISPATCH_AUTHENTICATION_PROVIDER_AWS_ALB_PUBLIC_KEY_CACHE_SECONDS)) + @cached( + cache=TTLCache( + maxsize=1024, ttl=DISPATCH_AUTHENTICATION_PROVIDER_AWS_ALB_PUBLIC_KEY_CACHE_SECONDS + ) + ) def get_public_key(self, kid: str, region: str): log.debug("Cache miss. Requesting key from AWS endpoint.") url = f"https://public-keys.auth.elb.{region}.amazonaws.com/{kid}" @@ -193,20 +197,18 @@ def get_current_user(self, request: Request, **kwargs): status_code=HTTP_401_UNAUTHORIZED, detail=[{"msg": "Could not validate credentials"}] ) - encoded_jwt: str = request.headers.get('x-amzn-oidc-data') + encoded_jwt: str = request.headers.get("x-amzn-oidc-data") if not encoded_jwt: - log.error( - "Unable to authenticate. Header x-amzn-oidc-data not found." - ) + log.error("Unable to authenticate. Header x-amzn-oidc-data not found.") raise credentials_exception log.debug(f"Header x-amzn-oidc-data header received: {encoded_jwt}") # Validate the signer - jwt_headers = encoded_jwt.split('.')[0] + jwt_headers = encoded_jwt.split(".")[0] decoded_jwt_headers = base64.b64decode(jwt_headers) decoded_json = json.loads(decoded_jwt_headers) - received_alb_arn = decoded_json['signer'] + received_alb_arn = decoded_json["signer"] if received_alb_arn != DISPATCH_AUTHENTICATION_PROVIDER_AWS_ALB_ARN: log.error( @@ -215,10 +217,10 @@ def get_current_user(self, request: Request, **kwargs): raise credentials_exception # Get the key id from JWT headers (the kid field) - kid = decoded_json['kid'] + kid = decoded_json["kid"] # Get the region from the ARN - region = DISPATCH_AUTHENTICATION_PROVIDER_AWS_ALB_ARN.split(':')[3] + region = DISPATCH_AUTHENTICATION_PROVIDER_AWS_ALB_ARN.split(":")[3] # Get the public key from regional endpoint log.debug(f"Getting public key for kid {kid} in region {region}.") @@ -226,7 +228,7 @@ def get_current_user(self, request: Request, **kwargs): # Get the payload log.debug(f"Decoding {encoded_jwt} with public key {pub_key}.") - payload = jwt.decode(encoded_jwt, pub_key, algorithms=['ES256']) + payload = jwt.decode(encoded_jwt, pub_key, algorithms=["ES256"]) return payload[DISPATCH_AUTHENTICATION_PROVIDER_AWS_ALB_EMAIL_CLAIM] diff --git a/src/dispatch/plugins/dispatch_google/drive/task.py b/src/dispatch/plugins/dispatch_google/drive/task.py index cbb4401da71c..36cd93f11ef7 100644 --- a/src/dispatch/plugins/dispatch_google/drive/task.py +++ b/src/dispatch/plugins/dispatch_google/drive/task.py @@ -49,7 +49,10 @@ def find_urls(text: str) -> list[str]: """Finds a url in a text blob.""" # findall() has been used # with valid conditions for urls in string - regex = r"(?i)\b((?:https?://|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:'\".,<>?«»""'']))" + regex = ( + r"(?i)\b((?:https?://|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:'\".,<>?«»" + "'']))" + ) url = re.findall(regex, text) return [x[0] for x in url] diff --git a/src/dispatch/plugins/dispatch_google/groups/plugin.py b/src/dispatch/plugins/dispatch_google/groups/plugin.py index aac6dedac399..ea6d41fc98fc 100644 --- a/src/dispatch/plugins/dispatch_google/groups/plugin.py +++ b/src/dispatch/plugins/dispatch_google/groups/plugin.py @@ -26,7 +26,9 @@ retry=retry_if_exception_type(TryAgain), wait=wait_exponential(multiplier=1, min=2, max=5), ) -def make_call(client: Any, func: Any, delay: int | None = None, propagate_errors: bool = False, **kwargs): +def make_call( + client: Any, func: Any, delay: int | None = None, propagate_errors: bool = False, **kwargs +): """Make an google client api call.""" try: data = getattr(client, func)(**kwargs).execute() diff --git a/src/dispatch/plugins/dispatch_slack/case/interactive.py b/src/dispatch/plugins/dispatch_slack/case/interactive.py index 0c79c900ba6e..04052f28c0ac 100644 --- a/src/dispatch/plugins/dispatch_slack/case/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/case/interactive.py @@ -287,7 +287,7 @@ def handle_update_case_command( ), case_visibility_select( initial_option={"text": case.visibility, "value": case.visibility}, - ) + ), ] modal = Modal( diff --git a/src/dispatch/plugins/dispatch_slack/case/messages.py b/src/dispatch/plugins/dispatch_slack/case/messages.py index 12d5cbdb0f93..481841806969 100644 --- a/src/dispatch/plugins/dispatch_slack/case/messages.py +++ b/src/dispatch/plugins/dispatch_slack/case/messages.py @@ -1,5 +1,7 @@ import logging from typing import NamedTuple +import html +import re from blockkit import ( @@ -46,21 +48,61 @@ def map_priority_color(color: str) -> str: - """Maps a priority color to its corresponding emoji symbol.""" - if not color: - return "" + """ + Returns the first slack-compatible priority color for the given color. - # TODO we should probably restrict the possible colors to make this work - priority_color_mapping = { - "#9e9e9e": "⚪", - "#8bc34a": "🟢", - "#ffeb3b": "🟡", - "#ff9800": "🟠", - "#f44336": "🔴", - "#9c27b0": "🟣", + Args: + color (str): RGB Hex color string. + + Returns: + str: Slack Color string. + """ + color_mappings = { + "red": ":red_circle:", + "orange": ":orange_circle:", + "amber": ":yellow_circle:", + "green": ":green_circle:", + "blue": ":blue_circle:", + "purple": ":purple_circle:", + "grey": ":grey_circle:", + "gray": ":grey_circle:", } - return priority_color_mapping.get(color.lower(), "") + for mapping_color in color_mappings: + if mapping_color in color.lower(): + return color_mappings[mapping_color] + + return ":blue_circle:" + + +def html_to_plain_text(html_content: str) -> str: + """ + Convert HTML content to plain text for Slack messages. + + Args: + html_content (str): HTML content to convert + + Returns: + str: Plain text content + """ + if not html_content: + return "" + + # First decode any HTML entities + text = html.unescape(html_content) + + # Remove HTML tags but preserve line breaks + text = re.sub(r"", "\n", text) + text = re.sub(r"

", "\n\n", text) + text = re.sub(r"]*>", "", text) + text = re.sub(r"]+>", "", text) + + # Clean up extra whitespace + text = re.sub(r"\n\s*\n\s*\n", "\n\n", text) # Multiple newlines to double + text = re.sub(r"[ \t]+", " ", text) # Multiple spaces/tabs to single space + text = text.strip() + + return text def create_case_message(case: Case, channel_id: str) -> list[Block]: @@ -142,11 +184,13 @@ def create_case_message(case: Case, channel_id: str) -> list[Block]: ] ) elif case.status == CaseStatus.closed: + # Convert HTML resolution to plain text for Slack display + resolution_text = html_to_plain_text(case.resolution) if case.resolution else "" blocks.extend( [ Section(text=f"*Resolution reason* \n {case.resolution_reason}"), Section( - text=f"*Resolution description* \n {case.resolution}"[:MAX_SECTION_TEXT_LENGTH] + text=f"*Resolution description* \n {resolution_text}"[:MAX_SECTION_TEXT_LENGTH] ), Actions( elements=[ diff --git a/src/dispatch/plugins/dispatch_slack/fields.py b/src/dispatch/plugins/dispatch_slack/fields.py index 888e6cd505cd..95e4b8f0c104 100644 --- a/src/dispatch/plugins/dispatch_slack/fields.py +++ b/src/dispatch/plugins/dispatch_slack/fields.py @@ -696,7 +696,7 @@ def case_visibility_select( """Creates a case visibility select.""" visibility = [ {"text": Visibility.restricted, "value": Visibility.restricted}, - {"text": Visibility.open, "value": Visibility.open} + {"text": Visibility.open, "value": Visibility.open}, ] return static_select_block( diff --git a/src/dispatch/plugins/dispatch_slack/messaging.py b/src/dispatch/plugins/dispatch_slack/messaging.py index e11662b8ca50..c12906aeb78b 100644 --- a/src/dispatch/plugins/dispatch_slack/messaging.py +++ b/src/dispatch/plugins/dispatch_slack/messaging.py @@ -143,18 +143,18 @@ def get_incident_conversation_command_message( def build_command_error_message(payload: dict, error: Any) -> str: - message = f"""Unfortunately we couldn't run `{payload['command']}` due to the following reason: {str(error)} """ + message = f"""Unfortunately we couldn't run `{payload["command"]}` due to the following reason: {str(error)} """ return message def build_role_error_message(payload: dict) -> str: - message = f"""I see you tried to run `{payload['command']}`. This is a sensitive command and cannot be run with the incident role you are currently assigned.""" + message = f"""I see you tried to run `{payload["command"]}`. This is a sensitive command and cannot be run with the incident role you are currently assigned.""" return message def build_context_error_message(payload: dict, error: Any) -> str: message = ( - f"""I see you tried to run `{payload['command']}` in an non-incident conversation. Incident-specific commands can only be run in incident conversations.""" # command_context_middleware() + f"""I see you tried to run `{payload["command"]}` in an non-incident conversation. Incident-specific commands can only be run in incident conversations.""" # command_context_middleware() if payload.get("command") else str(error) # everything else ) diff --git a/src/dispatch/plugins/dispatch_slack/plugin.py b/src/dispatch/plugins/dispatch_slack/plugin.py index 5e10e0fafc2d..1871dda26e6a 100644 --- a/src/dispatch/plugins/dispatch_slack/plugin.py +++ b/src/dispatch/plugins/dispatch_slack/plugin.py @@ -442,7 +442,10 @@ def fetch_events( activity = event_instance.fetch_activity(client, subject, oldest) return activity except Exception as e: - logger.exception("An error occurred while fetching incident or case events from the Slack plugin.", exc_info=e) + logger.exception( + "An error occurred while fetching incident or case events from the Slack plugin.", + exc_info=e, + ) raise def get_conversation_replies(self, conversation_id: str, thread_ts: str) -> list[str]: diff --git a/src/dispatch/report/flows.py b/src/dispatch/report/flows.py index 6b473658d041..e1faf6a8d86d 100644 --- a/src/dispatch/report/flows.py +++ b/src/dispatch/report/flows.py @@ -98,12 +98,14 @@ def create_executive_report( incident = incident_service.get(db_session=db_session, incident_id=incident_id) if not incident.incident_type.executive_template_document: - raise ValidationError([ - { - "msg": "No executive report template defined.", - "loc": "executive_template_document", - } - ]) + raise ValidationError( + [ + { + "msg": "No executive report template defined.", + "loc": "executive_template_document", + } + ] + ) # we fetch all previous executive reports executive_reports = get_all_by_incident_id_and_type( diff --git a/src/dispatch/report/service.py b/src/dispatch/report/service.py index 8299ddf0e226..5db8209560c1 100644 --- a/src/dispatch/report/service.py +++ b/src/dispatch/report/service.py @@ -1,4 +1,3 @@ - from .enums import ReportTypes from .models import Report, ReportCreate, ReportUpdate diff --git a/src/dispatch/search/fulltext/__init__.py b/src/dispatch/search/fulltext/__init__.py index 4619b5d89a37..18b510c9bff6 100644 --- a/src/dispatch/search/fulltext/__init__.py +++ b/src/dispatch/search/fulltext/__init__.py @@ -2,6 +2,7 @@ Originally authored by: https://github.com/kvesteri/sqlalchemy-searchable/blob/master/sqlalchemy_searchable """ + import os from functools import reduce @@ -55,9 +56,11 @@ def search(query, search_query, vector=None, regconfig=None, sort=False): # Get the entity class from the query in a SQLAlchemy 2.x compatible way try: # For SQLAlchemy 2.x - entity = query.column_descriptions[0]['entity'] + entity = query.column_descriptions[0]["entity"] except (AttributeError, IndexError, KeyError): - raise ValueError("Could not determine entity class from query. Please provide vector explicitly.") from None + raise ValueError( + "Could not determine entity class from query. Please provide vector explicitly." + ) from None search_vectors = inspect_search_vectors(entity) vector = search_vectors[0] diff --git a/src/dispatch/search/models.py b/src/dispatch/search/models.py index 7cb40670f16a..f758284766f5 100644 --- a/src/dispatch/search/models.py +++ b/src/dispatch/search/models.py @@ -16,9 +16,11 @@ from dispatch.team.models import TeamContactRead from dispatch.term.models import TermRead + # Pydantic models... class SearchBase(DispatchBase): """Base model for search queries.""" + query: str | None = None @@ -28,6 +30,7 @@ class SearchRequest(SearchBase): class ContentResponse(DispatchBase): """Model for search content response.""" + documents: list[DocumentRead] | None = Field(default_factory=list, alias="Document") incidents: list[IncidentRead] | None = Field(default_factory=list, alias="Incident") tasks: list[TaskRead] | None = Field(default_factory=list, alias="Task") @@ -37,7 +40,9 @@ class ContentResponse(DispatchBase): sources: list[SourceRead] | None = Field(default_factory=list, alias="Source") queries: list[QueryRead] | None = Field(default_factory=list, alias="Query") teams: list[TeamContactRead] | None = Field(default_factory=list, alias="TeamContact") - individuals: list[IndividualContactRead] | None = Field(default_factory=list, alias="IndividualContact") + individuals: list[IndividualContactRead] | None = Field( + default_factory=list, alias="IndividualContact" + ) services: list[ServiceRead] | None = Field(default_factory=list, alias="Service") cases: list[CaseRead] | None = Field(default_factory=list, alias="Case") model_config: ClassVar[ConfigDict] = ConfigDict(populate_by_name=True) @@ -45,5 +50,6 @@ class ContentResponse(DispatchBase): class SearchResponse(DispatchBase): """Model for a search response.""" + query: str | None = None results: ContentResponse diff --git a/src/dispatch/search_filter/service.py b/src/dispatch/search_filter/service.py index 67ef809671cd..e57ffabcf142 100644 --- a/src/dispatch/search_filter/service.py +++ b/src/dispatch/search_filter/service.py @@ -1,4 +1,3 @@ - from sqlalchemy_filters import apply_filters from dispatch.database.core import Base, get_class_by_tablename, get_table_name_by_class_instance diff --git a/src/dispatch/service/service.py b/src/dispatch/service/service.py index 6352d1f1dd4b..5b3ecd43887c 100644 --- a/src/dispatch/service/service.py +++ b/src/dispatch/service/service.py @@ -1,4 +1,3 @@ - from pydantic import ValidationError from dispatch.plugin import service as plugin_service @@ -39,14 +38,16 @@ def get_by_name_or_raise(*, db_session, project_id, service_in: ServiceRead) -> source = get_by_name(db_session=db_session, project_id=project_id, name=service_in.name) if not source: - raise ValidationError([ - { - "loc": ("service",), - "msg": f"Service not found: {service_in.name}", - "type": "value_error", - "input": service_in.name, - } - ]) + raise ValidationError( + [ + { + "loc": ("service",), + "msg": f"Service not found: {service_in.name}", + "type": "value_error", + "input": service_in.name, + } + ] + ) return source diff --git a/src/dispatch/static/dispatch/.npmrc b/src/dispatch/static/dispatch/.npmrc index ff753f54526a..74d1d2708e10 100644 --- a/src/dispatch/static/dispatch/.npmrc +++ b/src/dispatch/static/dispatch/.npmrc @@ -1,2 +1 @@ //registry.npmjs.org/:_authToken=${FORMKIT_ENTERPRISE_TOKEN} - diff --git a/src/dispatch/static/dispatch/components.d.ts b/src/dispatch/static/dispatch/components.d.ts index cd9ce5015d8e..b4e4bf790339 100644 --- a/src/dispatch/static/dispatch/components.d.ts +++ b/src/dispatch/static/dispatch/components.d.ts @@ -8,11 +8,9 @@ export {} declare module '@vue/runtime-core' { export interface GlobalComponents { AdminLayout: typeof import('./src/components/layouts/AdminLayout.vue')['default'] - AnimatedNumber: typeof import("./src/components/AnimatedNumber.vue")["default"] AppDrawer: typeof import('./src/components/AppDrawer.vue')['default'] AppToolbar: typeof import('./src/components/AppToolbar.vue')['default'] AutoComplete: typeof import('./src/components/AutoComplete.vue')['default'] - Avatar: typeof import("./src/components/Avatar.vue")["default"] BaseCombobox: typeof import('./src/components/BaseCombobox.vue')['default'] BasicLayout: typeof import('./src/components/layouts/BasicLayout.vue')['default'] ColorPickerInput: typeof import('./src/components/ColorPickerInput.vue')['default'] @@ -25,17 +23,15 @@ declare module '@vue/runtime-core' { DMenu: typeof import('./src/components/DMenu.vue')['default'] DTooltip: typeof import('./src/components/DTooltip.vue')['default'] GenaiAnalysisDisplay: typeof import('./src/components/GenaiAnalysisDisplay.vue')['default'] + HtmlRenderer: typeof import('./src/components/HtmlRenderer.vue')['default'] IconPickerInput: typeof import('./src/components/IconPickerInput.vue')['default'] InfoWidget: typeof import('./src/components/InfoWidget.vue')['default'] Loading: typeof import('./src/components/Loading.vue')['default'] LockButton: typeof import('./src/components/LockButton.vue')['default'] MonacoEditor: typeof import('./src/components/MonacoEditor.vue')['default'] NotificationSnackbarsWrapper: typeof import('./src/components/NotificationSnackbarsWrapper.vue')['default'] - PageHeader: typeof import("./src/components/PageHeader.vue")["default"] - ParticipantAutoComplete: typeof import("./src/components/ParticipantAutoComplete.vue")["default"] ParticipantSelect: typeof import('./src/components/ParticipantSelect.vue')['default'] PreciseDateTimePicker: typeof import('./src/components/PreciseDateTimePicker.vue')['default'] - ProjectAutoComplete: typeof import("./src/components/ProjectAutoComplete.vue")["default"] Refresh: typeof import('./src/components/Refresh.vue')['default'] RichEditor: typeof import('./src/components/RichEditor.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] @@ -43,93 +39,6 @@ declare module '@vue/runtime-core' { SavingState: typeof import('./src/components/SavingState.vue')['default'] SearchPopover: typeof import('./src/components/SearchPopover.vue')['default'] SettingsBreadcrumbs: typeof import('./src/components/SettingsBreadcrumbs.vue')['default'] - ShepherdStep: typeof import("./src/components/ShepherdStep.vue")["default"] - ShpherdStep: typeof import("./src/components/ShpherdStep.vue")["default"] StatWidget: typeof import('./src/components/StatWidget.vue')['default'] - SubjectLastUpdated: typeof import("./src/components/SubjectLastUpdated.vue")["default"] - TimePicker: typeof import("./src/components/TimePicker.vue")["default"] - VAlert: typeof import("vuetify/lib")["VAlert"] - VApp: typeof import("vuetify/lib")["VApp"] - VAppBar: typeof import("vuetify/lib")["VAppBar"] - VAutocomplete: typeof import("vuetify/lib")["VAutocomplete"] - VAvatar: typeof import("vuetify/lib")["VAvatar"] - VBadge: typeof import("vuetify/lib")["VBadge"] - VBottomSheet: typeof import("vuetify/lib")["VBottomSheet"] - VBreadcrumbs: typeof import("vuetify/lib")["VBreadcrumbs"] - VBreadcrumbsItem: typeof import("vuetify/lib")["VBreadcrumbsItem"] - VBtn: typeof import("vuetify/lib")["VBtn"] - VCard: typeof import("vuetify/lib")["VCard"] - VCardActions: typeof import("vuetify/lib")["VCardActions"] - VCardSubtitle: typeof import("vuetify/lib")["VCardSubtitle"] - VCardText: typeof import("vuetify/lib")["VCardText"] - VCardTitle: typeof import("vuetify/lib")["VCardTitle"] - VCheckbox: typeof import("vuetify/lib")["VCheckbox"] - VChip: typeof import("vuetify/lib")["VChip"] - VChipGroup: typeof import("vuetify/lib")["VChipGroup"] - VCol: typeof import("vuetify/lib")["VCol"] - VColorPicker: typeof import("vuetify/lib")["VColorPicker"] - VCombobox: typeof import("vuetify/lib")["VCombobox"] - VContainer: typeof import("vuetify/lib")["VContainer"] - VDataTable: typeof import("vuetify/lib")["VDataTable"] - VDatePicker: typeof import("vuetify/lib")["VDatePicker"] - VDialog: typeof import("vuetify/lib")["VDialog"] - VDivider: typeof import("vuetify/lib")["VDivider"] - VExpandTransition: typeof import("vuetify/lib")["VExpandTransition"] - VExpansionPanel: typeof import("vuetify/lib")["VExpansionPanel"] - VExpansionPanelContent: typeof import("vuetify/lib")["VExpansionPanelContent"] - VExpansionPanelHeader: typeof import("vuetify/lib")["VExpansionPanelHeader"] - VExpansionPanels: typeof import("vuetify/lib")["VExpansionPanels"] - VFlex: typeof import("vuetify/lib")["VFlex"] - VForm: typeof import("vuetify/lib")["VForm"] - VHover: typeof import("vuetify/lib")["VHover"] - VIcon: typeof import("vuetify/lib")["VIcon"] - VItem: typeof import("vuetify/lib")["VItem"] - VLayout: typeof import("vuetify/lib")["VLayout"] - VLazy: typeof import("vuetify/lib")["VLazy"] - VList: typeof import("vuetify/lib")["VList"] - VListGroup: typeof import("vuetify/lib")["VListGroup"] - VListItem: typeof import("vuetify/lib")["VListItem"] - VListItemAction: typeof import("vuetify/lib")["VListItemAction"] - VListItemAvatar: typeof import("vuetify/lib")["VListItemAvatar"] - VListItemContent: typeof import("vuetify/lib")["VListItemContent"] - VListItemGroup: typeof import("vuetify/lib")["VListItemGroup"] - VListItemIcon: typeof import("vuetify/lib")["VListItemIcon"] - VListItemSubtitle: typeof import("vuetify/lib")["VListItemSubtitle"] - VListItemTitle: typeof import("vuetify/lib")["VListItemTitle"] - VMain: typeof import("vuetify/lib")["VMain"] - VMenu: typeof import("vuetify/lib")["VMenu"] - VNavigationDrawer: typeof import("vuetify/lib")["VNavigationDrawer"] - VProgressLinear: typeof import("vuetify/lib")["VProgressLinear"] - VRadio: typeof import("vuetify/lib")["VRadio"] - VRadioGroup: typeof import("vuetify/lib")["VRadioGroup"] - VRow: typeof import("vuetify/lib")["VRow"] - VSelect: typeof import("vuetify/lib")["VSelect"] - VSheet: typeof import("vuetify/lib")["VSheet"] - VSimpleCheckbox: typeof import("vuetify/lib")["VSimpleCheckbox"] - VSnackbar: typeof import("vuetify/lib")["VSnackbar"] - VSpacer: typeof import("vuetify/lib")["VSpacer"] - VStepper: typeof import("vuetify/lib")["VStepper"] - VStepperContent: typeof import("vuetify/lib")["VStepperContent"] - VStepperHeader: typeof import("vuetify/lib")["VStepperHeader"] - VStepperItems: typeof import("vuetify/lib")["VStepperItems"] - VStepperStep: typeof import("vuetify/lib")["VStepperStep"] - VSubheader: typeof import("vuetify/lib")["VSubheader"] - VSwitch: typeof import("vuetify/lib")["VSwitch"] - VSystemBar: typeof import("vuetify/lib")["VSystemBar"] - VTab: typeof import("vuetify/lib")["VTab"] - VTabItem: typeof import("vuetify/lib")["VTabItem"] - VTabs: typeof import("vuetify/lib")["VTabs"] - VTabsItems: typeof import("vuetify/lib")["VTabsItems"] - VTextarea: typeof import("vuetify/lib")["VTextarea"] - VTextArea: typeof import("vuetify/lib")["VTextArea"] - VTextField: typeof import("vuetify/lib")["VTextField"] - VTimeline: typeof import("vuetify/lib")["VTimeline"] - VTimelineItem: typeof import("vuetify/lib")["VTimelineItem"] - VTimePicker: typeof import("vuetify/lib")["VTimePicker"] - VToolbarItems: typeof import("vuetify/lib")["VToolbarItems"] - VToolbarTitle: typeof import("vuetify/lib")["VToolbarTitle"] - VTooltip: typeof import("vuetify/lib")["VTooltip"] - VWindow: typeof import("vuetify/lib")["VWindow"] - VWindowItem: typeof import("vuetify/lib")["VWindowItem"] } } diff --git a/src/dispatch/static/dispatch/jsconfig.json b/src/dispatch/static/dispatch/jsconfig.json index e8b3494e07b5..b8d6842d7fad 100644 --- a/src/dispatch/static/dispatch/jsconfig.json +++ b/src/dispatch/static/dispatch/jsconfig.json @@ -4,4 +4,4 @@ "@/*": ["./src/*"] } } -} \ No newline at end of file +} diff --git a/src/dispatch/static/dispatch/public/static/robots.txt b/src/dispatch/static/dispatch/public/static/robots.txt index 6f27bb66a346..eb0536286f30 100644 --- a/src/dispatch/static/dispatch/public/static/robots.txt +++ b/src/dispatch/static/dispatch/public/static/robots.txt @@ -1,2 +1,2 @@ User-agent: * -Disallow: \ No newline at end of file +Disallow: diff --git a/src/dispatch/static/dispatch/src/case/CaseAttributesDrawer.vue b/src/dispatch/static/dispatch/src/case/CaseAttributesDrawer.vue index 3ce037f86e5b..631dc467bb9e 100644 --- a/src/dispatch/static/dispatch/src/case/CaseAttributesDrawer.vue +++ b/src/dispatch/static/dispatch/src/case/CaseAttributesDrawer.vue @@ -11,6 +11,7 @@ import DTooltip from "@/components/DTooltip.vue" import ParticipantSearchPopover from "@/participant/ParticipantSearchPopover.vue" import ProjectSearchPopover from "@/project/ProjectSearchPopover.vue" import TagSearchPopover from "@/tag/TagSearchPopover.vue" +import RichEditor from "@/components/RichEditor.vue" import { useSavingState } from "@/composables/useSavingState" // Define the props diff --git a/src/dispatch/static/dispatch/src/case/ClosedDialog.vue b/src/dispatch/static/dispatch/src/case/ClosedDialog.vue index 660765180fd1..4e89e9ddc2ef 100644 --- a/src/dispatch/static/dispatch/src/case/ClosedDialog.vue +++ b/src/dispatch/static/dispatch/src/case/ClosedDialog.vue @@ -19,11 +19,18 @@ /> - Resolution + + + + Cancel @@ -51,10 +58,15 @@ + + diff --git a/src/dispatch/static/dispatch/src/components/RichEditor.vue b/src/dispatch/static/dispatch/src/components/RichEditor.vue index 40ec57b99f28..d6e45e60191a 100644 --- a/src/dispatch/static/dispatch/src/components/RichEditor.vue +++ b/src/dispatch/static/dispatch/src/components/RichEditor.vue @@ -22,6 +22,7 @@ const props = defineProps({ }) const editor = ref(null) +const htmlValue = ref("") const plainTextValue = ref("") const emit = defineEmits(["update:modelValue"]) @@ -36,6 +37,12 @@ const handleBlur = () => { userIsTyping.value = false } +// Utility function to convert HTML to plain text +const htmlToPlainText = (html) => { + if (!html) return "" + return html.replace(/<\/?[^>]+(>|$)/g, "") +} + watch( () => props.content, (value) => { @@ -63,7 +70,7 @@ onMounted(() => { // Use different placeholders depending on the node type: // placeholder: ({ node }) => { // if (node.type.name === 'heading') { - // return 'What’s the title?' + // return 'What's the title?' // } // return 'Can you add some further context?' @@ -73,10 +80,12 @@ onMounted(() => { content: props.content, onUpdate: () => { let content = editor.value?.getHTML() - // remove the HTML tags - plainTextValue.value = content.replace(/<\/?[^>]+(>|$)/g, "") - // Emitting the updated plain text - emit("update:modelValue", plainTextValue.value) + // Preserve the HTML content to maintain formatting + htmlValue.value = content + // Also maintain plain text version for backwards compatibility + plainTextValue.value = htmlToPlainText(content) + // Emit the HTML content instead of stripped plain text + emit("update:modelValue", htmlValue.value) }, keyboardShortcuts: { Enter: () => {}, // Override Enter key to do nothing @@ -92,8 +101,8 @@ onBeforeUnmount(() => { editor.value?.destroy() }) -// Expose plainTextValue for parent component to use -defineExpose({ plainTextValue }) +// Expose both HTML and plain text values for parent component to use +defineExpose({ htmlValue, plainTextValue, htmlToPlainText })