Skip to content

Commit 27e8fc9

Browse files
committed
Add Mattermost on-call shift notification support:
- Introduced personal Mattermost notifications for on-call shift starts with configurable preferences. - Added deduplication table `OnCallShiftMattermostNotification` and migration scripts. - Extended Mattermost notifier to handle direct message notifications via bot API. - Updated user preferences UI to include Mattermost notifications. - Enhanced SSO workflows to support team roles in mapping configurations. - Adjusted related models, API endpoints, and templates to support Mattermost integration. - Incremented service version to `1.0.28-beta`.
1 parent ec7950c commit 27e8fc9

30 files changed

Lines changed: 1125 additions & 50 deletions

app/api/openapi/endpoints/sso.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from app.api.schemas.roles import GROUP_ROLE_VALUES, GROUP_VIEWER_ROLE
1+
from app.api.schemas.roles import GROUP_ROLE_VALUES, GROUP_VIEWER_ROLE, TEAM_ROLE_VALUES, TEAM_VIEWER_ROLE
22

33

44
def path_param(name, description):
@@ -168,6 +168,16 @@ def response(description, schema=None):
168168
"default": GROUP_VIEWER_ROLE,
169169
"example": "viewer",
170170
},
171+
"team_id": {"type": "integer", "nullable": True, "minimum": 1, "example": 10},
172+
"team_slug": {"type": "string", "nullable": True, "readOnly": True, "example": "cloud-api"},
173+
"team_name": {"type": "string", "nullable": True, "readOnly": True, "example": "Cloud API"},
174+
"team_role": {
175+
"type": "string",
176+
"nullable": True,
177+
"enum": list(TEAM_ROLE_VALUES),
178+
"default": TEAM_VIEWER_ROLE,
179+
"example": "responder",
180+
},
171181
"active": {"type": "boolean", "default": True},
172182
"priority": {"type": "integer", "default": 100, "minimum": 0, "maximum": 100000},
173183
"created_at": {"type": "string", "format": "date-time", "readOnly": True},

app/api/schemas/profile.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class ProfileUpdateSchema(ApiModel):
4141
)
4242
notify_oncall_shift_start_email: bool | None = None
4343
notify_oncall_shift_end_email: bool | None = None
44+
notify_oncall_shift_start_mattermost: bool | None = None
4445

4546
@field_validator("phone")
4647
@classmethod

app/api/schemas/sso.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
from pydantic import Field, field_validator, model_validator
22

33
from app.api.schemas.base import ApiModel
4-
from app.api.schemas.roles import GROUP_VIEWER_ROLE, GROUP_EDITOR_ROLE, GROUP_USER_ADMIN_ROLE, SSO_GLOBAL_ADMIN_ROLE
4+
from app.api.schemas.roles import (
5+
GROUP_VIEWER_ROLE,
6+
GROUP_EDITOR_ROLE,
7+
GROUP_USER_ADMIN_ROLE,
8+
SSO_GLOBAL_ADMIN_ROLE,
9+
TEAM_ROLE_VALUES,
10+
TEAM_VIEWER_ROLE,
11+
)
512
from app.modules.sso.saml_security import SsoExtraConfig
613

