From b2549c8ec465b36a3436dc92da326ecf5abe33ff Mon Sep 17 00:00:00 2001 From: adamlphillips Date: Thu, 27 Jun 2019 12:39:02 +0100 Subject: [PATCH 1/8] Add actions and external identifier for response users --- core/admin.py | 4 +- core/migrations/0004_create_ExternalUser.py | 29 ++++++ .../migrations/0005_alter_use_ExternalUser.py | 69 ++++++++++++++ core/models/__init__.py | 2 + core/models/action.py | 17 ++++ core/models/incident.py | 7 +- core/models/user_external.py | 16 ++++ core/serializers.py | 43 +++++++++ core/urls.py | 48 ++++++++-- response/settings/base.py | 2 + slack/action_handlers.py | 9 +- slack/dialog_handlers.py | 23 ++++- slack/incident_commands.py | 37 +++++++- slack/migrations/0003_auto_20190624_1422.py | 91 +++++++++++++++++++ slack/models/comms_channel.py | 3 + slack/models/headline_post.py | 4 +- slack/models/pinned_message.py | 10 +- slack/models/user_stats.py | 15 +-- slack/slack_utils.py | 5 + slack/workflows/statuspage/action_handler.py | 2 +- slack/workflows/statuspage/dialog_handler.py | 2 +- .../workflows/statuspage/incident_command.py | 2 +- slack/workflows/statuspage/workflow.py | 10 +- 23 files changed, 407 insertions(+), 43 deletions(-) create mode 100644 core/migrations/0004_create_ExternalUser.py create mode 100644 core/migrations/0005_alter_use_ExternalUser.py create mode 100644 core/models/action.py create mode 100644 core/models/user_external.py create mode 100644 core/serializers.py create mode 100644 slack/migrations/0003_auto_20190624_1422.py diff --git a/core/admin.py b/core/admin.py index e95351a6..018658ef 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,4 +1,6 @@ from django.contrib import admin -from core.models import Incident +from core.models import Incident, Action, ExternalUser +admin.site.register(Action) admin.site.register(Incident) +admin.site.register(ExternalUser) diff --git a/core/migrations/0004_create_ExternalUser.py b/core/migrations/0004_create_ExternalUser.py new file mode 100644 index 00000000..e719f50c --- /dev/null +++ b/core/migrations/0004_create_ExternalUser.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2.2 on 2019-06-24 14:22 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('core', '0003_incidentextension'), + ] + + operations = [ + migrations.CreateModel( + name='ExternalUser', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('app_id', models.CharField(max_length=50)), + ('external_id', models.CharField(max_length=50)), + ('display_name', models.CharField(max_length=50)), + ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('owner', 'app_id')}, + }, + ), + ] diff --git a/core/migrations/0005_alter_use_ExternalUser.py b/core/migrations/0005_alter_use_ExternalUser.py new file mode 100644 index 00000000..4f796c94 --- /dev/null +++ b/core/migrations/0005_alter_use_ExternalUser.py @@ -0,0 +1,69 @@ +# Generated by Django 2.2.2 on 2019-06-24 14:22 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +def MoveToExternalID(apps, schema_editor): + Incident = apps.get_model('core', 'Incident') + ExternalUser = apps.get_model('core', 'ExternalUser') + + for inc in Incident.objects.all(): + inc.reporter = ExternalUser.objects.get(external_id=inc.reporter_tmp) + if inc.lead_tmp: + inc.lead = ExternalUser.objects.get(external_id=inc.lead_tmp) + inc.save() + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('core', '0004_create_ExternalUser'), + ('slack','0003_auto_20190624_1422'), + ] + + operations = [ + migrations.RenameField( + model_name='incident', + old_name='lead', + new_name='lead_tmp', + ), + migrations.RenameField( + model_name='incident', + old_name='reporter', + new_name='reporter_tmp', + ), + migrations.AddField( + model_name='incident', + name='lead', + field=models.ForeignKey(blank=True, help_text='Who is leading?', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='lead', to='core.ExternalUser'), + preserve_default=False, + ), + migrations.AddField( + model_name='incident', + name='reporter', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='reporter', to='core.ExternalUser'), + preserve_default=False, + ), + migrations.RunPython(MoveToExternalID), + migrations.RemoveField( + model_name='incident', + name='reporter_tmp', + ), + migrations.RemoveField( + model_name='incident', + name='lead_tmp', + ), + migrations.CreateModel( + name='Action', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('details', models.TextField(blank=True, default='')), + ('done', models.BooleanField(default=False)), + ('incident', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.Incident')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.ExternalUser')), + ], + ), + ] diff --git a/core/models/__init__.py b/core/models/__init__.py index d0100384..3ace633a 100644 --- a/core/models/__init__.py +++ b/core/models/__init__.py @@ -1 +1,3 @@ +from .action import * from .incident import * +from .user_external import * diff --git a/core/models/action.py b/core/models/action.py new file mode 100644 index 00000000..d263221d --- /dev/null +++ b/core/models/action.py @@ -0,0 +1,17 @@ +from datetime import datetime +from django.db import models +from core.models.incident import Incident +from core.models.user_external import ExternalUser + + +class Action(models.Model): + details = models.TextField(blank=True, default="") + done = models.BooleanField(default=False) + incident = models.ForeignKey(Incident, on_delete=models.CASCADE) + user = models.ForeignKey(ExternalUser, on_delete=models.CASCADE, blank=False, null=False) + + def icon(self): + return "🔜️" + + def __str__(self): + return f"{self.details}" diff --git a/core/models/incident.py b/core/models/incident.py index 6d16a5c4..b8de1f30 100644 --- a/core/models/incident.py +++ b/core/models/incident.py @@ -1,6 +1,6 @@ from datetime import datetime from django.db import models - +from core.models.user_external import ExternalUser class IncidentManager(models.Manager): def create_incident(self, report, reporter, report_time, summary=None, impact=None, lead=None, severity=None): @@ -23,7 +23,7 @@ class Incident(models.Model): # Reporting info report = models.CharField(max_length=200) - reporter = models.CharField(max_length=50, default="") + reporter = models.ForeignKey(ExternalUser, related_name='reporter', on_delete=models.PROTECT, blank=False, null=True,) report_time = models.DateTimeField() start_time = models.DateTimeField(null=False) @@ -32,7 +32,8 @@ class Incident(models.Model): # Additional info summary = models.TextField(blank=True, null=True, help_text="What's the high level summary?") impact = models.TextField(blank=True, null=True, help_text="What impact is this having?") - lead = models.CharField(max_length=50, blank=True, null=True, help_text="Who is leading?") + lead = models.ForeignKey(ExternalUser, related_name='lead', on_delete=models.PROTECT, blank=True, null=True, help_text="Who is leading?") + # Severity SEVERITIES = ( diff --git a/core/models/user_external.py b/core/models/user_external.py new file mode 100644 index 00000000..88745809 --- /dev/null +++ b/core/models/user_external.py @@ -0,0 +1,16 @@ +from datetime import datetime +from django.db import models +from django.contrib.auth.models import User + + +class ExternalUser(models.Model): + class Meta: + unique_together = ("owner", "app_id") + + owner = models.ForeignKey(User, on_delete=models.PROTECT, null=True, blank=True) + app_id = models.CharField(max_length=50, blank=False, null=False) + external_id = models.CharField(max_length=50, blank=False, null=False) + display_name = models.CharField(max_length=50, blank=False, null=False) + + def __str__(self): + return f'{self.display_name or self.external_id} ({self.app_id})' \ No newline at end of file diff --git a/core/serializers.py b/core/serializers.py new file mode 100644 index 00000000..1c5f968b --- /dev/null +++ b/core/serializers.py @@ -0,0 +1,43 @@ +from rest_framework import serializers +from rest_framework.decorators import action + +from core.models.incident import Incident +from core.models.action import Action +from core.models.user_external import ExternalUser + +from django.contrib.auth.models import User + + +class ExternalUserSerializer(serializers.HyperlinkedModelSerializer): + owner = serializers.PrimaryKeyRelatedField(queryset=User.objects.all(), required=False) + + class Meta: + model = ExternalUser + fields = ('app_id', 'external_id', 'owner', 'display_name') + + +class ActionSerializer(serializers.HyperlinkedModelSerializer): + # Serializers define the API representation. + incident = serializers.PrimaryKeyRelatedField(queryset=Incident.objects.all(), required=False) + user = serializers.PrimaryKeyRelatedField(queryset=ExternalUser.objects.all(), required=False) + + class Meta: + model = Action + fields = ('pk', 'details', 'done', 'incident', 'user') + + +class IncidentSerializer(serializers.HyperlinkedModelSerializer): + reporter = serializers.PrimaryKeyRelatedField(queryset=ExternalUser.objects.all(), required=False) + lead = serializers.PrimaryKeyRelatedField(queryset=ExternalUser.objects.all(), required=False) + + class Meta: + model = Incident + fields = ('pk','report', 'reporter', 'lead', 'start_time', 'end_time', 'report_time', 'action_set') + + def __init__(self, *args, **kwargs): + super(IncidentSerializer, self).__init__(*args, **kwargs) + request = kwargs['context']['request'] + expand = request.GET.get('expand', "").split(',') + + if 'actions' in expand: + self.fields['action_set'] = ActionSerializer(many=True, read_only=True) diff --git a/core/urls.py b/core/urls.py index 9ea7e59c..e3341486 100644 --- a/core/urls.py +++ b/core/urls.py @@ -1,25 +1,57 @@ from django.conf.urls import url, include -from rest_framework import routers, serializers, viewsets +from rest_framework import routers, viewsets, pagination +from rest_framework.decorators import action from core.models.incident import Incident +from core.models.action import Action +from core.models.user_external import ExternalUser +from datetime import datetime +from calendar import monthrange -class IncidentSerializer(serializers.HyperlinkedModelSerializer): - # Serializers define the API representation. - class Meta: - model = Incident - fields = ('report', 'reporter', 'report_time') +from core.serializers import * + +class ExternalUserViewSet(viewsets.ModelViewSet): + # ViewSets define the view behavior. + queryset = ExternalUser.objects.all() + serializer_class = ExternalUserSerializer + + +class ActionViewSet(viewsets.ModelViewSet): + # ViewSets define the view behavior. + queryset = Action.objects.all() + serializer_class = ActionSerializer +# Will return the incidents of the current month +# Can pass ?start=2019-05-28&end=2019-06-03 to change range class IncidentViewSet(viewsets.ModelViewSet): # ViewSets define the view behavior. - queryset = Incident.objects.all() + serializer_class = IncidentSerializer + pagination_class = None # Remove pagination + + def get_queryset(self): + # Same query is used to get single items so we check if pk is passed + # incident/2/ if we use the filter below we would have to have correct time range + if 'pk' in self.kwargs: + return Incident.objects.filter(pk=self.kwargs['pk']) + + today = datetime.today() + first_day_of_current_month = datetime(today.year, today.month, 1) + days_in_month = monthrange(today.year, today.month)[1] + last_day_of_current_month = datetime(today.year, today.month, days_in_month) + + start = self.request.GET.get('start', first_day_of_current_month) + end = self.request.GET.get('end', last_day_of_current_month) + return Incident.objects.filter(start_time__gte=start, start_time__lte=end) # Routers provide an easy way of automatically determining the URL conf. router = routers.DefaultRouter() -router.register(r'incidents', IncidentViewSet) +router.register(r'incidents', IncidentViewSet, base_name='Incidents') +router.register(r'actions', ActionViewSet) +router.register(r'ExternalUser', ExternalUserViewSet) # Wire up our API using automatic URL routing. # Additionally, we include login URLs for the browsable API. diff --git a/response/settings/base.py b/response/settings/base.py index ba264cd9..d22d2c9e 100644 --- a/response/settings/base.py +++ b/response/settings/base.py @@ -137,6 +137,8 @@ # https://www.django-rest-framework.org/ REST_FRAMEWORK = { + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', + 'PAGE_SIZE': 100, # Use Django's standard `django.contrib.auth` permissions, # or allow read-only access for unauthenticated users. 'DEFAULT_PERMISSION_CLASSES': [ diff --git a/slack/action_handlers.py b/slack/action_handlers.py index 2a298a08..cb501be7 100644 --- a/slack/action_handlers.py +++ b/slack/action_handlers.py @@ -11,6 +11,8 @@ from slack.decorators import action_handler, ActionContext +import logging +logger = logging.getLogger(__name__) @action_handler(HeadlinePost.CLOSE_INCIDENT_BUTTON) def handle_close_incident(ac: ActionContext): @@ -26,7 +28,10 @@ def handle_create_comms_channel(ac: ActionContext): comms_channel = CommsChannel.objects.create_comms_channel(ac.incident) # Invite the bot to the channel - invite_user_to_channel(settings.INCIDENT_BOT_ID, comms_channel.channel_id) + try: + invite_user_to_channel(settings.INCIDENT_BOT_ID, comms_channel.channel_id) + except Exception as ex: + logger.error(ex) # Un-invite the user who owns the Slack token, # otherwise they'll be added to every incident channel @@ -52,7 +57,7 @@ def handle_edit_incident_button(ac: ActionContext): Text(label="Report", name="report", value=ac.incident.report), TextArea(label="Summary", name="summary", value=ac.incident.summary, optional=True, placeholder="Can you share any more details?"), TextArea(label="Impact", name="impact", value=ac.incident.impact, optional=True, placeholder="Who or what might be affected?", hint="Think about affected people, systems, and processes"), - SelectFromUsers(label="Lead", name="lead", value=ac.incident.lead, optional=True), + SelectFromUsers(label="Lead", name="lead", value=ac.incident.lead.external_id, optional=True), SelectWithOptions([(i, s.capitalize()) for i, s in Incident.SEVERITIES], value=ac.incident.severity, label="Severity", name="severity", optional=True) ] ) diff --git a/slack/dialog_handlers.py b/slack/dialog_handlers.py index 2804a082..46858c51 100644 --- a/slack/dialog_handlers.py +++ b/slack/dialog_handlers.py @@ -4,10 +4,10 @@ from django.conf import settings from slack.settings import INCIDENT_EDIT_DIALOG, INCIDENT_REPORT_DIALOG -from core.models.incident import Incident +from core.models.incident import Incident, ExternalUser from slack.models import HeadlinePost, CommsChannel from slack.decorators import dialog_handler -from slack.slack_utils import send_ephemeral_message, channel_reference +from slack.slack_utils import send_ephemeral_message, channel_reference, get_user_profile, GetOrCreateSlackExternalUser import logging logger = logging.getLogger(__name__) @@ -18,12 +18,20 @@ def report_incident(user_id: str, channel_id: str, submission: json, response_ur report = submission['report'] summary = submission['summary'] impact = submission['impact'] - lead = submission['lead'] + lead_id = submission['lead'] severity = submission['severity'] + name = get_user_profile(user_id)['name'] + reporter = GetOrCreateSlackExternalUser(external_id=user_id, display_name=name) + + lead = None + if lead_id: + lead_name = get_user_profile(lead_id)['name'] + lead = GetOrCreateSlackExternalUser(external_id=lead_id, display_name=lead_name) + Incident.objects.create_incident( report=report, - reporter=user_id, + reporter=reporter, report_time=datetime.now(), summary=summary, impact=impact, @@ -41,9 +49,14 @@ def edit_incident(user_id: str, channel_id: str, submission: json, response_url: report = submission['report'] summary = submission['summary'] impact = submission['impact'] - lead = submission['lead'] + lead_id = submission['lead'] severity = submission['severity'] + lead = None + if lead_id: + lead_name = get_user_profile(lead_id)['name'] + lead = GetOrCreateSlackExternalUser(external_id=lead_id, display_name=lead_name) + try: incident = Incident.objects.get(pk=state) diff --git a/slack/incident_commands.py b/slack/incident_commands.py index 8c285e8f..d8ef907f 100644 --- a/slack/incident_commands.py +++ b/slack/incident_commands.py @@ -1,8 +1,8 @@ -from core.models import Incident +from core.models import Incident, Action, ExternalUser from slack.models import CommsChannel from slack.decorators import incident_command, get_help -from slack.slack_utils import reference_to_id, rename_channel, SlackError - +from slack.slack_utils import reference_to_id, get_user_profile, rename_channel, SlackError, GetOrCreateSlackExternalUser +from datetime import datetime @incident_command(['help'], helptext='Display a list of commands and usage') def send_help_text(incident: Incident, user_id: str, message: str): @@ -25,8 +25,10 @@ def update_impact(incident: Incident, user_id: str, message: str): @incident_command(['lead'], helptext='Assign someone as the incident lead') def set_incident_lead(incident: Incident, user_id: str, message: str): - assignee = reference_to_id(message) - incident.lead = assignee or user_id + assignee = reference_to_id(message) or user_id + name = get_user_profile(assignee)['name'] + user = GetOrCreateSlackExternalUser(external_id=assignee, display_name=name) + incident.lead = user incident.save() return True, None @@ -61,3 +63,28 @@ def set_severity(incident: Incident, user_id: str, message: str): comms_channel.post_in_channel(f"The incident has been running for {duration}") return True, None + + +@incident_command(['close'], helptext='Close this incident.') +def close_incident(incident: Incident, user_id: str, message: str): + comms_channel = CommsChannel.objects.get(incident=incident) + + if incident.is_closed(): + comms_channel.post_in_channel(f"This incident was already closed at {incident.end_time.strftime('%Y-%m-%d %H:%M:%S')}") + return True, None + + incident.end_time = datetime.now() + incident.save() + + comms_channel.post_in_channel(f"This incident has been closed! 📖 -> 📕") + + return True, None + + +@incident_command(['action'], helptext='Log a follow up action') +def set_action(incident: Incident, user_id: str, message: str): + comms_channel = CommsChannel.objects.get(incident=incident) + name = get_user_profile(user_id)['name'] + action_reporter = GetOrCreateSlackExternalUser(external_id=user_id, display_name=name) + Action(incident=incident, details=message, user=action_reporter).save() + return True, None diff --git a/slack/migrations/0003_auto_20190624_1422.py b/slack/migrations/0003_auto_20190624_1422.py new file mode 100644 index 00000000..4baace3f --- /dev/null +++ b/slack/migrations/0003_auto_20190624_1422.py @@ -0,0 +1,91 @@ +# Generated by Django 2.2.2 on 2019-06-24 14:22 + +import django.db.models.deletion +from django.db import migrations, models +from slack.slack_utils import get_user_profile +from django.db.models import F + + +def PopulateExternalUser(apps, schema_editor): + Incident = apps.get_model('core', 'Incident') + ExternalUser = apps.get_model('core', 'ExternalUser') + + UserStats = apps.get_model('slack', 'UserStats') + PinnedMessage = apps.get_model('slack', 'PinnedMessage') + + user_id_list = [ x['userid'] for x in Incident.objects.all().values(userid=F('reporter')) + .union(Incident.objects.all().values(userid=F('lead'))) + .union(UserStats.objects.all().values(userid=F('user_id'))) + .union(PinnedMessage.objects.all().values(userid=F('author_id'))) if x['userid'] ] + + for user_id in user_id_list: + ExternalID, created = ExternalUser.objects.get_or_create(app_id='slack', + external_id=user_id, + display_name=get_user_profile(user_id)['name']) + ExternalID.save() + + +def move_userstats_user_id_forward(apps, schema_editor): + ExternalUser = apps.get_model('core', 'ExternalUser') + UserStats = apps.get_model('slack', 'UserStats') + + for userStat in UserStats.objects.all(): + userStat.user = ExternalUser.objects.get(external_id=userStat.user_id_tmp) + userStat.save() + + +def move_pinnedmessage_user_id_forward(apps, schema_editor): + ExternalUser = apps.get_model('core', 'ExternalUser') + PinnedMessage = apps.get_model('slack', 'PinnedMessage') + + for pm in PinnedMessage.objects.all(): + pm.author = ExternalUser.objects.get(external_id=pm.author_id_tmp) + pm.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0004_create_ExternalUser'), + ('slack', '0002_workflow_workflowparameters'), + ] + + operations = [ + migrations.RunPython(PopulateExternalUser), + migrations.RenameField( + model_name='userstats', + old_name='user_id', + new_name='user_id_tmp', + ), + migrations.AddField( + model_name='userstats', + name='user', + field=models.ForeignKey(default=5555, on_delete=django.db.models.deletion.CASCADE, to='core.ExternalUser'), + preserve_default=False, + ), + migrations.RunPython(move_userstats_user_id_forward), + migrations.AlterUniqueTogether( + name='userstats', + unique_together={('incident', 'user')}, + ), + migrations.RemoveField( + model_name='userstats', + name='user_id_tmp', + ), + migrations.RenameField( + model_name='pinnedmessage', + old_name='author_id', + new_name='author_id_tmp', + ), + migrations.AddField( + model_name='pinnedmessage', + name='author', + field=models.ForeignKey(default=5555, on_delete=django.db.models.deletion.PROTECT, to='core.ExternalUser'), + preserve_default=False, + ), + migrations.RunPython(move_pinnedmessage_user_id_forward), + migrations.RemoveField( + model_name='pinnedmessage', + name='author_id_tmp', + ), + ] diff --git a/slack/models/comms_channel.py b/slack/models/comms_channel.py index e804aa26..1199c772 100644 --- a/slack/models/comms_channel.py +++ b/slack/models/comms_channel.py @@ -50,3 +50,6 @@ def post_in_channel(self, message: str): def rename(self, new_name): rename_channel(self.channel_id, new_name) + + def __str__(self): + return self.incident.report diff --git a/slack/models/headline_post.py b/slack/models/headline_post.py index c8cc1f19..d24278a9 100644 --- a/slack/models/headline_post.py +++ b/slack/models/headline_post.py @@ -40,8 +40,8 @@ def update_in_slack(self): # Add report/people msg.add_block(Section(block_id="report", text=Text(f"*{self.incident.report}*"))) - msg.add_block(Section(block_id="reporter", text=Text(f"🙋🏻‍♂️ Reporter: {user_reference(self.incident.reporter)}"))) - incident_lead_text = user_reference(self.incident.lead) if self.incident.lead else "-" + msg.add_block(Section(block_id="reporter", text=Text(f"🙋🏻‍♂️ Reporter: {user_reference(self.incident.reporter.external_id)}"))) + incident_lead_text = user_reference(self.incident.lead.external_id) if self.incident.lead else "-" msg.add_block(Section(block_id="lead", text=Text(f"👩‍🚒 Incident Lead: {incident_lead_text}"))) msg.add_block(Divider()) diff --git a/slack/models/pinned_message.py b/slack/models/pinned_message.py index 46967efd..56fbad58 100644 --- a/slack/models/pinned_message.py +++ b/slack/models/pinned_message.py @@ -1,16 +1,20 @@ from datetime import datetime from django.db import models -from core.models import Incident +from slack.slack_utils import get_user_profile, GetOrCreateSlackExternalUser +from core.models import Incident, ExternalUser class PinnedMessageManager(models.Manager): def add_pin(self, incident, message_ts, author_id, text): + name = get_user_profile(author_id)['name'] + author = GetOrCreateSlackExternalUser(external_id=author_id, display_name=name) + PinnedMessage.objects.get_or_create( incident=incident, message_ts=message_ts, defaults={ - 'author_id': author_id, + 'author': author, 'text': text, 'timestamp': datetime.fromtimestamp(float(message_ts)), } @@ -25,7 +29,7 @@ def remove_pin(self, incident, message_ts): class PinnedMessage(models.Model): incident = models.ForeignKey(Incident, on_delete=models.CASCADE) - author_id = models.CharField(max_length=50, blank=False, null=False) + author = models.ForeignKey(ExternalUser, on_delete=models.PROTECT, blank=False, null=False) message_ts = models.CharField(max_length=50, blank=False, null=False) text = models.TextField() timestamp = models.DateTimeField() diff --git a/slack/models/user_stats.py b/slack/models/user_stats.py index 1b2ffeb3..385ab547 100644 --- a/slack/models/user_stats.py +++ b/slack/models/user_stats.py @@ -1,22 +1,25 @@ from datetime import datetime from django.db import models -from core.models import Incident - +from core.models import Incident, ExternalUser +from slack.slack_utils import get_user_profile, GetOrCreateSlackExternalUser class UserStats(models.Model): - user_id = models.CharField(max_length=50, blank=False, null=False) + user = models.ForeignKey(ExternalUser, on_delete=models.CASCADE, blank=False, null=False) incident = models.ForeignKey(Incident, on_delete=models.CASCADE, blank=False, null=False) join_time = models.DateTimeField(null=True) message_count = models.IntegerField(default=0) class Meta: - unique_together = ("incident", "user_id") + unique_together = ("incident", "user") @staticmethod def increment_message_count(incident, user_id): - user_stats, created = UserStats.objects.get_or_create(incident=incident, user_id=user_id) + name = get_user_profile(user_id)['name'] + user = GetOrCreateSlackExternalUser(external_id=user_id, display_name=name) + + user_stats, created = UserStats.objects.get_or_create(incident=incident, user=user) if created: user_stats.join_time = datetime.now() @@ -25,4 +28,4 @@ def increment_message_count(incident, user_id): user_stats.save() def __str__(self): - return f"{self.user_id} - {self.incident}" + return f"{self.user.display_name} - {self.incident}" diff --git a/slack/slack_utils.py b/slack/slack_utils.py index 54a5928b..aeed1eab 100644 --- a/slack/slack_utils.py +++ b/slack/slack_utils.py @@ -4,6 +4,8 @@ from slugify import slugify from slackclient import SlackClient +from functools import partial +from core.models.incident import ExternalUser slack_token = settings.SLACK_TOKEN slack_client = SlackClient(slack_token) @@ -226,3 +228,6 @@ def rename_channel(channel_id, new_name): if not response.get("ok", False): raise SlackError( 'Failed to rename channel : {}'.format(response['error'])) + + +GetOrCreateSlackExternalUser = lambda *args, **kwargs: ExternalUser.objects.get_or_create(app_id='slack', *args, **kwargs)[0] diff --git a/slack/workflows/statuspage/action_handler.py b/slack/workflows/statuspage/action_handler.py index b017d472..2ec63419 100644 --- a/slack/workflows/statuspage/action_handler.py +++ b/slack/workflows/statuspage/action_handler.py @@ -1,7 +1,7 @@ import requests import slack.dialog_builder as dialog_bld -from .connections import get_status_page_conn +from slack.workflows.statuspage.connections import get_status_page_conn from slack.decorators import ActionContext from core.models import IncidentExtension diff --git a/slack/workflows/statuspage/dialog_handler.py b/slack/workflows/statuspage/dialog_handler.py index 24276de1..d0d9afa4 100644 --- a/slack/workflows/statuspage/dialog_handler.py +++ b/slack/workflows/statuspage/dialog_handler.py @@ -1,6 +1,6 @@ import json -from .connections import get_status_page_conn +from slack.workflows.statuspage.connections import get_status_page_conn from slack.models import Incident from core.models import IncidentExtension diff --git a/slack/workflows/statuspage/incident_command.py b/slack/workflows/statuspage/incident_command.py index 348ee0c4..f29d2f0c 100644 --- a/slack/workflows/statuspage/incident_command.py +++ b/slack/workflows/statuspage/incident_command.py @@ -1,4 +1,4 @@ -from .constants import * +from slack.workflows.statuspage.constants import * from core.models import Incident from slack.block_kit import Message, Button, Section, Actions, Text diff --git a/slack/workflows/statuspage/workflow.py b/slack/workflows/statuspage/workflow.py index 3316cb7d..6e9e0783 100644 --- a/slack/workflows/statuspage/workflow.py +++ b/slack/workflows/statuspage/workflow.py @@ -3,12 +3,12 @@ import logging logger = logging.getLogger(name="statuspage init") -from .action_handler import * -from .incident_command import * -from .dialog_handler import * -from .constants import * +from slack.workflows.statuspage.action_handler import * +from slack.workflows.statuspage.incident_command import * +from slack.workflows.statuspage.dialog_handler import * +from slack.workflows.statuspage.constants import * -from .connections import set_status_page_conn +from slack.workflows.statuspage.connections import set_status_page_conn from slack.models import Workflow from slack.decorators import incident_command, remove_incident_command From 0ee9a12ed67a39866cdabcf99d977a893756146c Mon Sep 17 00:00:00 2001 From: adamlphillips Date: Tue, 2 Jul 2019 11:21:17 +0100 Subject: [PATCH 2/8] Fix migrations for pinned messages and user stats --- slack/migrations/0003_auto_20190624_1422.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/slack/migrations/0003_auto_20190624_1422.py b/slack/migrations/0003_auto_20190624_1422.py index 4baace3f..923203ba 100644 --- a/slack/migrations/0003_auto_20190624_1422.py +++ b/slack/migrations/0003_auto_20190624_1422.py @@ -60,10 +60,15 @@ class Migration(migrations.Migration): migrations.AddField( model_name='userstats', name='user', - field=models.ForeignKey(default=5555, on_delete=django.db.models.deletion.CASCADE, to='core.ExternalUser'), + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='core.ExternalUser'), preserve_default=False, ), migrations.RunPython(move_userstats_user_id_forward), + migrations.AlterField( + model_name='userstats', + name='user', + field=models.ForeignKey(null=False, on_delete=django.db.models.deletion.CASCADE, to='core.ExternalUser'), + ), migrations.AlterUniqueTogether( name='userstats', unique_together={('incident', 'user')}, @@ -80,10 +85,15 @@ class Migration(migrations.Migration): migrations.AddField( model_name='pinnedmessage', name='author', - field=models.ForeignKey(default=5555, on_delete=django.db.models.deletion.PROTECT, to='core.ExternalUser'), + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='core.ExternalUser'), preserve_default=False, ), migrations.RunPython(move_pinnedmessage_user_id_forward), + migrations.AlterField( + model_name='pinnedmessage', + name='author', + field=models.ForeignKey(null=False, on_delete=django.db.models.deletion.PROTECT, to='core.ExternalUser'), + ), migrations.RemoveField( model_name='pinnedmessage', name='author_id_tmp', From aa646a1c8218dd2c9e271175dbac2467487cc03d Mon Sep 17 00:00:00 2001 From: Sotnikov Maksym Date: Wed, 3 Jul 2019 09:24:32 +0300 Subject: [PATCH 3/8] Add actions --- core/admin.py | 4 +- core/migrations/0004_create_ExternalUser.py | 29 +++++ .../migrations/0005_alter_use_ExternalUser.py | 69 ++++++++++++ core/models/__init__.py | 2 + core/models/action.py | 17 +++ core/models/incident.py | 7 +- core/models/user_external.py | 16 +++ core/serializers.py | 43 ++++++++ core/urls.py | 48 +++++++-- response/settings/base.py | 2 + slack/action_handlers.py | 9 +- slack/dialog_handlers.py | 23 +++- slack/incident_commands.py | 37 ++++++- slack/migrations/0003_auto_20190624_1422.py | 101 ++++++++++++++++++ slack/models/comms_channel.py | 3 + slack/models/headline_post.py | 4 +- slack/models/pinned_message.py | 10 +- slack/models/user_stats.py | 15 +-- slack/slack_utils.py | 5 + slack/workflows/statuspage/action_handler.py | 2 +- slack/workflows/statuspage/dialog_handler.py | 2 +- .../workflows/statuspage/incident_command.py | 2 +- slack/workflows/statuspage/workflow.py | 10 +- 23 files changed, 417 insertions(+), 43 deletions(-) create mode 100644 core/migrations/0004_create_ExternalUser.py create mode 100644 core/migrations/0005_alter_use_ExternalUser.py create mode 100644 core/models/action.py create mode 100644 core/models/user_external.py create mode 100644 core/serializers.py create mode 100644 slack/migrations/0003_auto_20190624_1422.py diff --git a/core/admin.py b/core/admin.py index e95351a6..018658ef 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,4 +1,6 @@ from django.contrib import admin -from core.models import Incident +from core.models import Incident, Action, ExternalUser +admin.site.register(Action) admin.site.register(Incident) +admin.site.register(ExternalUser) diff --git a/core/migrations/0004_create_ExternalUser.py b/core/migrations/0004_create_ExternalUser.py new file mode 100644 index 00000000..e719f50c --- /dev/null +++ b/core/migrations/0004_create_ExternalUser.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2.2 on 2019-06-24 14:22 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('core', '0003_incidentextension'), + ] + + operations = [ + migrations.CreateModel( + name='ExternalUser', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('app_id', models.CharField(max_length=50)), + ('external_id', models.CharField(max_length=50)), + ('display_name', models.CharField(max_length=50)), + ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('owner', 'app_id')}, + }, + ), + ] diff --git a/core/migrations/0005_alter_use_ExternalUser.py b/core/migrations/0005_alter_use_ExternalUser.py new file mode 100644 index 00000000..4f796c94 --- /dev/null +++ b/core/migrations/0005_alter_use_ExternalUser.py @@ -0,0 +1,69 @@ +# Generated by Django 2.2.2 on 2019-06-24 14:22 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +def MoveToExternalID(apps, schema_editor): + Incident = apps.get_model('core', 'Incident') + ExternalUser = apps.get_model('core', 'ExternalUser') + + for inc in Incident.objects.all(): + inc.reporter = ExternalUser.objects.get(external_id=inc.reporter_tmp) + if inc.lead_tmp: + inc.lead = ExternalUser.objects.get(external_id=inc.lead_tmp) + inc.save() + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('core', '0004_create_ExternalUser'), + ('slack','0003_auto_20190624_1422'), + ] + + operations = [ + migrations.RenameField( + model_name='incident', + old_name='lead', + new_name='lead_tmp', + ), + migrations.RenameField( + model_name='incident', + old_name='reporter', + new_name='reporter_tmp', + ), + migrations.AddField( + model_name='incident', + name='lead', + field=models.ForeignKey(blank=True, help_text='Who is leading?', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='lead', to='core.ExternalUser'), + preserve_default=False, + ), + migrations.AddField( + model_name='incident', + name='reporter', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='reporter', to='core.ExternalUser'), + preserve_default=False, + ), + migrations.RunPython(MoveToExternalID), + migrations.RemoveField( + model_name='incident', + name='reporter_tmp', + ), + migrations.RemoveField( + model_name='incident', + name='lead_tmp', + ), + migrations.CreateModel( + name='Action', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('details', models.TextField(blank=True, default='')), + ('done', models.BooleanField(default=False)), + ('incident', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.Incident')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.ExternalUser')), + ], + ), + ] diff --git a/core/models/__init__.py b/core/models/__init__.py index d0100384..3ace633a 100644 --- a/core/models/__init__.py +++ b/core/models/__init__.py @@ -1 +1,3 @@ +from .action import * from .incident import * +from .user_external import * diff --git a/core/models/action.py b/core/models/action.py new file mode 100644 index 00000000..d263221d --- /dev/null +++ b/core/models/action.py @@ -0,0 +1,17 @@ +from datetime import datetime +from django.db import models +from core.models.incident import Incident +from core.models.user_external import ExternalUser + + +class Action(models.Model): + details = models.TextField(blank=True, default="") + done = models.BooleanField(default=False) + incident = models.ForeignKey(Incident, on_delete=models.CASCADE) + user = models.ForeignKey(ExternalUser, on_delete=models.CASCADE, blank=False, null=False) + + def icon(self): + return "🔜️" + + def __str__(self): + return f"{self.details}" diff --git a/core/models/incident.py b/core/models/incident.py index 6862e798..e45dac8e 100644 --- a/core/models/incident.py +++ b/core/models/incident.py @@ -1,6 +1,6 @@ from datetime import datetime from django.db import models - +from core.models.user_external import ExternalUser class IncidentManager(models.Manager): def create_incident(self, report, reporter, report_time, summary=None, impact=None, squad=None, lead=None): @@ -24,7 +24,7 @@ class Incident(models.Model): # Reporting info report = models.CharField(max_length=200) - reporter = models.CharField(max_length=50, default="") + reporter = models.ForeignKey(ExternalUser, related_name='reporter', on_delete=models.PROTECT, blank=False, null=True,) report_time = models.DateTimeField() start_time = models.DateTimeField(null=False) @@ -33,7 +33,8 @@ class Incident(models.Model): # Additional info summary = models.TextField(blank=True, null=True, help_text="What's the high level summary?") impact = models.TextField(blank=True, null=True, help_text="What impact is this having?") - lead = models.CharField(max_length=50, blank=True, null=True, help_text="Who is leading?") + lead = models.ForeignKey(ExternalUser, related_name='lead', on_delete=models.PROTECT, blank=True, null=True, help_text="Who is leading?") + # Severity # SEVERITIES = ( diff --git a/core/models/user_external.py b/core/models/user_external.py new file mode 100644 index 00000000..88745809 --- /dev/null +++ b/core/models/user_external.py @@ -0,0 +1,16 @@ +from datetime import datetime +from django.db import models +from django.contrib.auth.models import User + + +class ExternalUser(models.Model): + class Meta: + unique_together = ("owner", "app_id") + + owner = models.ForeignKey(User, on_delete=models.PROTECT, null=True, blank=True) + app_id = models.CharField(max_length=50, blank=False, null=False) + external_id = models.CharField(max_length=50, blank=False, null=False) + display_name = models.CharField(max_length=50, blank=False, null=False) + + def __str__(self): + return f'{self.display_name or self.external_id} ({self.app_id})' \ No newline at end of file diff --git a/core/serializers.py b/core/serializers.py new file mode 100644 index 00000000..1c5f968b --- /dev/null +++ b/core/serializers.py @@ -0,0 +1,43 @@ +from rest_framework import serializers +from rest_framework.decorators import action + +from core.models.incident import Incident +from core.models.action import Action +from core.models.user_external import ExternalUser + +from django.contrib.auth.models import User + + +class ExternalUserSerializer(serializers.HyperlinkedModelSerializer): + owner = serializers.PrimaryKeyRelatedField(queryset=User.objects.all(), required=False) + + class Meta: + model = ExternalUser + fields = ('app_id', 'external_id', 'owner', 'display_name') + + +class ActionSerializer(serializers.HyperlinkedModelSerializer): + # Serializers define the API representation. + incident = serializers.PrimaryKeyRelatedField(queryset=Incident.objects.all(), required=False) + user = serializers.PrimaryKeyRelatedField(queryset=ExternalUser.objects.all(), required=False) + + class Meta: + model = Action + fields = ('pk', 'details', 'done', 'incident', 'user') + + +class IncidentSerializer(serializers.HyperlinkedModelSerializer): + reporter = serializers.PrimaryKeyRelatedField(queryset=ExternalUser.objects.all(), required=False) + lead = serializers.PrimaryKeyRelatedField(queryset=ExternalUser.objects.all(), required=False) + + class Meta: + model = Incident + fields = ('pk','report', 'reporter', 'lead', 'start_time', 'end_time', 'report_time', 'action_set') + + def __init__(self, *args, **kwargs): + super(IncidentSerializer, self).__init__(*args, **kwargs) + request = kwargs['context']['request'] + expand = request.GET.get('expand', "").split(',') + + if 'actions' in expand: + self.fields['action_set'] = ActionSerializer(many=True, read_only=True) diff --git a/core/urls.py b/core/urls.py index 9ea7e59c..e3341486 100644 --- a/core/urls.py +++ b/core/urls.py @@ -1,25 +1,57 @@ from django.conf.urls import url, include -from rest_framework import routers, serializers, viewsets +from rest_framework import routers, viewsets, pagination +from rest_framework.decorators import action from core.models.incident import Incident +from core.models.action import Action +from core.models.user_external import ExternalUser +from datetime import datetime +from calendar import monthrange -class IncidentSerializer(serializers.HyperlinkedModelSerializer): - # Serializers define the API representation. - class Meta: - model = Incident - fields = ('report', 'reporter', 'report_time') +from core.serializers import * + +class ExternalUserViewSet(viewsets.ModelViewSet): + # ViewSets define the view behavior. + queryset = ExternalUser.objects.all() + serializer_class = ExternalUserSerializer + + +class ActionViewSet(viewsets.ModelViewSet): + # ViewSets define the view behavior. + queryset = Action.objects.all() + serializer_class = ActionSerializer +# Will return the incidents of the current month +# Can pass ?start=2019-05-28&end=2019-06-03 to change range class IncidentViewSet(viewsets.ModelViewSet): # ViewSets define the view behavior. - queryset = Incident.objects.all() + serializer_class = IncidentSerializer + pagination_class = None # Remove pagination + + def get_queryset(self): + # Same query is used to get single items so we check if pk is passed + # incident/2/ if we use the filter below we would have to have correct time range + if 'pk' in self.kwargs: + return Incident.objects.filter(pk=self.kwargs['pk']) + + today = datetime.today() + first_day_of_current_month = datetime(today.year, today.month, 1) + days_in_month = monthrange(today.year, today.month)[1] + last_day_of_current_month = datetime(today.year, today.month, days_in_month) + + start = self.request.GET.get('start', first_day_of_current_month) + end = self.request.GET.get('end', last_day_of_current_month) + return Incident.objects.filter(start_time__gte=start, start_time__lte=end) # Routers provide an easy way of automatically determining the URL conf. router = routers.DefaultRouter() -router.register(r'incidents', IncidentViewSet) +router.register(r'incidents', IncidentViewSet, base_name='Incidents') +router.register(r'actions', ActionViewSet) +router.register(r'ExternalUser', ExternalUserViewSet) # Wire up our API using automatic URL routing. # Additionally, we include login URLs for the browsable API. diff --git a/response/settings/base.py b/response/settings/base.py index ba264cd9..d22d2c9e 100644 --- a/response/settings/base.py +++ b/response/settings/base.py @@ -137,6 +137,8 @@ # https://www.django-rest-framework.org/ REST_FRAMEWORK = { + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', + 'PAGE_SIZE': 100, # Use Django's standard `django.contrib.auth` permissions, # or allow read-only access for unauthenticated users. 'DEFAULT_PERMISSION_CLASSES': [ diff --git a/slack/action_handlers.py b/slack/action_handlers.py index 0c458325..ec7010cd 100644 --- a/slack/action_handlers.py +++ b/slack/action_handlers.py @@ -11,6 +11,8 @@ from slack.decorators import action_handler, ActionContext +import logging +logger = logging.getLogger(__name__) @action_handler(HeadlinePost.CLOSE_INCIDENT_BUTTON) def handle_close_incident(ac: ActionContext): @@ -26,7 +28,10 @@ def handle_create_comms_channel(ac: ActionContext): comms_channel = CommsChannel.objects.create_comms_channel(ac.incident) # Invite the bot to the channel - invite_user_to_channel(settings.INCIDENT_BOT_ID, comms_channel.channel_id) + try: + invite_user_to_channel(settings.INCIDENT_BOT_ID, comms_channel.channel_id) + except Exception as ex: + logger.error(ex) # Un-invite the user who owns the Slack token, # otherwise they'll be added to every incident channel @@ -52,7 +57,7 @@ def handle_edit_incident_button(ac: ActionContext): Text(label="Report", name="report", value=ac.incident.report), TextArea(label="Summary", name="summary", value=ac.incident.summary, optional=True, placeholder="Can you share any more details?"), TextArea(label="Impact", name="impact", value=ac.incident.impact, optional=True, placeholder="Who or what might be affected?", hint="Think about affected people, systems, and processes"), - SelectFromUsers(label="Lead", name="lead", value=ac.incident.lead, optional=True), + SelectFromUsers(label="Lead", name="lead", value=ac.incident.lead.external_id, optional=True), SelectWithOptions([(i, s) for i, s in Incident.SQUADS], label="Squad", name="squad", optional=True) # SelectWithOptions([(i, s.capitalize()) for i, s in Incident.SEVERITIES], value=ac.incident.severity, label="Severity", name="severity", optional=True) ] diff --git a/slack/dialog_handlers.py b/slack/dialog_handlers.py index b3bc7c93..01dbc8b3 100644 --- a/slack/dialog_handlers.py +++ b/slack/dialog_handlers.py @@ -4,10 +4,10 @@ from django.conf import settings from slack.settings import INCIDENT_EDIT_DIALOG, INCIDENT_REPORT_DIALOG -from core.models.incident import Incident +from core.models.incident import Incident, ExternalUser from slack.models import HeadlinePost, CommsChannel from slack.decorators import dialog_handler -from slack.slack_utils import send_ephemeral_message, channel_reference +from slack.slack_utils import send_ephemeral_message, channel_reference, get_user_profile, GetOrCreateSlackExternalUser import logging logger = logging.getLogger(__name__) @@ -19,12 +19,20 @@ def report_incident(user_id: str, channel_id: str, submission: json, response_ur summary = submission['summary'] impact = submission['impact'] squad = submission['squad'] - lead = submission['lead'] + lead_id = submission['lead'] # severity = submission['severity'] + name = get_user_profile(user_id)['name'] + reporter = GetOrCreateSlackExternalUser(external_id=user_id, display_name=name) + + lead = None + if lead_id: + lead_name = get_user_profile(lead_id)['name'] + lead = GetOrCreateSlackExternalUser(external_id=lead_id, display_name=lead_name) + Incident.objects.create_incident( report=report, - reporter=user_id, + reporter=reporter, report_time=datetime.now(), summary=summary, impact=impact, @@ -44,9 +52,14 @@ def edit_incident(user_id: str, channel_id: str, submission: json, response_url: summary = submission['summary'] impact = submission['impact'] squad = submission['squad'] - lead = submission['lead'] + lead_id = submission['lead'] # severity = submission['severity'] + lead = None + if lead_id: + lead_name = get_user_profile(lead_id)['name'] + lead = GetOrCreateSlackExternalUser(external_id=lead_id, display_name=lead_name) + try: incident = Incident.objects.get(pk=state) diff --git a/slack/incident_commands.py b/slack/incident_commands.py index 0f33264c..89a71906 100644 --- a/slack/incident_commands.py +++ b/slack/incident_commands.py @@ -1,8 +1,8 @@ -from core.models import Incident +from core.models import Incident, Action, ExternalUser from slack.models import CommsChannel from slack.decorators import incident_command, get_help -from slack.slack_utils import reference_to_id, rename_channel, SlackError - +from slack.slack_utils import reference_to_id, get_user_profile, rename_channel, SlackError, GetOrCreateSlackExternalUser +from datetime import datetime @incident_command(['help'], helptext='Display a list of commands and usage') def send_help_text(incident: Incident, user_id: str, message: str): @@ -25,8 +25,10 @@ def update_impact(incident: Incident, user_id: str, message: str): @incident_command(['lead'], helptext='Assign someone as the incident lead') def set_incident_lead(incident: Incident, user_id: str, message: str): - assignee = reference_to_id(message) - incident.lead = assignee or user_id + assignee = reference_to_id(message) or user_id + name = get_user_profile(assignee)['name'] + user = GetOrCreateSlackExternalUser(external_id=assignee, display_name=name) + incident.lead = user incident.save() return True, None @@ -72,3 +74,28 @@ def rename_incident(incident: Incident, user_id: str, message: str): # comms_channel.post_in_channel(f"The incident has been running for {duration}") # # return True, None + + +@incident_command(['close'], helptext='Close this incident.') +def close_incident(incident: Incident, user_id: str, message: str): + comms_channel = CommsChannel.objects.get(incident=incident) + + if incident.is_closed(): + comms_channel.post_in_channel(f"This incident was already closed at {incident.end_time.strftime('%Y-%m-%d %H:%M:%S')}") + return True, None + + incident.end_time = datetime.now() + incident.save() + + comms_channel.post_in_channel(f"This incident has been closed! 📖 -> 📕") + + return True, None + + +@incident_command(['action'], helptext='Log a follow up action') +def set_action(incident: Incident, user_id: str, message: str): + comms_channel = CommsChannel.objects.get(incident=incident) + name = get_user_profile(user_id)['name'] + action_reporter = GetOrCreateSlackExternalUser(external_id=user_id, display_name=name) + Action(incident=incident, details=message, user=action_reporter).save() + return True, None diff --git a/slack/migrations/0003_auto_20190624_1422.py b/slack/migrations/0003_auto_20190624_1422.py new file mode 100644 index 00000000..923203ba --- /dev/null +++ b/slack/migrations/0003_auto_20190624_1422.py @@ -0,0 +1,101 @@ +# Generated by Django 2.2.2 on 2019-06-24 14:22 + +import django.db.models.deletion +from django.db import migrations, models +from slack.slack_utils import get_user_profile +from django.db.models import F + + +def PopulateExternalUser(apps, schema_editor): + Incident = apps.get_model('core', 'Incident') + ExternalUser = apps.get_model('core', 'ExternalUser') + + UserStats = apps.get_model('slack', 'UserStats') + PinnedMessage = apps.get_model('slack', 'PinnedMessage') + + user_id_list = [ x['userid'] for x in Incident.objects.all().values(userid=F('reporter')) + .union(Incident.objects.all().values(userid=F('lead'))) + .union(UserStats.objects.all().values(userid=F('user_id'))) + .union(PinnedMessage.objects.all().values(userid=F('author_id'))) if x['userid'] ] + + for user_id in user_id_list: + ExternalID, created = ExternalUser.objects.get_or_create(app_id='slack', + external_id=user_id, + display_name=get_user_profile(user_id)['name']) + ExternalID.save() + + +def move_userstats_user_id_forward(apps, schema_editor): + ExternalUser = apps.get_model('core', 'ExternalUser') + UserStats = apps.get_model('slack', 'UserStats') + + for userStat in UserStats.objects.all(): + userStat.user = ExternalUser.objects.get(external_id=userStat.user_id_tmp) + userStat.save() + + +def move_pinnedmessage_user_id_forward(apps, schema_editor): + ExternalUser = apps.get_model('core', 'ExternalUser') + PinnedMessage = apps.get_model('slack', 'PinnedMessage') + + for pm in PinnedMessage.objects.all(): + pm.author = ExternalUser.objects.get(external_id=pm.author_id_tmp) + pm.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0004_create_ExternalUser'), + ('slack', '0002_workflow_workflowparameters'), + ] + + operations = [ + migrations.RunPython(PopulateExternalUser), + migrations.RenameField( + model_name='userstats', + old_name='user_id', + new_name='user_id_tmp', + ), + migrations.AddField( + model_name='userstats', + name='user', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='core.ExternalUser'), + preserve_default=False, + ), + migrations.RunPython(move_userstats_user_id_forward), + migrations.AlterField( + model_name='userstats', + name='user', + field=models.ForeignKey(null=False, on_delete=django.db.models.deletion.CASCADE, to='core.ExternalUser'), + ), + migrations.AlterUniqueTogether( + name='userstats', + unique_together={('incident', 'user')}, + ), + migrations.RemoveField( + model_name='userstats', + name='user_id_tmp', + ), + migrations.RenameField( + model_name='pinnedmessage', + old_name='author_id', + new_name='author_id_tmp', + ), + migrations.AddField( + model_name='pinnedmessage', + name='author', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='core.ExternalUser'), + preserve_default=False, + ), + migrations.RunPython(move_pinnedmessage_user_id_forward), + migrations.AlterField( + model_name='pinnedmessage', + name='author', + field=models.ForeignKey(null=False, on_delete=django.db.models.deletion.PROTECT, to='core.ExternalUser'), + ), + migrations.RemoveField( + model_name='pinnedmessage', + name='author_id_tmp', + ), + ] diff --git a/slack/models/comms_channel.py b/slack/models/comms_channel.py index 586fbafe..a25aebcb 100644 --- a/slack/models/comms_channel.py +++ b/slack/models/comms_channel.py @@ -50,3 +50,6 @@ def post_in_channel(self, message: str): def rename(self, new_name): rename_channel(self.channel_id, new_name) + + def __str__(self): + return self.incident.report diff --git a/slack/models/headline_post.py b/slack/models/headline_post.py index df58e1c7..90f433c5 100644 --- a/slack/models/headline_post.py +++ b/slack/models/headline_post.py @@ -40,8 +40,8 @@ def update_in_slack(self): # Add report/people msg.add_block(Section(block_id="report", text=Text(f"*{self.incident.report}*"))) - msg.add_block(Section(block_id="reporter", text=Text(f"🙋🏻‍♂️ Reporter: {user_reference(self.incident.reporter)}"))) - incident_lead_text = user_reference(self.incident.lead) if self.incident.lead else "-" + msg.add_block(Section(block_id="reporter", text=Text(f"🙋🏻‍♂️ Reporter: {user_reference(self.incident.reporter.external_id)}"))) + incident_lead_text = user_reference(self.incident.lead.external_id) if self.incident.lead else "-" msg.add_block(Section(block_id="lead", text=Text(f"👩‍🚒 Incident Lead: {incident_lead_text}"))) squad_text = self.incident.squad_text() msg.add_block(Section(block_id="squad", text=Text(f":office: Squad: {squad_text}‍"))) diff --git a/slack/models/pinned_message.py b/slack/models/pinned_message.py index 46967efd..56fbad58 100644 --- a/slack/models/pinned_message.py +++ b/slack/models/pinned_message.py @@ -1,16 +1,20 @@ from datetime import datetime from django.db import models -from core.models import Incident +from slack.slack_utils import get_user_profile, GetOrCreateSlackExternalUser +from core.models import Incident, ExternalUser class PinnedMessageManager(models.Manager): def add_pin(self, incident, message_ts, author_id, text): + name = get_user_profile(author_id)['name'] + author = GetOrCreateSlackExternalUser(external_id=author_id, display_name=name) + PinnedMessage.objects.get_or_create( incident=incident, message_ts=message_ts, defaults={ - 'author_id': author_id, + 'author': author, 'text': text, 'timestamp': datetime.fromtimestamp(float(message_ts)), } @@ -25,7 +29,7 @@ def remove_pin(self, incident, message_ts): class PinnedMessage(models.Model): incident = models.ForeignKey(Incident, on_delete=models.CASCADE) - author_id = models.CharField(max_length=50, blank=False, null=False) + author = models.ForeignKey(ExternalUser, on_delete=models.PROTECT, blank=False, null=False) message_ts = models.CharField(max_length=50, blank=False, null=False) text = models.TextField() timestamp = models.DateTimeField() diff --git a/slack/models/user_stats.py b/slack/models/user_stats.py index 1b2ffeb3..385ab547 100644 --- a/slack/models/user_stats.py +++ b/slack/models/user_stats.py @@ -1,22 +1,25 @@ from datetime import datetime from django.db import models -from core.models import Incident - +from core.models import Incident, ExternalUser +from slack.slack_utils import get_user_profile, GetOrCreateSlackExternalUser class UserStats(models.Model): - user_id = models.CharField(max_length=50, blank=False, null=False) + user = models.ForeignKey(ExternalUser, on_delete=models.CASCADE, blank=False, null=False) incident = models.ForeignKey(Incident, on_delete=models.CASCADE, blank=False, null=False) join_time = models.DateTimeField(null=True) message_count = models.IntegerField(default=0) class Meta: - unique_together = ("incident", "user_id") + unique_together = ("incident", "user") @staticmethod def increment_message_count(incident, user_id): - user_stats, created = UserStats.objects.get_or_create(incident=incident, user_id=user_id) + name = get_user_profile(user_id)['name'] + user = GetOrCreateSlackExternalUser(external_id=user_id, display_name=name) + + user_stats, created = UserStats.objects.get_or_create(incident=incident, user=user) if created: user_stats.join_time = datetime.now() @@ -25,4 +28,4 @@ def increment_message_count(incident, user_id): user_stats.save() def __str__(self): - return f"{self.user_id} - {self.incident}" + return f"{self.user.display_name} - {self.incident}" diff --git a/slack/slack_utils.py b/slack/slack_utils.py index 54a5928b..aeed1eab 100644 --- a/slack/slack_utils.py +++ b/slack/slack_utils.py @@ -4,6 +4,8 @@ from slugify import slugify from slackclient import SlackClient +from functools import partial +from core.models.incident import ExternalUser slack_token = settings.SLACK_TOKEN slack_client = SlackClient(slack_token) @@ -226,3 +228,6 @@ def rename_channel(channel_id, new_name): if not response.get("ok", False): raise SlackError( 'Failed to rename channel : {}'.format(response['error'])) + + +GetOrCreateSlackExternalUser = lambda *args, **kwargs: ExternalUser.objects.get_or_create(app_id='slack', *args, **kwargs)[0] diff --git a/slack/workflows/statuspage/action_handler.py b/slack/workflows/statuspage/action_handler.py index b017d472..2ec63419 100644 --- a/slack/workflows/statuspage/action_handler.py +++ b/slack/workflows/statuspage/action_handler.py @@ -1,7 +1,7 @@ import requests import slack.dialog_builder as dialog_bld -from .connections import get_status_page_conn +from slack.workflows.statuspage.connections import get_status_page_conn from slack.decorators import ActionContext from core.models import IncidentExtension diff --git a/slack/workflows/statuspage/dialog_handler.py b/slack/workflows/statuspage/dialog_handler.py index 24276de1..d0d9afa4 100644 --- a/slack/workflows/statuspage/dialog_handler.py +++ b/slack/workflows/statuspage/dialog_handler.py @@ -1,6 +1,6 @@ import json -from .connections import get_status_page_conn +from slack.workflows.statuspage.connections import get_status_page_conn from slack.models import Incident from core.models import IncidentExtension diff --git a/slack/workflows/statuspage/incident_command.py b/slack/workflows/statuspage/incident_command.py index 348ee0c4..f29d2f0c 100644 --- a/slack/workflows/statuspage/incident_command.py +++ b/slack/workflows/statuspage/incident_command.py @@ -1,4 +1,4 @@ -from .constants import * +from slack.workflows.statuspage.constants import * from core.models import Incident from slack.block_kit import Message, Button, Section, Actions, Text diff --git a/slack/workflows/statuspage/workflow.py b/slack/workflows/statuspage/workflow.py index 3316cb7d..6e9e0783 100644 --- a/slack/workflows/statuspage/workflow.py +++ b/slack/workflows/statuspage/workflow.py @@ -3,12 +3,12 @@ import logging logger = logging.getLogger(name="statuspage init") -from .action_handler import * -from .incident_command import * -from .dialog_handler import * -from .constants import * +from slack.workflows.statuspage.action_handler import * +from slack.workflows.statuspage.incident_command import * +from slack.workflows.statuspage.dialog_handler import * +from slack.workflows.statuspage.constants import * -from .connections import set_status_page_conn +from slack.workflows.statuspage.connections import set_status_page_conn from slack.models import Workflow from slack.decorators import incident_command, remove_incident_command From ca956619631af05aca2ae98ce1b36945eabaed42 Mon Sep 17 00:00:00 2001 From: Sotnikov Maksym Date: Wed, 3 Jul 2019 11:14:48 +0300 Subject: [PATCH 4/8] Merge migrations --- core/migrations/0006_merge_20190703_0812.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 core/migrations/0006_merge_20190703_0812.py diff --git a/core/migrations/0006_merge_20190703_0812.py b/core/migrations/0006_merge_20190703_0812.py new file mode 100644 index 00000000..ff17813d --- /dev/null +++ b/core/migrations/0006_merge_20190703_0812.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.2 on 2019-07-03 08:12 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0005_alter_use_ExternalUser'), + ('core', '0004_auto_20190627_0907'), + ] + + operations = [ + ] From a4068fb96e18783acd77b5d9458e3b9c6bdb3830 Mon Sep 17 00:00:00 2001 From: Serhii Khylyk Date: Wed, 3 Jul 2019 11:26:06 +0300 Subject: [PATCH 5/8] Disable auth --- response/settings/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/response/settings/base.py b/response/settings/base.py index d22d2c9e..3da95bdb 100644 --- a/response/settings/base.py +++ b/response/settings/base.py @@ -29,7 +29,7 @@ SECRET_KEY = 'c+*z3&f$!v@am35()o57_l885=t$2vlw*w#*jusz0qiyi#h_iz' # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = False ALLOWED_HOSTS = ["*"] @@ -142,7 +142,7 @@ # Use Django's standard `django.contrib.auth` permissions, # or allow read-only access for unauthenticated users. 'DEFAULT_PERMISSION_CLASSES': [ - 'rest_framework.permissions.IsAuthenticated' + 'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly' ] } From 6460f01f467c86d3612c20168f3ce4943fe3d229 Mon Sep 17 00:00:00 2001 From: adamlphillips Date: Wed, 3 Jul 2019 15:07:32 +0100 Subject: [PATCH 6/8] Update ui for action/user changes --- slack/action_handlers.py | 2 +- ui/templates/incident_doc.html | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/slack/action_handlers.py b/slack/action_handlers.py index cb501be7..9e0f5822 100644 --- a/slack/action_handlers.py +++ b/slack/action_handlers.py @@ -57,7 +57,7 @@ def handle_edit_incident_button(ac: ActionContext): Text(label="Report", name="report", value=ac.incident.report), TextArea(label="Summary", name="summary", value=ac.incident.summary, optional=True, placeholder="Can you share any more details?"), TextArea(label="Impact", name="impact", value=ac.incident.impact, optional=True, placeholder="Who or what might be affected?", hint="Think about affected people, systems, and processes"), - SelectFromUsers(label="Lead", name="lead", value=ac.incident.lead.external_id, optional=True), + SelectFromUsers(label="Lead", name="lead", value=ac.incident.lead.external_id if ac.incident.lead else None, optional=True), SelectWithOptions([(i, s.capitalize()) for i, s in Incident.SEVERITIES], value=ac.incident.severity, label="Severity", name="severity", optional=True) ] ) diff --git a/ui/templates/incident_doc.html b/ui/templates/incident_doc.html index 08e0b7f6..d2661ce2 100644 --- a/ui/templates/incident_doc.html +++ b/ui/templates/incident_doc.html @@ -25,8 +25,8 @@

