Skip to content

Commit

Permalink
feat: change role from web ui (#21)
Browse files Browse the repository at this point in the history
  • Loading branch information
sanjeevan authored Jun 29, 2024
1 parent fc33490 commit cb203e2
Show file tree
Hide file tree
Showing 26 changed files with 509 additions and 90 deletions.
3 changes: 2 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion backend/app/models/incident_role.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
10 changes: 10 additions & 0 deletions backend/app/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
25 changes: 20 additions & 5 deletions backend/app/repos/incident_repo.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import Sequence
from dataclasses import dataclass
from typing import Literal, Sequence

from sqlalchemy import func, select

Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand All @@ -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 = (
Expand Down Expand Up @@ -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()
4 changes: 2 additions & 2 deletions backend/app/repos/timestamp_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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():
Expand Down
13 changes: 12 additions & 1 deletion backend/app/repos/user_repo.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
36 changes: 32 additions & 4 deletions backend/app/routes/incidents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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)
Expand All @@ -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()

Expand Down
22 changes: 22 additions & 0 deletions backend/app/routes/roles.py
Original file line number Diff line number Diff line change
@@ -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)
27 changes: 18 additions & 9 deletions backend/app/routes/users.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
7 changes: 6 additions & 1 deletion backend/app/schemas/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ class CreateTimestampSchema(BaseSchema):
description: str


class UpdateIncidentTimestampsSchema(BaseSchema):
class PatchIncidentTimestampsSchema(BaseSchema):
timezone: str
values: dict[str, datetime | None]

Expand Down Expand Up @@ -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
3 changes: 3 additions & 0 deletions backend/app/schemas/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
7 changes: 7 additions & 0 deletions backend/app/schemas/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
26 changes: 26 additions & 0 deletions backend/app/services/factories.py
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit cb203e2

Please sign in to comment.