714
SSO_MAPPING_ROLES = {
@@ -166,6 +173,8 @@ class SsoGroupMappingCreateSchema(ApiModel):
166173
external_group: str = Field(min_length=1, max_length=100)
167174
group_id: int = Field(ge=1)
168175
group_role: str = Field(default=GROUP_VIEWER_ROLE, max_length=32)
176+
team_id: int | None = Field(default=None, ge=1)
177+
team_role: str | None = Field(default=TEAM_VIEWER_ROLE, max_length=32)
169178
active: bool = True
170179
priority: int = Field(default=100, ge=0, le=10000)
171180

@@ -180,6 +189,14 @@ def validate_mapping(self):
180189
if not self.group_id:
181190
raise ValueError("incidentrelay_group_id is required")
182191

192+
if self.team_id and (self.team_role or TEAM_VIEWER_ROLE) not in TEAM_ROLE_VALUES:
193+
raise ValueError("team_role must be viewer, responder or manager")
194+
195+
if not self.team_id:
196+
self.team_role = None
197+
elif not self.team_role:
198+
self.team_role = TEAM_VIEWER_ROLE
199+
183200
return self
184201

185202

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Add dedup table for personal Mattermost on-call shift notifications."""
2+
3+
from app.db import init_database
4+
from app.modules.db.models import OnCallShiftMattermostNotification
5+
6+
7+
db = init_database()
8+
9+
10+
def upgrade():
11+
"""Create Mattermost shift notification dedup table."""
12+
db.create_tables(
13+
[OnCallShiftMattermostNotification],
14+
safe=True,
15+
)
16+
17+
18+
def downgrade():
19+
"""Drop Mattermost shift notification dedup table."""
20+
db.drop_tables(
21+
[OnCallShiftMattermostNotification],
22+
safe=True,
23+
)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""Add personal preference for Mattermost on-call shift notifications."""
2+
3+
from peewee import BooleanField
4+
from playhouse.migrate import migrate
5+
6+
from app.db import init_database
7+
from app.modules.db.migrator import get_migrator
8+
from app.modules.db.models import User
9+
10+
11+
db = init_database()
12+
migrator = get_migrator(db)
13+
14+
15+
def table_has_column(table_name, column_name):
16+
"""Return True if table already has column."""
17+
return any(column.name == column_name for column in db.get_columns(table_name))
18+
19+
20+
def upgrade():
21+
"""Add Mattermost shift start notification preference to users."""
22+
user_table = User._meta.table_name
23+
24+
if table_has_column(user_table, "notify_oncall_shift_start_mattermost"):
25+
return
26+
27+
migrate(
28+
migrator.add_column(
29+
user_table,
30+
"notify_oncall_shift_start_mattermost",
31+
BooleanField(default=True),
32+
)
33+
)
34+
35+
36+
def downgrade():
37+
"""Remove Mattermost shift start notification preference from users."""
38+
user_table = User._meta.table_name
39+
40+
if not table_has_column(user_table, "notify_oncall_shift_start_mattermost"):
41+
return
42+
43+
migrate(
44+
migrator.drop_column(
45+
user_table,
46+
"notify_oncall_shift_start_mattermost",
47+
)
48+
)

app/modules/db/models.py

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ class User(SoftDeleteModel):
140140
mattermost_user_id = CharField(null=True)
141141
notify_oncall_shift_start_email = BooleanField(default=True)
142142
notify_oncall_shift_end_email = BooleanField(default=True)
143+
notify_oncall_shift_start_mattermost = BooleanField(default=True)
143144
password_hash = CharField(null=True)
144145
active = BooleanField(default=True)
145146
is_admin = BooleanField(default=False)
@@ -1908,6 +1909,41 @@ class Meta:
19081909
)
19091910

19101911

1912+
class OnCallShiftMattermostNotification(BaseModel):
1913+
"""Deduplication log for personal Mattermost on-call shift notifications."""
1914+
1915+
id = AutoField()
1916+
1917+
user = ForeignKeyField(User, backref="oncall_shift_mattermost_notifications", on_delete="CASCADE")
1918+
rotation = ForeignKeyField(Rotation, backref="oncall_shift_mattermost_notifications", on_delete="CASCADE")
1919+
1920+
event_type = CharField(index=True) # shift_start
1921+
1922+
slot_start_at = DateTimeField(index=True)
1923+
slot_end_at = DateTimeField(index=True)
1924+
1925+
layer_id = IntegerField(null=True)
1926+
override_id = IntegerField(null=True)
1927+
1928+
mattermost_user_id = CharField(index=True)
1929+
fingerprint = CharField(unique=True, index=True)
1930+
1931+
status = CharField(default="pending", index=True) # pending | sent | failed | skipped
1932+
last_error = TextField(null=True)
1933+
1934+
created_at = DateTimeField(default=datetime.utcnow)
1935+
sent_at = DateTimeField(null=True)
1936+
updated_at = DateTimeField(default=datetime.utcnow)
1937+
1938+
class Meta:
1939+
table_name = "oncall_shift_mattermost_notification"
1940+
indexes = (
1941+
(("user", "event_type", "slot_start_at", "slot_end_at"), False),
1942+
(("rotation", "event_type", "slot_start_at"), False),
1943+
(("mattermost_user_id", "event_type", "slot_start_at"), False),
1944+
)
1945+
1946+
19111947
class Silence(SoftDeleteModel):
19121948
"""Alert silence rule for a team."""
19131949

@@ -2033,7 +2069,7 @@ class Meta:
20332069

20342070

20352071
class SsoGroupMapping(BaseModel):
2036-
"""Map external SSO group value to IncidentRelay group role."""
2072+
"""Map external SSO group value to IncidentRelay group and optional team role."""
20372073

20382074
id = AutoField()
20392075
provider = ForeignKeyField(SsoProvider, backref="group_mappings", on_delete="CASCADE")
@@ -2046,6 +2082,13 @@ class SsoGroupMapping(BaseModel):
20462082
)
20472083

20482084
group_role = CharField(default="viewer")
2085+
incidentrelay_team = ForeignKeyField(
2086+
Team,
2087+
null=True,
2088+
backref="sso_group_mappings",
2089+
on_delete="CASCADE",
2090+
)
2091+
team_role = CharField(null=True)
20492092
active = BooleanField(default=True)
20502093
priority = IntegerField(default=100)
20512094

