Skip to content
This repository was archived by the owner on Sep 3, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/dispatch/auth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,18 @@ class DispatchUserProject(Base, TimeStampMixin):
role = Column(String, nullable=False, default=UserRoles.member)


class DispatchUserSettings(Base, TimeStampMixin):
"""SQLAlchemy model for user settings."""

__table_args__ = {"schema": "dispatch_core"}

id = Column(Integer, primary_key=True)
dispatch_user_id = Column(Integer, ForeignKey(DispatchUser.id), unique=True)
dispatch_user = relationship(DispatchUser, backref="settings")

auto_add_to_incident_bridges = Column(Boolean, default=True)


class UserProject(DispatchBase):
"""Pydantic model for a user's project membership."""

Expand Down Expand Up @@ -202,6 +214,7 @@ class UserRead(UserBase):
id: PrimaryKey
role: str | None = None
experimental_features: bool | None = None
settings: "UserSettingsRead | None" = None


class UserUpdate(DispatchBase):
Expand Down Expand Up @@ -321,3 +334,28 @@ class MfaPayload(DispatchBase):
action: str
project_id: int
challenge_id: str


class UserSettingsBase(DispatchBase):
"""Base Pydantic model for user settings."""

auto_add_to_incident_bridges: bool = True


class UserSettingsRead(UserSettingsBase):
"""Pydantic model for reading user settings."""

id: PrimaryKey | None = None
dispatch_user_id: PrimaryKey | None = None


class UserSettingsUpdate(UserSettingsBase):
"""Pydantic model for updating user settings."""

pass


class UserSettingsCreate(UserSettingsBase):
"""Pydantic model for creating user settings."""

dispatch_user_id: PrimaryKey
56 changes: 54 additions & 2 deletions src/dispatch/auth/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,14 @@
DispatchUser,
DispatchUserOrganization,
DispatchUserProject,
DispatchUserSettings,
UserOrganization,
UserProject,
UserRegister,
UserUpdate,
UserCreate,
UserSettingsCreate,
UserSettingsUpdate,
)


Expand Down Expand Up @@ -152,7 +155,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(
Expand Down Expand Up @@ -264,12 +268,14 @@ def get_current_user(request: Request) -> DispatchUser:
)
raise InvalidCredentialException

return get_or_create(
user = get_or_create(
db_session=request.state.db,
organization=request.state.organization,
user_in=UserRegister(email=user_email),
)

return user


CurrentUser = Annotated[DispatchUser, Depends(get_current_user)]

Expand All @@ -279,3 +285,49 @@ def get_current_role(
) -> UserRoles:
"""Attempts to get the current user depending on the configured authentication provider."""
return current_user.get_organization_role(organization_slug=request.state.organization)


def get_user_settings(*, db_session, user_id: int) -> DispatchUserSettings | None:
"""Get user settings for a specific user."""
return (
db_session.query(DispatchUserSettings)
.filter(DispatchUserSettings.dispatch_user_id == user_id)
.one_or_none()
)


def get_or_create_user_settings(*, db_session, user_id: int) -> DispatchUserSettings:
"""Get or create user settings for a specific user."""
settings = get_user_settings(db_session=db_session, user_id=user_id)

if not settings:
settings_in = UserSettingsCreate(dispatch_user_id=user_id)
settings = create_user_settings(db_session=db_session, settings_in=settings_in)

return settings


def create_user_settings(*, db_session, settings_in: UserSettingsCreate) -> DispatchUserSettings:
"""Create user settings."""
settings = DispatchUserSettings(**settings_in.model_dump())
db_session.add(settings)
db_session.commit()
db_session.refresh(settings)
return settings


def update_user_settings(
*,
db_session,
settings: DispatchUserSettings,
settings_in: UserSettingsUpdate,
) -> DispatchUserSettings:
"""Update user settings."""
settings_data = settings_in.model_dump(exclude_unset=True)

for field, value in settings_data.items():
setattr(settings, field, value)

db_session.commit()
db_session.refresh(settings)
return settings
52 changes: 50 additions & 2 deletions src/dispatch/auth/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,17 @@
UserUpdate,
UserPasswordUpdate,
AdminPasswordReset,
UserSettingsRead,
UserSettingsUpdate,
)
from .service import (
get,
get_by_email,
update,
create,
get_or_create_user_settings,
update_user_settings,
)
from .service import get, get_by_email, update, create


log = logging.getLogger(__name__)
Expand Down Expand Up @@ -264,7 +273,20 @@ def get_me(
db_session: DbSession,
current_user: CurrentUser,
):
return current_user
# Get user settings and include in response
user_settings = get_or_create_user_settings(db_session=db_session, user_id=current_user.id)

# Create a response dict that includes settings
response_data = {
"id": current_user.id,
"email": current_user.email,
"projects": current_user.projects,
"organizations": current_user.organizations,
"experimental_features": current_user.experimental_features,
"settings": user_settings,
}

