From cb203e25b78275a7b49529668a985c306ab3d89a Mon Sep 17 00:00:00 2001 From: Sanjeevan Ambalavanar Date: Sat, 29 Jun 2024 09:32:17 +0100 Subject: [PATCH] feat: change role from web ui (#21) --- backend/app/main.py | 3 +- backend/app/models/incident_role.py | 2 +- backend/app/models/user.py | 10 + backend/app/repos/incident_repo.py | 25 ++- backend/app/repos/timestamp_repo.py | 4 +- backend/app/repos/user_repo.py | 13 +- backend/app/routes/incidents.py | 36 +++- backend/app/routes/roles.py | 22 +++ backend/app/routes/users.py | 27 ++- backend/app/schemas/actions.py | 7 +- backend/app/schemas/models.py | 3 + backend/app/schemas/tasks.py | 7 + backend/app/services/factories.py | 26 +++ backend/app/services/incident.py | 41 +++- backend/app/services/slack/command.py | 6 +- backend/app/tasks/__init__.py | 1 + backend/app/tasks/celerytasks.py | 8 + backend/app/tasks/create_announcement.py | 2 + backend/app/tasks/create_slack_message.py | 19 ++ backend/tests/schemas/test_actions.py | 6 +- frontend/src/pages/Incidents/Show.tsx | 185 ++++++++++++++---- .../EditTitleForm/EditTitleForm.tsx | 19 +- .../components/RoleForm/RoleForm.tsx | 69 +++++++ .../Timestamps/EditTimestampsForm.tsx | 26 +-- frontend/src/services/api.ts | 29 +++ frontend/src/types/models.ts | 3 + 26 files changed, 509 insertions(+), 90 deletions(-) create mode 100644 backend/app/routes/roles.py create mode 100644 backend/app/services/factories.py create mode 100644 backend/app/tasks/create_slack_message.py create mode 100644 frontend/src/pages/Incidents/components/RoleForm/RoleForm.tsx diff --git a/backend/app/main.py b/backend/app/main.py index 9c69644..04cf609 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,7 @@ from sqlalchemy.exc import NoResultFound from app.exceptions import ApplicationException, ErrorCodes, FormFieldValidationError -from app.routes import forms, health, incidents, organisations, severities, slack, timestamps, users, world +from app.routes import forms, health, incidents, organisations, roles, severities, slack, timestamps, users, world from app.utils import setup_logger from .env import settings @@ -36,6 +36,7 @@ def create_app() -> FastAPI: app.include_router(severities.router, prefix="/severities") app.include_router(timestamps.router, prefix="/timestamps") app.include_router(organisations.router, prefix="/organisations") + app.include_router(roles.router, prefix="/roles") # exception handler for form field validation errors @app.exception_handler(FormFieldValidationError) diff --git a/backend/app/models/incident_role.py b/backend/app/models/incident_role.py index 0c0525c..a910c37 100644 --- a/backend/app/models/incident_role.py +++ b/backend/app/models/incident_role.py @@ -22,7 +22,7 @@ class IncidentRole(Base, TimestampMixin, SoftDeleteMixin): ) name: Mapped[str] = mapped_column(UnicodeText, nullable=False) description: Mapped[str] = mapped_column(UnicodeText, nullable=False) - guide: Mapped[str] = mapped_column(UnicodeText, nullable=True) + guide: Mapped[str | None] = mapped_column(UnicodeText, nullable=True) slack_reference: Mapped[str] = mapped_column(UnicodeText, nullable=False) kind: Mapped[IncidentRoleKind] = mapped_column(Enum(IncidentRoleKind, native_enum=False), nullable=False) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 3cd0d90..bd05de1 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -71,8 +71,18 @@ def check_password(self, password: str) -> bool: return bcrypt.checkpw(password=password.encode("utf8"), hashed_password=self._password.encode("utf8")) def belongs_to(self, organisation: "Organisation") -> bool: + """Does user belong to the given organisation""" for org in self.organisations: if org.id == organisation.id: return True return False + + def belongs_to_any(self, organisations: list["Organisation"]) -> bool: + """Does this user belong to at least one of the given organisations""" + subject_org_ids = set([org.id for org in self.organisations]) + for org in organisations: + if org.id in subject_org_ids: + return True + + return False diff --git a/backend/app/repos/incident_repo.py b/backend/app/repos/incident_repo.py index 8b28df0..9b12298 100644 --- a/backend/app/repos/incident_repo.py +++ b/backend/app/repos/incident_repo.py @@ -1,4 +1,5 @@ -from typing import Sequence +from dataclasses import dataclass +from typing import Literal, Sequence from sqlalchemy import func, select @@ -21,6 +22,12 @@ from .base_repo import BaseRepo +@dataclass +class AssignRoleResult: + assignment: IncidentRoleAssignment + type: Literal["no_change"] | Literal["user_changed"] | Literal["new_assignment"] + + class IncidentRepo(BaseRepo): def get_incident_by_id(self, id: str) -> Incident | None: stmt = select(Incident).where(Incident.id == id).limit(1) @@ -181,12 +188,15 @@ def get_total_incidents(self, organisation: Organisation) -> int: return self.session.scalar(stmt) or 0 - def assign_role(self, incident: Incident, role: IncidentRole, user: User) -> IncidentRoleAssignment: + def assign_role(self, incident: Incident, role: IncidentRole, user: User) -> AssignRoleResult: # if that role has already been assigned, update the user for role_assignment in incident.incident_role_assignments: if role_assignment.incident_role.id == role.id: - role_assignment.user_id = user.id - return role_assignment + if role_assignment.user_id == user.id: + return AssignRoleResult(assignment=role_assignment, type="no_change") + else: + role_assignment.user_id = user.id + return AssignRoleResult(assignment=role_assignment, type="user_changed") # otherwise create a new role assignment model = IncidentRoleAssignment() @@ -197,7 +207,7 @@ def assign_role(self, incident: Incident, role: IncidentRole, user: User) -> Inc self.session.add(model) self.session.flush() - return model + return AssignRoleResult(assignment=model, type="new_assignment") def get_incident_role(self, organisation: Organisation, kind: IncidentRoleKind) -> IncidentRole | None: stmt = ( @@ -320,3 +330,8 @@ def get_incident_update_by_id(self, id: str) -> IncidentUpdate | None: stmt = select(IncidentUpdate).where(IncidentUpdate.id == id, IncidentUpdate.deleted_at.is_(None)) return self.session.scalar(stmt) + + def get_incident_role_by_id_or_raise(self, id: str) -> IncidentRole: + stmt = select(IncidentRole).where(IncidentRole.id == id).limit(1) + + return self.session.scalars(stmt).one() diff --git a/backend/app/repos/timestamp_repo.py b/backend/app/repos/timestamp_repo.py index 24b1a21..8b1c47d 100644 --- a/backend/app/repos/timestamp_repo.py +++ b/backend/app/repos/timestamp_repo.py @@ -5,7 +5,7 @@ from sqlalchemy import func, select from app.models import Incident, Organisation, Timestamp, TimestampKind, TimestampRule, TimestampValue -from app.schemas.actions import CreateTimestampSchema, PatchTimestampSchema, UpdateIncidentTimestampsSchema +from app.schemas.actions import CreateTimestampSchema, PatchIncidentTimestampsSchema, PatchTimestampSchema from .base_repo import BaseRepo @@ -123,7 +123,7 @@ def get_timestamp_by_label(self, organisation: Organisation, label: str) -> Time return self.session.scalar(stmt) - def bulk_update_incident_timestamps(self, incident: Incident, put_in: UpdateIncidentTimestampsSchema): + def bulk_update_incident_timestamps(self, incident: Incident, put_in: PatchIncidentTimestampsSchema): """Bulk update timetime values for an incident""" tz = pytz.timezone(put_in.timezone) for timestamp_id, naive_datetime in put_in.values.items(): diff --git a/backend/app/repos/user_repo.py b/backend/app/repos/user_repo.py index 1cd86e3..18005c5 100644 --- a/backend/app/repos/user_repo.py +++ b/backend/app/repos/user_repo.py @@ -1,9 +1,10 @@ import secrets +from typing import Sequence from sqlalchemy import select from app.exceptions import FormFieldValidationError -from app.models import User +from app.models import Organisation, OrganisationMember, User from app.schemas.actions import CreateUserSchema, CreateUserViaSlackSchema from .base_repo import BaseRepo @@ -60,3 +61,13 @@ def create_user(self, create_in: CreateUserSchema | CreateUserViaSlackSchema) -> def get_by_slack_user_id(self, slack_user_id: str) -> User | None: query = select(User).where(User.slack_user_id == slack_user_id).limit(1) return self.session.scalars(query).first() + + def get_all_users_in_organisation(self, organisation: Organisation) -> Sequence[User]: + """Get all users within organisation""" + stmt = ( + select(User) + .join(OrganisationMember) + .where(OrganisationMember.organisation_id == organisation.id, User.is_active.is_(True)) + ) + + return self.session.scalars(stmt).all() diff --git a/backend/app/routes/incidents.py b/backend/app/routes/incidents.py index 5465c03..b3a9114 100644 --- a/backend/app/routes/incidents.py +++ b/backend/app/routes/incidents.py @@ -6,16 +6,18 @@ from app.deps import CurrentOrganisation, CurrentUser, DatabaseSession, EventsService from app.exceptions import NotPermittedError from app.models import FormKind -from app.repos import AnnouncementRepo, FormRepo, IncidentRepo, TimestampRepo +from app.repos import AnnouncementRepo, FormRepo, IncidentRepo, TimestampRepo, UserRepo from app.schemas.actions import ( CreateIncidentSchema, IncidentSearchSchema, PaginationParamsSchema, PatchIncidentSchema, - UpdateIncidentTimestampsSchema, + PatchIncidentTimestampsSchema, + UpdateIncidentRoleSchema, ) from app.schemas.models import IncidentSchema, IncidentUpdateSchema from app.schemas.resources import PaginatedResults +from app.services.factories import create_incident_service from app.services.incident import IncidentService logger = structlog.get_logger(logger_name=__name__) @@ -130,7 +132,7 @@ async def incident_updates( @router.patch("/{id}/timestamps") async def incident_patch_timestamps( - id: str, db: DatabaseSession, user: CurrentUser, put_in: UpdateIncidentTimestampsSchema + id: str, db: DatabaseSession, user: CurrentUser, patch_in: PatchIncidentTimestampsSchema ): """Patch timestamps for an incident""" incident_repo = IncidentRepo(session=db) @@ -140,7 +142,33 @@ async def incident_patch_timestamps( if not user.belongs_to(incident.organisation): raise NotPermittedError() - timestamp_repo.bulk_update_incident_timestamps(incident=incident, put_in=put_in) + timestamp_repo.bulk_update_incident_timestamps(incident=incident, put_in=patch_in) + + db.commit() + + return None + + +@router.put("/{id}/roles/") +async def incident_update_role( + id: str, db: DatabaseSession, user: CurrentUser, put_in: UpdateIncidentRoleSchema, events: EventsService +): + """Set role for an incident""" + incident_repo = IncidentRepo(session=db) + user_repo = UserRepo(session=db) + incident = incident_repo.get_incident_by_id_or_raise(id) + + if not user.belongs_to(incident.organisation): + raise NotPermittedError() + + role_assignee = user_repo.get_by_id_or_raise(put_in.user.id) + role = incident_repo.get_incident_role_by_id_or_raise(put_in.role.id) + + if not user.belongs_to_any(role_assignee.organisations) or not user.belongs_to(role.organisation): + raise NotPermittedError() + + incident_service = create_incident_service(session=db, organisation=incident.organisation, events=events) + incident_service.assign_role(incident=incident, user=role_assignee, role=role) db.commit() diff --git a/backend/app/routes/roles.py b/backend/app/routes/roles.py new file mode 100644 index 0000000..35fe5c2 --- /dev/null +++ b/backend/app/routes/roles.py @@ -0,0 +1,22 @@ +import structlog +from fastapi import APIRouter + +from app.deps import CurrentOrganisation, CurrentUser, DatabaseSession +from app.repos import IncidentRepo +from app.schemas.models import IncidentRoleSchema +from app.schemas.resources import PaginatedResults + +logger = structlog.get_logger(logger_name=__name__) + +router = APIRouter(tags=["Severities"]) + + +@router.get("/search", response_model=PaginatedResults[IncidentRoleSchema]) +async def roles_search(user: CurrentUser, db: DatabaseSession, organisation: CurrentOrganisation): + """Search for all roles within the organisation""" + incident_repo = IncidentRepo(session=db) + + roles = incident_repo.get_all_incident_roles(organisation=organisation) + total = len(roles) + + return PaginatedResults(total=total, page=1, size=total, items=roles) diff --git a/backend/app/routes/users.py b/backend/app/routes/users.py index ff49094..f862865 100644 --- a/backend/app/routes/users.py +++ b/backend/app/routes/users.py @@ -1,13 +1,11 @@ -from fastapi import APIRouter, Depends -from sqlalchemy.orm import Session +from fastapi import APIRouter -from app.auth import get_current_user -from app.db import get_db +from app.deps import CurrentOrganisation, CurrentUser, DatabaseSession from app.exceptions import ErrorCodes, FormFieldValidationError, ValidationError -from app.models import User from app.repos import AnnouncementRepo, FormRepo, IncidentRepo, OrganisationRepo, SeverityRepo, TimestampRepo, UserRepo from app.schemas.actions import AuthUserSchema, CreateUserSchema -from app.schemas.models import UserSchema +from app.schemas.models import UserPublicSchema, UserSchema +from app.schemas.resources import PaginatedResults from app.services.identity import IdentityService from app.services.login import LoginError, LoginService from app.services.onboarding import OnboardingService @@ -17,7 +15,7 @@ @router.post("", response_model=UserSchema) -def user_register(create_in: CreateUserSchema, session: Session = Depends(get_db)): +def user_register(create_in: CreateUserSchema, session: DatabaseSession): """Create a new user account""" user_repo = UserRepo(session=session) organisation_repo = OrganisationRepo(session=session) @@ -48,7 +46,7 @@ def user_register(create_in: CreateUserSchema, session: Session = Depends(get_db @router.post("/auth", response_model=UserSchema) -def authenticate_user(item: AuthUserSchema, db: Session = Depends(get_db)): +def authenticate_user(item: AuthUserSchema, db: DatabaseSession): """Auth user""" repo = UserRepo(db) security_service = SecurityService(db) @@ -77,7 +75,18 @@ def authenticate_user(item: AuthUserSchema, db: Session = Depends(get_db)): @router.get("/me", response_model=UserSchema) def me( - user: User = Depends(get_current_user), + user: CurrentUser, ): """Get current user""" return user + + +@router.get("/search", response_model=PaginatedResults[UserPublicSchema]) +def users_search(user: CurrentUser, db: DatabaseSession, organisation: CurrentOrganisation): + """Get all users in the organisation""" + user_repo = UserRepo(session=db) + + users = user_repo.get_all_users_in_organisation(organisation=organisation) + total = len(users) + + return PaginatedResults(total=total, page=1, size=total, items=users) diff --git a/backend/app/schemas/actions.py b/backend/app/schemas/actions.py index 3f0ccac..e91b296 100644 --- a/backend/app/schemas/actions.py +++ b/backend/app/schemas/actions.py @@ -109,7 +109,7 @@ class CreateTimestampSchema(BaseSchema): description: str -class UpdateIncidentTimestampsSchema(BaseSchema): +class PatchIncidentTimestampsSchema(BaseSchema): timezone: str values: dict[str, datetime | None] @@ -159,3 +159,8 @@ def _validate_template_tags( raise ValueError(error_message.format(key, ", ".join(allowed_tags))) return v + + +class UpdateIncidentRoleSchema(BaseSchema): + role: ModelIdSchema + user: ModelIdSchema diff --git a/backend/app/schemas/models.py b/backend/app/schemas/models.py index 1239730..dd0291d 100644 --- a/backend/app/schemas/models.py +++ b/backend/app/schemas/models.py @@ -62,6 +62,9 @@ class IncidentSeveritySchema(ModelSchema): class IncidentRoleSchema(ModelSchema): name: str kind: str + description: str + guide: str | None + slack_reference: str class IncidentRoleAssignmentSchema(ModelSchema): diff --git a/backend/app/schemas/tasks.py b/backend/app/schemas/tasks.py index 4f21a3d..f3c8558 100644 --- a/backend/app/schemas/tasks.py +++ b/backend/app/schemas/tasks.py @@ -52,3 +52,10 @@ class IncidentStatusUpdatedTaskParameters(BaseModel): incident_id: str new_status_id: str old_status_id: str + + +class CreateSlackMessageTaskParameters(BaseModel): + organisation_id: str + channel_id: str + message: str + text: str | None = None diff --git a/backend/app/services/factories.py b/backend/app/services/factories.py new file mode 100644 index 0000000..63b8a9f --- /dev/null +++ b/backend/app/services/factories.py @@ -0,0 +1,26 @@ +from sqlalchemy.orm import Session + +from app.models import Organisation +from app.repos import AnnouncementRepo, IncidentRepo +from app.services.events import Events +from app.services.incident import IncidentService + + +def create_incident_service( + session: Session, organisation: Organisation, events: Events | None = None +) -> IncidentService: + """Create a new incident service""" + incident_repo = IncidentRepo(session=session) + + if not events: + events = Events() + + announcement_repo = AnnouncementRepo(session=session) + incident_service = IncidentService( + organisation=organisation, + incident_repo=incident_repo, + announcement_repo=announcement_repo, + events=events, + ) + + return incident_service diff --git a/backend/app/services/incident.py b/backend/app/services/incident.py index 3d86da6..9e7a7a6 100644 --- a/backend/app/services/incident.py +++ b/backend/app/services/incident.py @@ -2,13 +2,23 @@ import structlog -from app.models import Incident, IncidentRoleKind, IncidentSeverity, IncidentStatus, IncidentType, Organisation, User +from app.models import ( + Incident, + IncidentRole, + IncidentRoleKind, + IncidentSeverity, + IncidentStatus, + IncidentType, + Organisation, + User, +) from app.repos import AnnouncementRepo, IncidentRepo from app.schemas.actions import CreateIncidentSchema, ExtendedPatchIncidentSchema, PatchIncidentSchema from app.schemas.tasks import ( CreateAnnouncementTaskParameters, CreateIncidentUpdateParameters, CreatePinnedMessageTaskParameters, + CreateSlackMessageTaskParameters, IncidentDeclaredTaskParameters, IncidentStatusUpdatedTaskParameters, InviteUserToChannelParams, @@ -214,3 +224,32 @@ def patch_incident(self, user: User, incident: Incident, patch_in: PatchIncident ) self.incident_repo.patch_incident(incident=incident, patch_in=patch_in) + + def assign_role(self, incident: Incident, user: User, role: IncidentRole): + """Assign a role to a user""" + + assign_result = self.incident_repo.assign_role(incident=incident, role=role, user=user) + + # if user has not been changed, we don't need to do anything + if assign_result.type == "no_change": + return + + public_message = f"<@{user.slack_user_id}> has been assigned as {role.name} for this incident" + + if incident.slack_channel_id: + self.events.queue_job( + CreateSlackMessageTaskParameters( + organisation_id=incident.organisation.id, + message=public_message, + channel_id=incident.slack_channel_id, + ) + ) + else: + logger.error("slack channel id not set for incident", incident=incident.id) + + # will add lead info to bookmarks + self.events.queue_job( + SyncBookmarksTaskParameters( + incident_id=incident.id, + ) + ) diff --git a/backend/app/services/slack/command.py b/backend/app/services/slack/command.py index 0e90c11..5a03755 100644 --- a/backend/app/services/slack/command.py +++ b/backend/app/services/slack/command.py @@ -142,11 +142,7 @@ def assign_lead(self, command: SlackCommandDataSchema, params: list[str]): if not user: raise InvalidUsageError(f"Could not find user {params[0]}", command) - self.incident_repo.assign_role(incident=incident, role=role, user=user) - logger.info("Assigned role", role=IncidentRoleKind.LEAD, user=user.id) - - public_message = f"<@{user.slack_user_id}> has been assigned as {role.name} for this incident" - self.slack_client.chat_postMessage(channel=command.channel_id, user=user.slack_user_id, text=public_message) + self.incident_service.assign_role(incident=incident, user=user, role=role) # TODO: send a ephemeral message to the user who has the new role about what the expectations are for the role diff --git a/backend/app/tasks/__init__.py b/backend/app/tasks/__init__.py index 497aeb1..2ab444f 100644 --- a/backend/app/tasks/__init__.py +++ b/backend/app/tasks/__init__.py @@ -2,6 +2,7 @@ from .create_announcement import CreateAnnouncementTask from .create_incident_update import CreateIncidentUpdateTask from .create_pinned_message import CreatePinnedMessageTask +from .create_slack_message import CreateSlackMessageTask from .incident_declared import IncidentDeclaredTask from .incident_status_updated import IncidentStatusUpdatedTask from .invite_user_to_channel import InviteUserToChannelTask diff --git a/backend/app/tasks/celerytasks.py b/backend/app/tasks/celerytasks.py index fca9a3a..5458c00 100644 --- a/backend/app/tasks/celerytasks.py +++ b/backend/app/tasks/celerytasks.py @@ -3,6 +3,7 @@ CreateAnnouncementTaskParameters, CreateIncidentUpdateParameters, CreatePinnedMessageTaskParameters, + CreateSlackMessageTaskParameters, HandleSlashCommandTaskParameters, IncidentDeclaredTaskParameters, IncidentStatusUpdatedTaskParameters, @@ -15,6 +16,7 @@ CreateAnnouncementTask, CreateIncidentUpdateTask, CreatePinnedMessageTask, + CreateSlackMessageTask, HandleSlashCommandTask, IncidentDeclaredTask, IncidentStatusUpdatedTask, @@ -85,3 +87,9 @@ def incident_declared(params: IncidentDeclaredTaskParameters): def incident_status_updated(params: IncidentStatusUpdatedTaskParameters): with session_factory() as session: IncidentStatusUpdatedTask(session=session).execute(parameters=params) + + +@celery.task +def create_slack_message(params: CreateSlackMessageTaskParameters): + with session_factory() as session: + CreateSlackMessageTask(session=session).execute(parameters=params) diff --git a/backend/app/tasks/create_announcement.py b/backend/app/tasks/create_announcement.py index 219e43a..61199c1 100644 --- a/backend/app/tasks/create_announcement.py +++ b/backend/app/tasks/create_announcement.py @@ -35,6 +35,7 @@ def execute(self, parameters: "CreateAnnouncementTaskParameters"): # update channel id if channel_id != incident.organisation.settings.slack_announcement_channel_id: incident.organisation.settings.slack_announcement_channel_id = channel_id + logger.info("Updated announcement channel id", channel_id=channel_id) # app should join the announcements channel client.conversations_join(channel=channel_id) @@ -79,6 +80,7 @@ def create_channel_if_not_exists(self, client: WebClient, channel_name: str) -> return channels[channel_name] # create new channel, or use existing one + logger.info("Creating new announcements channel", channel_name=channel_name) channel_create_response = client.conversations_create(name=channel_name) channel_id = channel_create_response.get("channel", dict()).get("id") # type: ignore return channel_id diff --git a/backend/app/tasks/create_slack_message.py b/backend/app/tasks/create_slack_message.py new file mode 100644 index 0000000..7dee09c --- /dev/null +++ b/backend/app/tasks/create_slack_message.py @@ -0,0 +1,19 @@ +from slack_sdk import WebClient + +from app.repos import OrganisationRepo +from app.schemas.tasks import CreateSlackMessageTaskParameters + +from .base import BaseTask + + +class CreateSlackMessageTask(BaseTask["CreateSlackMessageTaskParameters"]): + def execute(self, parameters: "CreateSlackMessageTaskParameters"): + organisation_repo = OrganisationRepo(session=self.session) + organisation = organisation_repo.get_by_id_or_raise(id=parameters.organisation_id) + + client = WebClient(token=organisation.slack_bot_token) + + client.chat_postMessage( + channel=parameters.channel_id, + text=parameters.message, + ) diff --git a/backend/tests/schemas/test_actions.py b/backend/tests/schemas/test_actions.py index db77715..65eaed4 100644 --- a/backend/tests/schemas/test_actions.py +++ b/backend/tests/schemas/test_actions.py @@ -1,14 +1,14 @@ import pytest from pydantic import ValidationError -from app.schemas.actions import UpdateIncidentTimestampsSchema +from app.schemas.actions import PatchIncidentTimestampsSchema def test_update_incident_timezone_schema_invalid(): with pytest.raises(ValidationError, match="Invalid timezone"): - UpdateIncidentTimestampsSchema(timezone="xxx", values={}) + PatchIncidentTimestampsSchema(timezone="xxx", values={}) def test_update_incident_timezone_schema_valid_timezone(): - schema = UpdateIncidentTimestampsSchema(timezone="Europe/London", values={}) + schema = PatchIncidentTimestampsSchema(timezone="Europe/London", values={}) assert schema.timezone is not None diff --git a/frontend/src/pages/Incidents/Show.tsx b/frontend/src/pages/Incidents/Show.tsx index d95b179..867ee70 100644 --- a/frontend/src/pages/Incidents/Show.tsx +++ b/frontend/src/pages/Incidents/Show.tsx @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query' import { format } from 'date-fns' -import { MouseEvent, useCallback } from 'react' +import { MouseEvent, useCallback, useMemo } from 'react' import { useParams } from 'react-router-dom' import { toast } from 'react-toastify' import styled from 'styled-components' @@ -15,6 +15,8 @@ import MiniAvatar from '@/components/User/MiniAvatar' import useApiService from '@/hooks/useApi' import useGlobal from '@/hooks/useGlobal' import { APIError } from '@/services/transport' +import { IncidentRoleKind } from '@/types/enums' +import { IIncidentRole } from '@/types/models' import { rankSorter } from '@/utils/sort' import { getLocalTimeZone } from '@/utils/time' @@ -25,6 +27,7 @@ import ChangeStatusForm, { FormValues as ChangeStatusFormValues } from './compon import EditDescriptionForm, { FormValues } from './components/EditDescriptionForm/EditDescriptionForm' import EditTitleForm, { FormValues as ChangeNameFormValues } from './components/EditTitleForm/EditTitleForm' import Timeline from './components/IncidentUpdate/Timeline' +import RoleForm, { FormValues as RoleFormValues } from './components/RoleForm/RoleForm' import EditTimestampsForm, { FormValues as TimestampFormValues } from './components/Timestamps/EditTimestampsForm' const Description = styled.div` @@ -34,9 +37,11 @@ const Description = styled.div` const Field = styled.div` display: flex; padding: 1rem 0 0.5rem 1rem; + align-items: center; ` const FieldName = styled.div` width: 90px; + margin-right: 1rem; ` const FieldValue = styled.div` display: flex; @@ -44,24 +49,30 @@ const FieldValue = styled.div` ` const ModalContainer = styled.div` padding: 1rem; + min-width: 400px; + max-width: 400px; ` const FlatButton = styled.button` border: none; padding: 0.25rem 1rem; + border-radius: var(--radius-md); + cursor: pointer; + background-color: var(--color-gray-100); &:hover { background-color: var(--color-gray-200); } ` const SidebarHeader = styled.div` - padding: 1rem 0 0 1rem; - font-weight: 500; - color: var(--color-gray-400); + padding: 2rem 0 0 1rem; + font-weight: 600; + color: var(--color-gray-600); display: flex; & > :first-child { width: 90px; + margin-right: 1rem; } ` const RelatedFields = styled.div` @@ -70,7 +81,15 @@ const RelatedFields = styled.div` } ` -const FlatEdit = styled.a`` +const InnerButtonContent = styled.div` + display: flex; + gap: 8px; +` +const PaddedValue = styled.div` + display: flex; + gap: 8px; + padding: 0.25rem 1rem; +` type UrlParams = { id: string @@ -94,6 +113,18 @@ const ShowIncident = () => { queryFn: () => apiService.getIncidentUpdates(id) }) + // Fetch roles available for the organisation + const rolesQuery = useQuery({ + queryKey: ['roles', organisation!.id], + queryFn: () => apiService.getRoles() + }) + + // Fetch roles available for the organisation + const usersQuery = useQuery({ + queryKey: ['users', organisation!.id], + queryFn: () => apiService.getUsers() + }) + // Change description of this incident const handleChangeDescription = useCallback( async (values: FormValues) => { @@ -128,6 +159,19 @@ const ShowIncident = () => { closeModal() } + const handleSetRole = async (values: RoleFormValues, role: IIncidentRole) => { + try { + await apiService.setUserRole(incidentQuery.data!, values.user, role) + await incidentQuery.refetch() + closeModal() + } catch (e) { + if (e instanceof APIError) { + toast(e.detail, { type: 'error' }) + } + console.error(e) + } + } + // Show modal when edit status is clicked const handleEditStatus = (evt: MouseEvent) => { evt.preventDefault() @@ -197,7 +241,7 @@ const ShowIncident = () => { ) const handleShowEditTimestampsModal = useCallback( - (evt: MouseEvent) => { + (evt: MouseEvent) => { evt.preventDefault() setModal( @@ -214,7 +258,33 @@ const ShowIncident = () => { [incidentQuery.data, setModal, handleUpdateTimestampValues] ) - const slackUrl = `slack://channel?team=${organisation?.slackTeamId}&id=${incidentQuery.data?.slackChannelId}` + const createShowAssignRoleFormHandler = (role: IIncidentRole) => { + return async (evt: MouseEvent) => { + evt.preventDefault() + if (!incidentQuery.data) { + console.error('Incident has not been loaded yet') + return + } + setModal( + +

Assign role

+ {usersQuery.isSuccess && incidentQuery.data ? ( + handleSetRole(values, role)} + /> + ) : null} +
+ ) + } + } + + const slackUrl = useMemo( + () => `slack://channel?team=${organisation?.slackTeamId}&id=${incidentQuery.data?.slackChannelId}`, + [organisation, incidentQuery.data?.slackChannelId] + ) return ( <> @@ -242,49 +312,80 @@ const ShowIncident = () => { )} - - Slack - - - Open channel - - - - - Status - - - {incidentQuery.data.incidentStatus.name} - - - - - Severity - - - {incidentQuery.data.incidentSeverity.name} - - - - - Type - {incidentQuery.data.incidentType.name} - - {incidentQuery.data.incidentRoleAssignments.map((it) => ( - - {it.incidentRole.name} + + + Slack - {it.user.name} + + Open channel + - ))} + + Status + + + {incidentQuery.data.incidentStatus.name} + + + + + Severity + + + {incidentQuery.data.incidentSeverity.name} + + + + + Type + + {incidentQuery.data.incidentType.name} + + + + + Roles + + {rolesQuery.data?.items.map((role) => { + const assignment = incidentQuery.data.incidentRoleAssignments.find( + (it) => it.incidentRole.id === role.id + ) + return ( + + {role.name} + + {assignment ? ( + <> + {assignment.incidentRole.kind === IncidentRoleKind.REPORTER ? ( + + {assignment.user.name} + + ) : ( + + + {assignment.user.name} + + + )} + + ) : ( + + Assign role + + )} + + + ) + })} +
Timestamps
- + - +
diff --git a/frontend/src/pages/Incidents/components/EditTitleForm/EditTitleForm.tsx b/frontend/src/pages/Incidents/components/EditTitleForm/EditTitleForm.tsx index 5d750c7..bad2822 100644 --- a/frontend/src/pages/Incidents/components/EditTitleForm/EditTitleForm.tsx +++ b/frontend/src/pages/Incidents/components/EditTitleForm/EditTitleForm.tsx @@ -1,4 +1,4 @@ -import { createRef, KeyboardEvent, useCallback } from 'react' +import { createRef, KeyboardEvent, useCallback, useState } from 'react' import styled from 'styled-components' import { IIncident } from '@/types/models' @@ -19,18 +19,29 @@ export interface FormValues { const EditTitleForm: React.FC = ({ incident, onSubmit }) => { const ref = createRef() + const [lastGoodContentState, setLastGoodContentState] = useState(incident.name) const saveContents = useCallback(() => { const contents = ref.current?.innerHTML + + // don't allow title to be empty + if (!contents || contents.trim() === '') { + // restore last good state + ref.current?.append(lastGoodContentState) + return + } + onSubmit({ - name: contents ?? '' + name: contents }) - }, [ref, onSubmit]) + setLastGoodContentState(contents) + }, [ref, onSubmit, setLastGoodContentState, lastGoodContentState]) const handleChange = (evt: KeyboardEvent) => { if (evt.code == 'Enter') { evt.preventDefault() // we don't want newlines in the title saveContents() + ref.current?.blur() } } @@ -44,7 +55,7 @@ const EditTitleForm: React.FC = ({ incident, onSubmit }) => { contentEditable onBlur={handleBlur} onKeyDown={handleChange} - dangerouslySetInnerHTML={{ __html: incident.name }} + dangerouslySetInnerHTML={{ __html: lastGoodContentState }} > ) } diff --git a/frontend/src/pages/Incidents/components/RoleForm/RoleForm.tsx b/frontend/src/pages/Incidents/components/RoleForm/RoleForm.tsx new file mode 100644 index 0000000..65bc6c7 --- /dev/null +++ b/frontend/src/pages/Incidents/components/RoleForm/RoleForm.tsx @@ -0,0 +1,69 @@ +import { Form, Formik, FormikHelpers } from 'formik' +import { useCallback, useMemo } from 'react' +import * as Yup from 'yup' + +import SelectField from '@/components/Form/SelectField' +import { Button } from '@/components/Theme/Styles' +import { IIncident, IIncidentRole, IPublicUser, ModelID } from '@/types/models' + +interface Props { + incident: IIncident + role: IIncidentRole + users: Array + onSubmit: (values: FormValues, helpers: FormikHelpers) => void +} + +export interface FormValues { + user: IPublicUser +} + +export interface InternalFormValues { + userId: ModelID +} + +const validationSchema = Yup.object().shape({ + userId: Yup.string().required('Please select a user') +}) + +const RoleForm: React.FC = ({ users, onSubmit, role, incident }) => { + const options = useMemo(() => users.map((it) => ({ label: it.name, value: it.id })), [users]) + const currentAssignment = incident.incidentRoleAssignments.find((it) => it.incidentRole.id === role.id) + const defaultValues = { + userId: currentAssignment ? currentAssignment.user.id : ('' as ModelID) + } + const handleSubmit = useCallback( + (values: InternalFormValues, helpers: FormikHelpers) => { + const user = users.find((it) => it.id === values.userId) + if (!user) { + throw Error('Could not find user') + } + + onSubmit({ user }, helpers) + }, + [onSubmit, users] + ) + + return ( + + validationSchema={validationSchema} + initialValues={defaultValues} + onSubmit={handleSubmit} + > + {({ isSubmitting }) => ( +
+ +
+ +
+
+ +
+
+ )} + + ) +} + +export default RoleForm diff --git a/frontend/src/pages/Incidents/components/Timestamps/EditTimestampsForm.tsx b/frontend/src/pages/Incidents/components/Timestamps/EditTimestampsForm.tsx index 5dcb5f3..665d390 100644 --- a/frontend/src/pages/Incidents/components/Timestamps/EditTimestampsForm.tsx +++ b/frontend/src/pages/Incidents/components/Timestamps/EditTimestampsForm.tsx @@ -76,17 +76,21 @@ const EditTimestampsForm: React.FC = ({ incident, onSubmit }) => { ) })} -