@@ -2055,7 +2098,7 @@ class SsoGroupMapping(BaseModel):
20552098
class Meta:
20562099
table_name = "sso_group_mapping"
20572100
indexes = (
2058-
(("provider", "external_group", "incidentrelay_group"), True),
2101+
(("provider", "external_group", "incidentrelay_group", "incidentrelay_team"), True),
20592102
)
20602103

20612104

app/modules/db/sso_repo.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from datetime import datetime
22

3-
from app.modules.db.models import Group, SsoGroupMapping, SsoIdentity, SsoProvider
3+
from app.modules.db.models import Group, SsoGroupMapping, SsoIdentity, SsoProvider, Team
44
from app.modules.sso.crypto import encrypt_secret
55
from app.modules.sso.saml_security import normalize_sso_extra_config
66

@@ -197,13 +197,32 @@ def get_group_mapping(mapping_id):
197197
return SsoGroupMapping.get_by_id(mapping_id)
198198

199199

200+
def _validate_group_mapping_team(data):
201+
"""Validate that optional SSO team mapping belongs to the selected group."""
202+
team_id = data.get("team_id")
203+
204+
if not team_id:
205+
return None
206+
207+
team = Team.get_by_id(team_id)
208+
209+
if int(team.group_id) != int(data["group_id"]):
210+
raise ValueError("team_id must belong to the selected IncidentRelay group")
211+
212+
return team
213+
214+
200215
def create_group_mapping(provider_id, data):
201216
"""Create SSO group mapping."""
217+
team = _validate_group_mapping_team(data)
218+
202219
return SsoGroupMapping.create(
203220
provider=provider_id,
204221
external_group=data["external_group"],
205222
incidentrelay_group=data["group_id"],
206223
group_role=data["group_role"],
224+
incidentrelay_team=team.id if team else None,
225+
team_role=data.get("team_role") if team else None,
207226
active=data.get("active", True),
208227
priority=data.get("priority", 100),
209228
)
@@ -212,10 +231,13 @@ def create_group_mapping(provider_id, data):
212231
def update_group_mapping(mapping_id, data):
213232
"""Update SSO group mapping."""
214233
mapping = get_group_mapping(mapping_id)
234+
team = _validate_group_mapping_team(data)
215235

216236
mapping.external_group = data["external_group"]
217237
mapping.incidentrelay_group = data["group_id"]
218238
mapping.group_role = data["group_role"]
239+
mapping.incidentrelay_team = team.id if team else None
240+
mapping.team_role = data.get("team_role") if team else None
219241
mapping.active = data.get("active", True)
220242
mapping.priority = data.get("priority", 100)
221243
mapping.updated_at = datetime.utcnow()

app/modules/db/users_repo.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ def update_user(user_id, data):
244244
"mattermost_user_id",
245245
"notify_oncall_shift_start_email",
246246
"notify_oncall_shift_end_email",
247+
"notify_oncall_shift_start_mattermost",
247248
"active",
248249
"is_admin",
249250
"password_hash",

app/modules/sso/sso_login.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
from app.db import database_proxy as db
99
from app.login import create_access_token
10-
from app.modules.db import groups_repo, users_repo
10+
from app.modules.db import groups_repo, teams_repo, users_repo
1111
from app.modules.db.models import SsoGroupMapping, SsoIdentity, User, UserGroup
1212
from app.settings import Config
1313
from app.api.schemas.limits import normalize_phone
@@ -344,6 +344,20 @@ def _effective_group_role(mapping_role: str) -> str:
344344
return mapping_role
345345

346346

347+
def _sync_team_membership_from_mapping(user, mapping):
348+
"""Sync optional IncidentRelay team membership from a matched SSO mapping."""
349+
team_id = getattr(mapping, "incidentrelay_team_id", None)
350+
351+
if not team_id:
352+
return
353+
354+
teams_repo.add_user_to_team(
355+
team_id=team_id,
356+
user_id=user.id,
357+
role=mapping.team_role or "viewer",
358+
)
359+
360+
347361
def _sync_group_memberships(user, provider, claims):
348362
"""Sync IncidentRelay group memberships from SSO group mappings."""
349363
if not provider.sync_group_memberships:
@@ -400,6 +414,8 @@ def _sync_group_memberships(user, provider, claims):
400414
role=_effective_group_role(mapping.group_role),
401415
)
402416

417+
_sync_team_membership_from_mapping(user, mapping)
418+
403419
matched_group_ids.append(group_id)
404420

405421
if provider.remove_missing_group_memberships and provider_group_ids:

0 commit comments

Comments
 (0)