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() {