diff --git a/src/dispatch/auth/models.py b/src/dispatch/auth/models.py
index 9cdce9b3f58f..db34de39e265 100644
--- a/src/dispatch/auth/models.py
+++ b/src/dispatch/auth/models.py
@@ -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."""
@@ -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):
@@ -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
diff --git a/src/dispatch/auth/service.py b/src/dispatch/auth/service.py
index a965222b6d83..eb3366e63994 100644
--- a/src/dispatch/auth/service.py
+++ b/src/dispatch/auth/service.py
@@ -29,11 +29,14 @@
DispatchUser,
DispatchUserOrganization,
DispatchUserProject,
+ DispatchUserSettings,
UserOrganization,
UserProject,
UserRegister,
UserUpdate,
UserCreate,
+ UserSettingsCreate,
+ UserSettingsUpdate,
)
@@ -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(
@@ -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)]
@@ -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
diff --git a/src/dispatch/auth/views.py b/src/dispatch/auth/views.py
index 9ae00c388615..3e7e6ee489ab 100644
--- a/src/dispatch/auth/views.py
+++ b/src/dispatch/auth/views.py
@@ -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__)
@@ -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")
@@ -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
diff --git a/src/dispatch/database/revisions/core/versions/2025-06-23_903183fd9aee.py b/src/dispatch/database/revisions/core/versions/2025-06-23_903183fd9aee.py
new file mode 100644
index 000000000000..642462a30295
--- /dev/null
+++ b/src/dispatch/database/revisions/core/versions/2025-06-23_903183fd9aee.py
@@ -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 ###
diff --git a/src/dispatch/incident/flows.py b/src/dispatch/incident/flows.py
index b34323d90fe1..3896a71209b6 100644
--- a/src/dispatch/incident/flows.py
+++ b/src/dispatch/incident/flows.py
@@ -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
@@ -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]]:
@@ -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
)
diff --git a/src/dispatch/individual/models.py b/src/dispatch/individual/models.py
index 979301b946f7..0597c02c0586 100644
--- a/src/dispatch/individual/models.py
+++ b/src/dispatch/individual/models.py
@@ -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(
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/plugins/dispatch_google/calendar/plugin.py b/src/dispatch/plugins/dispatch_google/calendar/plugin.py
index 3d4e8f0e4d26..49266717e832 100644
--- a/src/dispatch/plugins/dispatch_google/calendar/plugin.py
+++ b/src/dispatch/plugins/dispatch_google/calendar/plugin.py
@@ -146,6 +146,7 @@ def create(
description=description,
participants=participants,
title=title,
+ duration=self.configuration.default_duration_minutes,
)
meet_url = ""
diff --git a/src/dispatch/plugins/dispatch_google/config.py b/src/dispatch/plugins/dispatch_google/config.py
index c4c331529391..0768aad217f0 100644
--- a/src/dispatch/plugins/dispatch_google/config.py
+++ b/src/dispatch/plugins/dispatch_google/config.py
@@ -38,3 +38,8 @@ class GoogleConfiguration(BaseConfigurationModel):
title="Google Workspace Domain",
description="Base domain for which this Google Cloud Platform (GCP) service account resides.",
)
+ default_duration_minutes: int = Field(
+ default=1440, # 1 day
+ title="Default Event Duration (Minutes)",
+ description="Default duration in minutes for conference events. Defaults to 1440 minutes (1 day).",
+ )
diff --git a/src/dispatch/plugins/dispatch_zoom/config.py b/src/dispatch/plugins/dispatch_zoom/config.py
index d72d869af9fc..f47acaa92afe 100644
--- a/src/dispatch/plugins/dispatch_zoom/config.py
+++ b/src/dispatch/plugins/dispatch_zoom/config.py
@@ -9,3 +9,8 @@ class ZoomConfiguration(BaseConfigurationModel):
api_user_id: str = Field(title="Zoom API User Id")
api_key: str = Field(title="API Key")
api_secret: SecretStr = Field(title="API Secret")
+ default_duration_minutes: int = Field(
+ default=1440, # 1 day
+ title="Default Meeting Duration (Minutes)",
+ description="Default duration in minutes for conference meetings. Defaults to 1440 minutes (1 day).",
+ )
diff --git a/src/dispatch/plugins/dispatch_zoom/plugin.py b/src/dispatch/plugins/dispatch_zoom/plugin.py
index 5a352ff86a52..0cb24192f9cc 100644
--- a/src/dispatch/plugins/dispatch_zoom/plugin.py
+++ b/src/dispatch/plugins/dispatch_zoom/plugin.py
@@ -74,7 +74,7 @@ def create(
)
conference_response = create_meeting(
- client, self.configuration.api_user_id, name, description=description, title=title
+ client, self.configuration.api_user_id, name, description=description, title=title, duration=self.configuration.default_duration_minutes
)
conference_json = conference_response.json()
diff --git a/src/dispatch/static/dispatch/src/auth/api.js b/src/dispatch/static/dispatch/src/auth/api.js
index dc81d9f9b0df..2991efc20680 100644
--- a/src/dispatch/static/dispatch/src/auth/api.js
+++ b/src/dispatch/static/dispatch/src/auth/api.js
@@ -30,4 +30,10 @@ export default {
verifyMfa(payload) {
return API.post(`/auth/mfa`, payload)
},
+ getUserSettings() {
+ return API.get(`/auth/me/settings`)
+ },
+ updateUserSettings(payload) {
+ return API.put(`/auth/me/settings`, payload)
+ },
}
diff --git a/src/dispatch/static/dispatch/src/auth/store.js b/src/dispatch/static/dispatch/src/auth/store.js
index 20e25708d798..41bab3b1237f 100644
--- a/src/dispatch/static/dispatch/src/auth/store.js
+++ b/src/dispatch/static/dispatch/src/auth/store.js
@@ -158,6 +158,28 @@ const actions = {
console.error("Error occurred while updating experimental features: ", error)
})
},
+ refreshCurrentUser({ commit }) {
+ return UserApi.getUserInfo()
+ .then((response) => {
+ commit("SET_CURRENT_USER", response.data)
+ return response.data
+ })
+ .catch((error) => {
+ console.error("Error occurred while refreshing current user: ", error)
+ throw error
+ })
+ },
+ updateUserSettings({ commit }, settings) {
+ return UserApi.updateUserSettings(settings)
+ .then((response) => {
+ commit("SET_USER_SETTINGS", response.data)
+ return response.data
+ })
+ .catch((error) => {
+ console.error("Error occurred while updating user settings: ", error)
+ throw error
+ })
+ },
createExpirationCheck({ state, commit }) {
// expiration time minus 10 min
let expire_at = subMinutes(fromUnixTime(state.currentUser.exp), 10)
@@ -214,6 +236,12 @@ const mutations = {
SET_USER_PROJECTS(state, value) {
state.currentUser.projects = value
},
+ SET_USER_SETTINGS(state, value) {
+ state.currentUser.settings = value
+ },
+ SET_CURRENT_USER(state, value) {
+ state.currentUser = { ...state.currentUser, ...value }
+ },
}
const getters = {
diff --git a/src/dispatch/static/dispatch/src/auth/userSettings.js b/src/dispatch/static/dispatch/src/auth/userSettings.js
index 96a206d8c9a7..6ccd245de194 100644
--- a/src/dispatch/static/dispatch/src/auth/userSettings.js
+++ b/src/dispatch/static/dispatch/src/auth/userSettings.js
@@ -3,6 +3,9 @@ import UserApi from "./api"
function load() {
return UserApi.getUserInfo().then(function (response) {
+ // Update the full current user data including settings
+ store.commit("auth/SET_CURRENT_USER", response.data)
+ // Also update projects for backward compatibility
return store.commit("auth/SET_USER_PROJECTS", response.data.projects)
})
}
diff --git a/src/dispatch/static/dispatch/src/components/AppToolbar.vue b/src/dispatch/static/dispatch/src/components/AppToolbar.vue
index c270b7856059..82e46655f446 100644
--- a/src/dispatch/static/dispatch/src/components/AppToolbar.vue
+++ b/src/dispatch/static/dispatch/src/components/AppToolbar.vue
@@ -81,14 +81,22 @@
- Experimental Features
+ User Preferences
+
Organizations
@@ -164,6 +172,26 @@ export default {
return this.$store.state.query.q
},
},
+ bridgePreference: {
+ get() {
+ return this.currentUser()?.settings?.auto_add_to_incident_bridges ?? true
+ },
+ set(value) {
+ // Call Vuex action to update settings
+ this.$store
+ .dispatch("auth/updateUserSettings", {
+ auto_add_to_incident_bridges: value,
+ })
+ .then(() => {
+ console.log("Bridge preference updated to:", value)
+ })
+ .catch((error) => {
+ console.error("Error occurred while updating bridge preference: ", error)
+ // Refresh user data to ensure UI is in sync with server
+ this.$store.dispatch("auth/refreshCurrentUser")
+ })
+ },
+ },
},
methods: {
updateExperimentalFeatures() {