Custom timestamps

- {grouped['custom'].map((it) => { - return ( -
- -
- -
-
- ) - })} + {grouped['custom'].length > 0 ? ( + <> +

Custom timestamps

+ {grouped['custom'].map((it) => { + return ( +
+ +
+ +
+
+ ) + })} + + ) : null} ) : (
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 2e89aca..224b000 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -2,6 +2,7 @@ import { PaginatedResults } from '@/types/core' import { IncidentStatusCategory } from '@/types/enums' import { IIncident, + IIncidentRole, IIncidentSeverity, IIncidentUpdate, ILoggedInUser, @@ -211,4 +212,32 @@ export class ApiService { json: values }) } + + getRoles = () => { + return callApi>('GET', `/roles/search`, { + user: this.user, + headers: { + [ORGANISATION_HEADER_KEY]: this.organisation + } + }) + } + + getUsers = () => { + return callApi>('GET', `/users/search`, { + user: this.user, + headers: { + [ORGANISATION_HEADER_KEY]: this.organisation + } + }) + } + + setUserRole = (incident: IIncident, user: IPublicUser, role: IIncidentRole) => { + return callApi('PUT', `/incidents/${incident.id}/roles`, { + user: this.user, + json: { + user: { id: user.id }, + role: { id: role.id } + } + }) + } } diff --git a/frontend/src/types/models.ts b/frontend/src/types/models.ts index 9b9d8e5..551d65d 100644 --- a/frontend/src/types/models.ts +++ b/frontend/src/types/models.ts @@ -98,6 +98,9 @@ export interface IIncidentSeverity extends IModel { export interface IIncidentRole extends IModel { name: string kind: IncidentRoleKind + description: string + guide: string + slackReference: string } export interface IIncidentRoleAssignment extends IModel {