oncall-engine/engine/apps/slack/models/slack_team_identity.py
Joey Orlando 4a5c4263e0
feat: convert schedule.channel (char field) to schedule.slack_channel (foreign key) (#5199)
# What this PR does

`OnCallSchedule` equivalent of
https://github.com/grafana/oncall/pull/5191.

**NOTE**: merge after https://github.com/grafana/oncall/pull/5224 (so
that I can use some of the new serializer fields defined in there)

### Migration
```bash
Running migrations:                                                                                                                                                                                                │
│ source=engine:app google_trace_id=none logger=apps.schedules.migrations.0019_auto_20241021_1735 Starting migration to populate slack_channel field.                                                                │
│ source=engine:app google_trace_id=none logger=apps.schedules.migrations.0019_auto_20241021_1735 Total schedules to process: 1                                                                                      │
│ source=engine:app google_trace_id=none logger=apps.schedules.migrations.0019_auto_20241021_1735 Schedule 26 updated with SlackChannel 2 (slack_id: C043LL6RTS7).                                                   │
│ source=engine:app google_trace_id=none logger=apps.schedules.migrations.0019_auto_20241021_1735 Bulk updated 1 OnCallSchedules with their Slack channel.                                                           │
│ source=engine:app google_trace_id=none logger=apps.schedules.migrations.0019_auto_20241021_1735 Finished migration. Total schedules processed: 1. Schedules updated: 1. Missing SlackChannels: 0.                  │
│   Applying schedules.0019_auto_20241021_1735... OK
```

### Tested Public API
```txt
POST {{oncall_host}}/api/v1/schedules/
Authorization: {{oncall_api_key}}
Content-Type: application/json

{
    "name": "Demo testy testy2",
    "type": "web",
    "time_zone": "America/Los_Angeles",
    "slack": {
        "channel_id": "C05PPLYN1U1"
    }
}

HTTP/1.1 201 Created
Content-Type: application/json
Vary: Accept, Origin
Allow: GET, POST, HEAD, OPTIONS
X-Frame-Options: DENY
Content-Length: 198
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin

{
  "id": "SBBN73UTUTVCE",
  "team_id": null,
  "name": "Demo testy testy2",
  "time_zone": "America/Los_Angeles",
  "on_call_now": [],
  "shifts": [],
  "slack": {
    "channel_id": "C05PPLYN1U1",
    "user_group_id": null
  },
  "type": "web"
}
```

### Tested via UI (eg; internal API)

https://www.loom.com/share/e66bf3468b144dd782da5eb6e0bfd0af

## Checklist

- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] Added the relevant release notes label (see labels prefixed w/
`release:`). These labels dictate how your PR will
    show up in the autogenerated release notes.
2024-11-04 14:27:21 -05:00

160 lines
6.2 KiB
Python