Summary

{% if incident.summary %}{{ incident.summary|unslackify|markdown_filter|safe }}{% endif %}

    {% if incident.impact %}
  • Impact:{{ incident.impact|unslackify|markdown_filter|safe }}
  • {% endif %} -
  • Reporter:{{ incident.reporter|slack_id_to_fullname }}
  • - {% if incident.lead %}
  • Lead:{{ incident.lead|slack_id_to_fullname }}
  • {% endif %} +
  • Reporter:{{ incident.reporter.display_name }}
  • + {% if incident.lead %}
  • Lead:{{ incident.lead.display_name }}
  • {% endif %}
  • Start Time:{% if incident.start_time %}{{ incident.start_time }}{% endif %}
  • Report Time:{% if incident.report_time %}{{ incident.report_time }}{% endif %}
  • @@ -39,9 +39,9 @@

    Summary

    {% if user_stats %}
  • Participants:
      - {% for user in user_stats %} + {% for stats in user_stats %}
    • - {{ user.user_id|slack_id_to_fullname }} ({{user.message_count}} messages) + {{ stats.user.display_name }} ({{stats.message_count}} messages)
    • {% endfor %}
    From e933d6793238c52f2e095c82bc6fee8bad152d88 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jul 2019 07:51:26 +0100 Subject: [PATCH 7/8] Bump django from 2.2.2 to 2.2.3 (#48) Bumps [django](https://github.com/django/django) from 2.2.2 to 2.2.3. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/2.2.2...2.2.3) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 7bbfa5c4..14fe308c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ certifi==2019.3.9 cffi==1.12.3 chardet==3.0.4 cryptography==2.7 -Django==2.2.2 +Django==2.2.3 django-after-response==0.2.2 django-bootstrap4==0.0.7 django-filter==2.1.0 From e544acdb79151cc8192910cf37b9080b6c443127 Mon Sep 17 00:00:00 2001 From: Sotnikov Maksym Date: Thu, 4 Jul 2019 11:05:02 +0300 Subject: [PATCH 8/8] Fix merge typo --- slack/dialog_handlers.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/slack/dialog_handlers.py b/slack/dialog_handlers.py index 0cc933ec..4a5f7928 100644 --- a/slack/dialog_handlers.py +++ b/slack/dialog_handlers.py @@ -59,20 +59,10 @@ def edit_incident(user_id: str, channel_id: str, submission: json, response_url: report = submission['report'] summary = submission['summary'] impact = submission['impact'] -<<<<<<< HEAD squad = submission['squad'] lead_id = submission['lead'] # severity = submission['severity'] - lead = None - if lead_id: - lead_name = get_user_profile(lead_id)['name'] - lead = GetOrCreateSlackExternalUser(external_id=lead_id, display_name=lead_name) -======= - lead_id = submission['lead'] - severity = submission['severity'] ->>>>>>> e933d6793238c52f2e095c82bc6fee8bad152d88 - lead = None if lead_id: lead_name = get_user_profile(lead_id)['name']