return response_data


@auth_router.get("/myrole")
Expand Down Expand Up @@ -380,6 +402,32 @@ def mfa_check(
log.info("MFA check completed")


@auth_router.get("/me/settings", response_model=UserSettingsRead)
def get_my_settings(
*,
db_session: DbSession,
current_user: CurrentUser,
):
"""Get current user's settings."""
settings = get_or_create_user_settings(db_session=db_session, user_id=int(current_user.id))
return settings


@auth_router.put("/me/settings", response_model=UserSettingsRead)
def update_my_settings(
*,
db_session: DbSession,
current_user: CurrentUser,
settings_in: UserSettingsUpdate,
):
"""Update current user's settings."""
settings = get_or_create_user_settings(db_session=db_session, user_id=int(current_user.id))
updated_settings = update_user_settings(
db_session=db_session, settings=settings, settings_in=settings_in
)
return updated_settings


if DISPATCH_AUTH_REGISTRATION_ENABLED:
register_user = auth_router.post("/register", response_model=UserRegisterResponse)(
register_user
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Add dispatch_user_settings table

Revision ID: 903183fd9aee
Revises: ed0b0388fa3f
Create Date: 2025-06-23 11:27:01.615306

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = '903183fd9aee'
down_revision = 'ed0b0388fa3f'
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
_ = op.create_table('dispatch_user_settings',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('dispatch_user_id', sa.Integer(), nullable=True),
sa.Column('auto_add_to_incident_bridges', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['dispatch_user_id'], ['dispatch_core.dispatch_user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('dispatch_user_id'),
schema='dispatch_core'
)
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('dispatch_user_settings', schema='dispatch_core')
# ### end Alembic commands ###
28 changes: 27 additions & 1 deletion src/dispatch/incident/flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from dispatch.incident_cost import service as incident_cost_service
from dispatch.individual import service as individual_service
from dispatch.individual.models import IndividualContact
from dispatch.auth import service as auth_service
from dispatch.participant import flows as participant_flows
from dispatch.participant import service as participant_service
from dispatch.participant.models import Participant
Expand Down Expand Up @@ -58,6 +59,26 @@
log = logging.getLogger(__name__)


def filter_participants_for_bridge(participant_emails: list[str], project_id: int, db_session: Session) -> list[str]:
"""Filter participant emails to only include those who have opted into bridge participation."""
filtered_emails = []
for email in participant_emails:
# Get the dispatch user by email
dispatch_user = auth_service.get_by_email(db_session=db_session, email=email)
if dispatch_user:
# Get or create user settings
user_settings = auth_service.get_or_create_user_settings(
db_session=db_session, user_id=dispatch_user.id
)
# Check if user has opted into bridge participation
if user_settings.auto_add_to_incident_bridges:
filtered_emails.append(email)
else:
# If no dispatch user found, default to adding them (they can't opt out without a user account)
filtered_emails.append(email)
return filtered_emails


def get_incident_participants(
incident: Incident, db_session: Session
) -> tuple[list[IndividualContact | None], list[TeamContact | None]]:
Expand Down Expand Up @@ -224,10 +245,15 @@ def incident_create_resources(
# we create the conference room
if not incident.conference:
# we only include individuals that are directly participating in the
# resolution of the incident
# resolution of the incident and have opted into bridge participation
conference_participants = tactical_participant_emails
if incident.tactical_group:
conference_participants = [incident.tactical_group.email]
else:
# filter participants based on their bridge participation preferences
conference_participants = filter_participants_for_bridge(
tactical_participant_emails, incident.project.id, db_session
)
conference_flows.create_conference(
incident=incident, participants=conference_participants, db_session=db_session
)
Expand Down
1 change: 1 addition & 0 deletions src/dispatch/individual/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ class IndividualContactReadMinimal(DispatchBase):
title: str | None = None
weblink: str | None = None
external_id: str | None = None
auto_add_to_incident_bridges: bool | None = True

# Ensure validation is turned off for tests
model_config = ConfigDict(
Expand Down
18 changes: 10 additions & 8 deletions src/dispatch/individual/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 11 additions & 9 deletions src/dispatch/individual/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def get_individual(db_session: DbSession, individual_contact_id: PrimaryKey):
"msg": "Individual not found.",
"input": individual_contact_id,
}
]
],
)
return individual

Expand All @@ -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)


Expand All @@ -88,7 +90,7 @@ def update_individual(
"msg": "Individual not found.",
"input": individual_contact_id,
}
]
],
)
return update(
db_session=db_session,
Expand Down Expand Up @@ -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)
1 change: 1 addition & 0 deletions src/dispatch/plugins/dispatch_google/calendar/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ def create(
description=description,
participants=participants,
title=title,
duration=self.configuration.default_duration_minutes,
)

meet_url = ""
Expand Down
Loading
Loading