import logging
import typing
from django.conf import settings
from django.db import models
from django.db.models import JSONField
from apps.api.permissions import RBACPermission
from apps.slack.client import SlackClient
from apps.slack.constants import SLACK_INVALID_AUTH_RESPONSE, SLACK_WRONG_TEAM_NAMES
from apps.slack.errors import (
SlackAPIChannelNotFoundError,
SlackAPIFetchMembersFailedError,
SlackAPIInvalidAuthError,
SlackAPITokenError,
)
from apps.user_management.models import Organization, User
if typing.TYPE_CHECKING:
from django.db.models.manager import RelatedManager
from apps.slack.models import SlackChannel, SlackUserGroup
logger = logging.getLogger(__name__)
class SlackTeamIdentity(models.Model):
cached_channels: "RelatedManager['SlackChannel']"
organizations: "RelatedManager[Organization]"
usergroups: "RelatedManager['SlackUserGroup']"
id = models.AutoField(primary_key=True)
slack_id = models.CharField(max_length=100)
cached_name = models.CharField(max_length=100, null=True, default=None)
cached_app_id = models.CharField(max_length=100, null=True, default=None)
access_token = models.CharField(max_length=255, null=True, default=None)
bot_user_id = models.CharField(max_length=100, null=True, default=None)
bot_access_token = models.CharField(max_length=255, null=True, default=None)
oauth_scope = models.TextField(max_length=30000, null=True, default=None)
detected_token_revoked = models.DateTimeField(null=True, default=None, verbose_name="Deleted At")
is_profile_populated = models.BooleanField(default=False)
datetime = models.DateTimeField(auto_now_add=True)
installed_via_granular_permissions = models.BooleanField(default=True)
installed_by = models.ForeignKey("SlackUserIdentity", on_delete=models.PROTECT, null=True, default=None)
last_populated = models.DateTimeField(null=True, default=None)
cached_bot_id = models.CharField(max_length=100, null=True, default=None)
# response after oauth.access. This field is used to reinstall app to another OnCall workspace
cached_reinstall_data = JSONField(null=True, default=None)
# Do not use directly, use the "needs_reinstall" property instead
_unified_slack_app_installed = models.BooleanField(null=True, default=False)
class Meta:
ordering = ("datetime",)
def __str__(self):
return f"{self.pk}: {self.name}"
def update_oauth_fields(self, user, organization, oauth_response):
logger.info(f"updated oauth_fields for sti {self.pk}")
from apps.slack.models import SlackUserIdentity
organization.slack_team_identity = self
organization.save(update_fields=["slack_team_identity"])
slack_user_identity, _ = SlackUserIdentity.objects.get_or_create(
slack_id=oauth_response["authed_user"]["id"],
slack_team_identity=self,
)
user.slack_user_identity = slack_user_identity
user.save(update_fields=["slack_user_identity"])
self.bot_access_token = oauth_response["access_token"]
self.bot_user_id = oauth_response["bot_user_id"]
self.oauth_scope = oauth_response["scope"]
self.cached_name = oauth_response["team"]["name"]
self.access_token = oauth_response["authed_user"]["access_token"]
self.installed_by = slack_user_identity
self.cached_reinstall_data = None
self.installed_via_granular_permissions = True
if settings.UNIFIED_SLACK_APP_ENABLED:
self._unified_slack_app_installed = True
self.save()
def get_cached_channels(self, search_term=None, slack_id=None):
queryset = self.cached_channels
if search_term is not None:
queryset = queryset.filter(name__startswith=search_term)
if slack_id is not None:
queryset = queryset.filter(slack_id=slack_id)
return queryset.all()
@property
def bot_id(self):
if self.cached_bot_id is None:
sc = SlackClient(self)
auth = sc.auth_test()
self.cached_bot_id = auth.get("bot_id")
self.save(update_fields=["cached_bot_id"])
return self.cached_bot_id
@property
def members(self):
sc = SlackClient(self)
next_cursor = None
members = []
while next_cursor != "" or next_cursor is None:
result = sc.users_list(cursor=next_cursor, team=self)
next_cursor = result["response_metadata"]["next_cursor"]
members += result["members"]
return members
@property
def name(self):
if self.cached_name is None or self.cached_name in SLACK_WRONG_TEAM_NAMES:
try:
sc = SlackClient(self)
result = sc.team_info()
self.cached_name = result["team"]["name"]
self.save()
except SlackAPIInvalidAuthError:
self.cached_name = SLACK_INVALID_AUTH_RESPONSE
self.save()
return self.cached_name
@property
def app_id(self):
if not self.cached_app_id:
sc = SlackClient(self)
result = sc.bots_info(bot=self.bot_id)
app_id = result["bot"]["app_id"]
self.cached_app_id = app_id
self.save(update_fields=["cached_app_id"])
return self.cached_app_id
@property
def needs_reinstall(self):
return settings.UNIFIED_SLACK_APP_ENABLED and not self._unified_slack_app_installed
def get_users_from_slack_conversation_for_organization(self, channel_id: str, organization: Organization):
sc = SlackClient(self)
return User.objects.filter_by_permission(
RBACPermission.Permissions.CHATOPS_WRITE,
organization,
slack_user_identity__slack_id__in=self.get_conversation_members(sc, channel_id),
)
def get_conversation_members(self, slack_client: SlackClient, channel_id: str):
try:
return slack_client.paginated_api_call(
"conversations_members", paginated_key="members", channel=channel_id
)["members"]
except (SlackAPITokenError, SlackAPIFetchMembersFailedError, SlackAPIChannelNotFoundError):
return []