From 6bfc9eb544432a78cf91aa3a85dbf9e6a25175cb Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Wed, 1 Jan 2025 00:45:17 -0800 Subject: [PATCH 01/34] Split Slack API into two files --- slack/api.py | 490 ++++++---------------------------------- slack/event_handlers.py | 427 ++++++++++++++++++++++++++++++++++ slack/models.py | 2 + slack/urls.py | 14 +- slack/views.py | 2 +- 5 files changed, 511 insertions(+), 424 deletions(-) create mode 100644 slack/event_handlers.py diff --git a/slack/api.py b/slack/api.py index c1222ecb..bd68bfeb 100755 --- a/slack/api.py +++ b/slack/api.py @@ -1,21 +1,8 @@ -import json import logging -from cryptography.fernet import Fernet from django.conf import settings -from django.shortcuts import reverse -from django.db import connection -from django.http import HttpResponse, HttpResponseNotFound, HttpResponseServerError, JsonResponse -from django.views.decorators.http import require_POST -from django.views.decorators.csrf import csrf_exempt -from django.contrib.auth import get_user_model from slack_sdk import WebClient from slack_sdk.errors import SlackApiError -from accounts.models import UserPreferences -from data.decorators import process_in_thread -from rt import api as rt_api -from . import views, models - logger = logging.getLogger(__name__) @@ -55,11 +42,11 @@ def load_channels(archived=False): return e.response -def channel_info(channel_id): +def channel_info(id, num_members=False): """ Retrieves all the information about a channel - :param channel_id: The ID of the channel + :param id: The ID of the channel :return: Channel details (Dictionary) """ @@ -69,12 +56,81 @@ def channel_info(channel_id): client = WebClient(token=settings.SLACK_TOKEN) try: - response = client.conversations_info(channel=channel_id) + response = client.conversations_info(channel=id, include_num_members=num_members) assert response['ok'] is True return response['channel'] except SlackApiError as e: assert e.response['ok'] is False return None + +def channel_latest_message(id): + """ + Retrieves the latest message in a channel + + :param id: The ID of the channel + :return: The message details + """ + + if not settings.SLACK_TOKEN: + return None + + client = WebClient(token=settings.SLACK_TOKEN) + + try: + response = client.conversations_history(channel=id, limit=1) + assert response['ok'] is True + return float(response['messages'][0]) + except SlackApiError as e: + assert e.response['ok'] is False + return None + +def channel_member_ids(id): + """ + Retrieves list of channel members' IDs + + :param id: The ID of the channel + :return: list of member Slack IDs + """ + + if not settings.SLACK_TOKEN: + return None + + client = WebClient(token=settings.SLACK_TOKEN) + + try: + response = client.conversations_members(channel=id) + assert response['ok'] is True + if response['response_metadata']['next_cursor']: + cursor = response['response_metadata']['next_cursor'] + members = response['members'] + while cursor: + response = client.conversations_members(channel=id, cursor=cursor) + assert response['ok'] is True + members += response['members'] + cursor = response['response_metadata']['next_cursor'] + return members + return response['members'] + except SlackApiError as e: + assert e.response['ok'] is False + return None + +def channel_members(id): + """ + Retrieves list of channel members and matches to users in the database + + :param id: The ID of the channel + :return: list of members + """ + + member_ids = channel_member_ids(id) + if not member_ids: return None + + members = [] + for member_id in member_ids: + user = user_profile(member_id) + if user['ok']: + members.append(user['user']['profile']['email']) + return members def join_channel(channel): @@ -472,405 +528,3 @@ def open_modal(trigger_id, blocks): return None -# Event Handlers -@csrf_exempt -@require_POST -def handle_event(request): - """ - Event endpoint for the Slack API. Slack will send POST requests here whenever certain events have been triggered. - """ - - payload = json.loads(request.body) - if payload['type'] == "url_verification": - return JsonResponse({"challenge": payload['challenge']}) - elif payload['type'] == "event_callback": - event = payload['event'] - if event['type'] == "team_join": - slack_post(event['user']['id'], text="Welcome to LNL!", content=views.welcome_message()) - elif event['type'] == "app_home_opened": - load_app_home(event['user']) - elif event['type'] == "channel_created": - if settings.SLACK_AUTO_JOIN: - join_channel(event['channel']['id']) - return HttpResponse() - return HttpResponse("Not implemented") - - -@process_in_thread -def load_app_home(user_id): - """ - Load the App's Home tab. - - :param user_id: The identifier for the user in Slack - :return: Response object (Dictionary) - """ - - ticket_ids = [] - tickets = [] - user = user_profile(user_id) - if user['ok']: - email = user['user']['profile']['email'] - ticket_ids = sorted(rt_api.simple_ticket_search(requestor=email, status="__Active__"), reverse=True) - for ticket_id in ticket_ids: - ticket = rt_api.fetch_ticket(ticket_id) - if ticket.get('message'): - continue - tickets.append(ticket) - blocks = views.app_home(tickets) - - if not settings.SLACK_TOKEN: - return {'ok': False, 'error': 'config_error'} - - client = WebClient(token=settings.SLACK_TOKEN) - - try: - response = client.views_publish(user_id=user_id, view={"type": "home", "blocks": blocks}) - assert response['ok'] is True - return response - except SlackApiError as e: - assert e.response['ok'] is False - return e.response - - -# Interaction handlers -@csrf_exempt -@require_POST -def handle_interaction(request): - """ - Interaction endpoint for the Slack API. Slack will send POST requests here when users interact with a shortcut or - interactive component. - """ - - payload = json.loads(request.POST['payload']) - interaction_type = payload.get('type', None) - - # Handle shortcut - if interaction_type == "shortcut": - callback_id = payload.get('callback_id', None) - if callback_id == "tfed": - blocks = views.tfed_modal() - modal_id = open_modal(payload.get('trigger_id', None), blocks) - if modal_id: - return HttpResponse() - return HttpResponseServerError("Failed to open modal") - if interaction_type == "message_action": - callback_id = payload.get('callback_id', None) - if callback_id == "report": - channel = payload.get('channel', {'id': None})['id'] - sender = payload['message'].get('user', None) - if not sender: - sender = payload['message']['username'] - ts = payload['message']['ts'] - text = payload['message']['text'] - message, created = models.SlackMessage.objects.get_or_create(posted_to=channel, posted_by=sender, ts=ts, - content=text) - blocks = views.report_message_modal(message) - modal_id = open_modal(payload.get('trigger_id', None), blocks) - if modal_id: - return HttpResponse() - return HttpResponseServerError("Failed to open modal") - - # Handle modal view submission - if interaction_type == "view_submission": - values = payload['view']['state']['values'] - callback_id = payload['view'].get('callback_id', None) - - # TFed ticket submission - if callback_id == "tfed-modal": - subject = values['subject']['subject-action']['value'] - description = values['description']['description-action']['value'] - topic = values['rt_topic']['rt_topic-action']['selected_option']['value'] - user_id = payload['user']['id'] - user = user_profile(user_id) - if user['ok']: - __create_ticket(user, subject, description, topic) - return HttpResponse() - return HttpResponseServerError("Failed to obtain user information") - - # Update TFed ticket - elif callback_id == "ticket-update-modal": - ticket_info = payload['view']['blocks'][1] - owner_id = None - if ticket_info['type'] != "divider": - ticket_info = payload['view']['blocks'][2] - owner_id = values['ticket_assignee']['ticket_assignee-action']['selected_user'] - ticket_id = ticket_info['block_id'].split("#")[0] - channel = ticket_info['block_id'].split("#")[1] - ts = ticket_info['block_id'].split("#")[2] - status = values['ticket_status']['ticket_status-action']['selected_option'] - if status: - status = status['value'] - comments = values['ticket_comment']['ticket_comment-action']['value'] - checkboxes = values['email_requestor']['email_requestor-action']['selected_options'] - notify_requestor = False - if len(checkboxes) > 0: - notify_requestor = True - - # Obtain user's RT token - user_id = payload['user']['id'] - token = __retrieve_rt_token(user_id) - - __update_ticket(ticket_id, status, owner_id, comments, notify_requestor, token, user_id, channel, ts) - return HttpResponse() - elif callback_id == "ticket-comment-modal": - ticket_id = payload['view']['blocks'][0]['block_id'] - comments = values[ticket_id]['comment-action']['value'] - user_id = payload['user']['id'] - token = __retrieve_rt_token(user_id) - __post_ticket_comment(ticket_id, user_id, comments, token) - return HttpResponse() - elif callback_id == "report-modal": - message_id = payload['view']['blocks'][0]['block_id'] - comments = values['report-comment']['comment-action']['value'] - reporter = payload['user']['id'] - __save_report(message_id, reporter, comments) - return HttpResponse() - return HttpResponseNotFound() - - # Handle block interaction event - if interaction_type == "block_actions": - action = payload['actions'][0]['action_id'] - channel = payload.get('channel', None) - if channel: - channel = channel['id'] - message = payload.get('message', None) - view = payload.get('view', None) - - # TFed message - if channel in [settings.SLACK_TARGET_TFED, settings.SLACK_TARGET_TFED_DB] and message and not view: - ticket_id = message['blocks'][0]['block_id'].split('~')[0] - blocks = views.ticket_update_modal(ticket_id, channel, message['ts'], action) - - # Get current ticket from RT - __refresh_ticket_async(channel, message) - - # Check that user has token, if not display a warning - user_id = payload['user']['id'] - token = __retrieve_rt_token(user_id) - if not token: - error_message = "Hi there! Before you can update tickets, you'll need to set up access to your RT " \ - "account. Visit https://lnl.wpi.edu" + reverse("support:link-account") + \ - " to get started." - post_ephemeral(channel, error_message, user_id, 'Request Tracker') - return HttpResponse() - - modal_id = open_modal(payload.get('trigger_id', None), blocks) - if modal_id: - return HttpResponse() - return HttpResponseServerError("Failed to open modal") - - # Home tab menu options - if action == "home-ticket-update": - ticket_id = payload['actions'][0]['block_id'] - option = payload['actions'][0]['selected_option']['value'] - if option == 'Comment': - blocks = views.ticket_comment_modal(ticket_id) - modal_id = open_modal(payload.get('trigger_id', None), blocks) - if not modal_id: - return HttpResponseServerError("Failed to open modal") - return HttpResponse() - return HttpResponseNotFound() - - -@process_in_thread -def __create_ticket(user, subject, description, topic): - """ - Handler for creating a new TFed ticket - - :param user: The user that submitted the ticket - :param subject: The ticket's subject line - :param description: The contents of the ticket - :param topic: The Queue in RT to post the ticket to - """ - - target = settings.SLACK_TARGET_TFED - if topic == 'Database': - target = settings.SLACK_TARGET_TFED_DB - user_email = user['user']['profile'].get('email', 'lnl-no-reply@wpi.edu') - display_name = user['user']['profile']['real_name'] - resp = rt_api.create_ticket(topic, user_email, subject, description + "\n\n- " + display_name) - ticket_id = resp.get('id', None) - if ticket_id: - ticket_info = { - "url": 'https://lnl-rt.wpi.edu/rt/Ticket/Display.html?id=' + ticket_id, - "id": ticket_id, - "subject": subject, - "description": description, - "status": "New", - "assignee": None, - "reporter": user['user']['name'] - } - ticket = views.tfed_ticket(ticket_info) - slack_post(target, text=description, content=ticket, username='Request Tracker') - return - error_message = "Whoops! It appears something went wrong while attempting to submit your request. " \ - "Please wait a few minutes then try again. If the problem persists, please email " \ - "us directly at tfed@wpi.edu." - post_ephemeral(target, error_message, user['user']['id'], username="Request Tracker") - - -@process_in_thread -def __update_ticket(ticket_id, status, owner_id, comments, notify_requestor, token, user_id, channel, ts): - """ - Handler for updating an existing TFed ticket - - :param ticket_id: The ticket number - :param status: The new status to assign to the ticket in RT - :param owner_id: The Slack user ID for the ticket owner (who the ticket will be assigned to) - :param comments: Comments to add to the ticket history - :param notify_requestor: If True, the ticket creator will receive an email with the comments - :param token: The RT auth token for the user that triggered this action - :param user_id: The Slack user ID for the user that triggered this action - :param channel: The identifier of the Slack channel this ticket was posted to - :param ts: The timestamp of the original ticket message in Slack - """ - - # Update ticket metadata - owner = user_profile(owner_id) - username = '' - if owner['ok']: - username = owner['user']['profile'].get('email', '').split('@')[0] - resp = rt_api.update_ticket(ticket_id, token, status, username) - if rt_api.permission_error(resp): - error_message = "Sorry, it appears you do not have permission to perform this action." - post_ephemeral(channel, error_message, user_id, 'Request Tracker') - return - - # Update ticket in Slack - current_message = retrieve_message(channel, ts) - if current_message.get('error', '') == 'not_in_channel': - join_channel(channel) - current_message = retrieve_message(channel, ts) - resp = refresh_ticket_message(channel, current_message['messages'][0]) - if not resp['ok']: - logger.warning("Failed to update ticket in Slack. Please check RT to see if your changes were applied.") - - # Post comments / replies, if applicable - if comments: - slack_user = user_profile(user_id) - display_name = slack_user['user']['profile']['real_name'] - resp = rt_api.ticket_comment(ticket_id, comments + "\n\n- " + display_name, notify_requestor, - token=token) - if rt_api.permission_error(resp): - error_message = "Sorry, it appears you do not have permission to perform this action." - post_ephemeral(channel, error_message, user_id, 'Request Tracker') - return - - profile_photo = slack_user['user']['profile']['image_original'] - slack_post(channel, ts, comments, username=display_name, icon_url=profile_photo) - - -@process_in_thread -def __post_ticket_comment(ticket_id, user_id, comments, token): - """ - Comment on a TFed ticket (background process). - - :param ticket_id: The ticket number - :param user_id: The Slack user ID for the user that triggered the action - :param comments: The comments to be added to the ticket - :param token: The RT auth token for the user that triggered the action (if applicable) - """ - - user = user_profile(user_id) - display_name = user['user']['profile']['real_name'] - rt_api.ticket_comment(ticket_id, comments + "\n\n- " + display_name, True, token=token) - - -def refresh_ticket_message(channel, message): - """ - Update a TFed ticket message with the latest information - - :param channel: The channel the ticket was posted to - :param message: The original message object - :return: Response from Slack API after attempting to update the message - """ - - ticket_id = message['blocks'][0]['block_id'].split('~')[0] - ticket_reporter = message['blocks'][0]['block_id'].split('~')[1] - ticket_description = message['blocks'][1]['text']['text'] - ticket = rt_api.fetch_ticket(ticket_id) - if ticket.get('message'): - return {"ok": False} - ticket_owner = ticket['Owner']['id'] - if ticket_owner == "Nobody": - ticket_owner = None - ticket_info = { - "url": 'https://lnl-rt.wpi.edu/rt/Ticket/Display.html?id=' + ticket_id, - "id": ticket_id, - "subject": ticket.get('Subject'), - "description": ticket_description, - "status": ticket.get('Status').capitalize(), - "assignee": ticket_owner, - "reporter": ticket_reporter - } - new_message = views.tfed_ticket(ticket_info) - return replace_message(channel, message['ts'], ticket_description, new_message) - - -@process_in_thread -def __refresh_ticket_async(channel, message): - """ - Update a TFed ticket message with the latest information in the background - - :param channel: The channel the ticket was posted to - :param message: The original message object - :return: Response from Slack API after attempting to update the message - """ - - resp = refresh_ticket_message(channel, message) - if not resp['ok']: - logger.warning("Failed to update ticket in Slack. Please check RT to see if your changes were applied.") - - -def __retrieve_rt_token(user_id): - """ - Retrieve a user's RT auth token (if it exists) - - :param user_id: The Slack user's identifier - :return: Auth token; `None` if it doesn't exist - """ - - slack_user = user_profile(user_id) - if slack_user['ok']: - username = slack_user['user']['profile'].get('email', '').split('@')[0] - user = get_user_model().objects.filter(username=username).first() - if user: - prefs = UserPreferences.objects.filter(user=user).first() - if prefs: - if prefs.rt_token: - cipher_suite = Fernet(settings.CRYPTO_KEY) - return cipher_suite.decrypt(prefs.rt_token.encode('utf-8')).decode('utf-8') - return None - - -@process_in_thread -def __save_report(message_id, reporter, comments): - """ - Create a report when a user reports a problematic Slack message - - :param message_id: The primary key value of the corresponding SlackMessage object - :param reporter: Slack user ID for the user that reported the message - :param comments: Optional comments for the report - """ - - message = models.SlackMessage.objects.get(pk=message_id) - - # Ensure message was posted to public channel. For privacy reasons, we currently do not report private messages. - channel_details = channel_info(message.posted_to) - if channel_details['is_channel'] and not channel_details['is_private']: - report = models.ReportedMessage.objects.create(message=message, comments=comments, reported_by=reporter) - - # Send Exec a notification - blocks = views.reported_message_notification(reporter, report) - slack_post(settings.SLACK_TARGET_EXEC, text="You have a new flagged message to review", content=blocks, - username="Admin Console") - - # Add red flag to message (to inform sender their message has been reported) - # message_react(message.posted_to, message.ts, 'triangular_flag_on_post') - else: - message.public = False - message.save() - - post_ephemeral(message.posted_to, "This feature currently does not support reporting private messages. Please " - "contact a member of the executive board directly.", reporter) - connection.close() diff --git a/slack/event_handlers.py b/slack/event_handlers.py new file mode 100644 index 00000000..25d28927 --- /dev/null +++ b/slack/event_handlers.py @@ -0,0 +1,427 @@ +import json +import logging +from cryptography.fernet import Fernet +from django.conf import settings +from django.shortcuts import reverse +from django.db import connection +from django.http import HttpResponse, HttpResponseNotFound, HttpResponseServerError, JsonResponse +from django.views.decorators.http import require_POST +from django.views.decorators.csrf import csrf_exempt +from django.contrib.auth import get_user_model +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +from accounts.models import UserPreferences +from data.decorators import process_in_thread +from rt import api as rt_api +from slack.api import slack_post, user_profile, open_modal, post_ephemeral, retrieve_message, replace_message, \ + channel_info, join_channel, message_react + +from .models import SlackMessage, ReportedMessage +from . import views + +logger = logging.getLogger(__name__) + + +# Event Handlers +@csrf_exempt +@require_POST +def handle_event(request): + """ + Event endpoint for the Slack API. Slack will send POST requests here whenever certain events have been triggered. + """ + + payload = json.loads(request.body) + if payload['type'] == "url_verification": + return JsonResponse({"challenge": payload['challenge']}) + elif payload['type'] == "event_callback": + event = payload['event'] + if event['type'] == "team_join": + slack_post(event['user']['id'], text="Welcome to LNL!", content=views.welcome_message()) + elif event['type'] == "app_home_opened": + load_app_home(event['user']) + elif event['type'] == "channel_created": + if settings.SLACK_AUTO_JOIN: + join_channel(event['channel']['id']) + return HttpResponse() + return HttpResponse("Not implemented") + + +@process_in_thread +def load_app_home(user_id): + """ + Load the App's Home tab. + + :param user_id: The identifier for the user in Slack + :return: Response object (Dictionary) + """ + + ticket_ids = [] + tickets = [] + user = user_profile(user_id) + if user['ok']: + email = user['user']['profile']['email'] + ticket_ids = sorted(rt_api.simple_ticket_search(requestor=email, status="__Active__"), reverse=True) + for ticket_id in ticket_ids: + ticket = rt_api.fetch_ticket(ticket_id) + if ticket.get('message'): + continue + tickets.append(ticket) + blocks = views.app_home(tickets) + + if not settings.SLACK_TOKEN: + return {'ok': False, 'error': 'config_error'} + + client = WebClient(token=settings.SLACK_TOKEN) + + try: + response = client.views_publish(user_id=user_id, view={"type": "home", "blocks": blocks}) + assert response['ok'] is True + return response + except SlackApiError as e: + assert e.response['ok'] is False + return e.response + + +# Interaction handlers +@csrf_exempt +@require_POST +def handle_interaction(request): + """ + Interaction endpoint for the Slack API. Slack will send POST requests here when users interact with a shortcut or + interactive component. + """ + + payload = json.loads(request.POST['payload']) + interaction_type = payload.get('type', None) + + # Handle shortcut + if interaction_type == "shortcut": + callback_id = payload.get('callback_id', None) + if callback_id == "tfed": + blocks = views.tfed_modal() + modal_id = open_modal(payload.get('trigger_id', None), blocks) + if modal_id: + return HttpResponse() + return HttpResponseServerError("Failed to open modal") + if interaction_type == "message_action": + callback_id = payload.get('callback_id', None) + if callback_id == "report": + channel = payload.get('channel', {'id': None})['id'] + sender = payload['message'].get('user', None) + if not sender: + sender = payload['message']['username'] + ts = payload['message']['ts'] + text = payload['message']['text'] + message, created = SlackMessage.objects.get_or_create(posted_to=channel, posted_by=sender, ts=ts, + content=text) + blocks = views.report_message_modal(message) + modal_id = open_modal(payload.get('trigger_id', None), blocks) + if modal_id: + return HttpResponse() + return HttpResponseServerError("Failed to open modal") + + # Handle modal view submission + if interaction_type == "view_submission": + values = payload['view']['state']['values'] + callback_id = payload['view'].get('callback_id', None) + + # TFed ticket submission + if callback_id == "tfed-modal": + subject = values['subject']['subject-action']['value'] + description = values['description']['description-action']['value'] + topic = values['rt_topic']['rt_topic-action']['selected_option']['value'] + user_id = payload['user']['id'] + user = user_profile(user_id) + if user['ok']: + __create_ticket(user, subject, description, topic) + return HttpResponse() + return HttpResponseServerError("Failed to obtain user information") + + # Update TFed ticket + elif callback_id == "ticket-update-modal": + ticket_info = payload['view']['blocks'][1] + owner_id = None + if ticket_info['type'] != "divider": + ticket_info = payload['view']['blocks'][2] + owner_id = values['ticket_assignee']['ticket_assignee-action']['selected_user'] + ticket_id = ticket_info['block_id'].split("#")[0] + channel = ticket_info['block_id'].split("#")[1] + ts = ticket_info['block_id'].split("#")[2] + status = values['ticket_status']['ticket_status-action']['selected_option'] + if status: + status = status['value'] + comments = values['ticket_comment']['ticket_comment-action']['value'] + checkboxes = values['email_requestor']['email_requestor-action']['selected_options'] + notify_requestor = False + if len(checkboxes) > 0: + notify_requestor = True + + # Obtain user's RT token + user_id = payload['user']['id'] + token = __retrieve_rt_token(user_id) + + __update_ticket(ticket_id, status, owner_id, comments, notify_requestor, token, user_id, channel, ts) + return HttpResponse() + elif callback_id == "ticket-comment-modal": + ticket_id = payload['view']['blocks'][0]['block_id'] + comments = values[ticket_id]['comment-action']['value'] + user_id = payload['user']['id'] + token = __retrieve_rt_token(user_id) + __post_ticket_comment(ticket_id, user_id, comments, token) + return HttpResponse() + elif callback_id == "report-modal": + message_id = payload['view']['blocks'][0]['block_id'] + comments = values['report-comment']['comment-action']['value'] + reporter = payload['user']['id'] + __save_report(message_id, reporter, comments) + return HttpResponse() + return HttpResponseNotFound() + + # Handle block interaction event + if interaction_type == "block_actions": + action = payload['actions'][0]['action_id'] + channel = payload.get('channel', None) + if channel: + channel = channel['id'] + message = payload.get('message', None) + view = payload.get('view', None) + + # TFed message + if channel in [settings.SLACK_TARGET_TFED, settings.SLACK_TARGET_TFED_DB] and message and not view: + ticket_id = message['blocks'][0]['block_id'].split('~')[0] + blocks = views.ticket_update_modal(ticket_id, channel, message['ts'], action) + + # Get current ticket from RT + __refresh_ticket_async(channel, message) + + # Check that user has token, if not display a warning + user_id = payload['user']['id'] + token = __retrieve_rt_token(user_id) + if not token: + error_message = "Hi there! Before you can update tickets, you'll need to set up access to your RT " \ + "account. Visit https://lnl.wpi.edu" + reverse("support:link-account") + \ + " to get started." + post_ephemeral(channel, error_message, user_id, 'Request Tracker') + return HttpResponse() + + modal_id = open_modal(payload.get('trigger_id', None), blocks) + if modal_id: + return HttpResponse() + return HttpResponseServerError("Failed to open modal") + + # Home tab menu options + if action == "home-ticket-update": + ticket_id = payload['actions'][0]['block_id'] + option = payload['actions'][0]['selected_option']['value'] + if option == 'Comment': + blocks = views.ticket_comment_modal(ticket_id) + modal_id = open_modal(payload.get('trigger_id', None), blocks) + if not modal_id: + return HttpResponseServerError("Failed to open modal") + return HttpResponse() + return HttpResponseNotFound() + + +@process_in_thread +def __create_ticket(user, subject, description, topic): + """ + Handler for creating a new TFed ticket + + :param user: The user that submitted the ticket + :param subject: The ticket's subject line + :param description: The contents of the ticket + :param topic: The Queue in RT to post the ticket to + """ + + target = settings.SLACK_TARGET_TFED + if topic == 'Database': + target = settings.SLACK_TARGET_TFED_DB + user_email = user['user']['profile'].get('email', 'lnl-no-reply@wpi.edu') + display_name = user['user']['profile']['real_name'] + resp = rt_api.create_ticket(topic, user_email, subject, description + "\n\n- " + display_name) + ticket_id = resp.get('id', None) + if ticket_id: + ticket_info = { + "url": 'https://lnl-rt.wpi.edu/rt/Ticket/Display.html?id=' + ticket_id, + "id": ticket_id, + "subject": subject, + "description": description, + "status": "New", + "assignee": None, + "reporter": user['user']['name'] + } + ticket = views.tfed_ticket(ticket_info) + slack_post(target, text=description, content=ticket, username='Request Tracker') + return + error_message = "Whoops! It appears something went wrong while attempting to submit your request. " \ + "Please wait a few minutes then try again. If the problem persists, please email " \ + "us directly at tfed@wpi.edu." + post_ephemeral(target, error_message, user['user']['id'], username="Request Tracker") + + +@process_in_thread +def __update_ticket(ticket_id, status, owner_id, comments, notify_requestor, token, user_id, channel, ts): + """ + Handler for updating an existing TFed ticket + + :param ticket_id: The ticket number + :param status: The new status to assign to the ticket in RT + :param owner_id: The Slack user ID for the ticket owner (who the ticket will be assigned to) + :param comments: Comments to add to the ticket history + :param notify_requestor: If True, the ticket creator will receive an email with the comments + :param token: The RT auth token for the user that triggered this action + :param user_id: The Slack user ID for the user that triggered this action + :param channel: The identifier of the Slack channel this ticket was posted to + :param ts: The timestamp of the original ticket message in Slack + """ + + # Update ticket metadata + owner = user_profile(owner_id) + username = '' + if owner['ok']: + username = owner['user']['profile'].get('email', '').split('@')[0] + resp = rt_api.update_ticket(ticket_id, token, status, username) + if rt_api.permission_error(resp): + error_message = "Sorry, it appears you do not have permission to perform this action." + post_ephemeral(channel, error_message, user_id, 'Request Tracker') + return + + # Update ticket in Slack + current_message = retrieve_message(channel, ts) + if current_message.get('error', '') == 'not_in_channel': + join_channel(channel) + current_message = retrieve_message(channel, ts) + resp = refresh_ticket_message(channel, current_message['messages'][0]) + if not resp['ok']: + logger.warning("Failed to update ticket in Slack. Please check RT to see if your changes were applied.") + + # Post comments / replies, if applicable + if comments: + slack_user = user_profile(user_id) + display_name = slack_user['user']['profile']['real_name'] + resp = rt_api.ticket_comment(ticket_id, comments + "\n\n- " + display_name, notify_requestor, + token=token) + if rt_api.permission_error(resp): + error_message = "Sorry, it appears you do not have permission to perform this action." + post_ephemeral(channel, error_message, user_id, 'Request Tracker') + return + + profile_photo = slack_user['user']['profile']['image_original'] + slack_post(channel, ts, comments, username=display_name, icon_url=profile_photo) + + +@process_in_thread +def __post_ticket_comment(ticket_id, user_id, comments, token): + """ + Comment on a TFed ticket (background process). + + :param ticket_id: The ticket number + :param user_id: The Slack user ID for the user that triggered the action + :param comments: The comments to be added to the ticket + :param token: The RT auth token for the user that triggered the action (if applicable) + """ + + user = user_profile(user_id) + display_name = user['user']['profile']['real_name'] + rt_api.ticket_comment(ticket_id, comments + "\n\n- " + display_name, True, token=token) + + +def refresh_ticket_message(channel, message): + """ + Update a TFed ticket message with the latest information + + :param channel: The channel the ticket was posted to + :param message: The original message object + :return: Response from Slack API after attempting to update the message + """ + + ticket_id = message['blocks'][0]['block_id'].split('~')[0] + ticket_reporter = message['blocks'][0]['block_id'].split('~')[1] + ticket_description = message['blocks'][1]['text']['text'] + ticket = rt_api.fetch_ticket(ticket_id) + if ticket.get('message'): + return {"ok": False} + ticket_owner = ticket['Owner']['id'] + if ticket_owner == "Nobody": + ticket_owner = None + ticket_info = { + "url": 'https://lnl-rt.wpi.edu/rt/Ticket/Display.html?id=' + ticket_id, + "id": ticket_id, + "subject": ticket.get('Subject'), + "description": ticket_description, + "status": ticket.get('Status').capitalize(), + "assignee": ticket_owner, + "reporter": ticket_reporter + } + new_message = views.tfed_ticket(ticket_info) + return replace_message(channel, message['ts'], ticket_description, new_message) + + +@process_in_thread +def __refresh_ticket_async(channel, message): + """ + Update a TFed ticket message with the latest information in the background + + :param channel: The channel the ticket was posted to + :param message: The original message object + :return: Response from Slack API after attempting to update the message + """ + + resp = refresh_ticket_message(channel, message) + if not resp['ok']: + logger.warning("Failed to update ticket in Slack. Please check RT to see if your changes were applied.") + + +def __retrieve_rt_token(user_id): + """ + Retrieve a user's RT auth token (if it exists) + + :param user_id: The Slack user's identifier + :return: Auth token; `None` if it doesn't exist + """ + + slack_user = user_profile(user_id) + if slack_user['ok']: + username = slack_user['user']['profile'].get('email', '').split('@')[0] + user = get_user_model().objects.filter(username=username).first() + if user: + prefs = UserPreferences.objects.filter(user=user).first() + if prefs: + if prefs.rt_token: + cipher_suite = Fernet(settings.CRYPTO_KEY) + return cipher_suite.decrypt(prefs.rt_token.encode('utf-8')).decode('utf-8') + return None + + +@process_in_thread +def __save_report(message_id, reporter, comments): + """ + Create a report when a user reports a problematic Slack message + + :param message_id: The primary key value of the corresponding SlackMessage object + :param reporter: Slack user ID for the user that reported the message + :param comments: Optional comments for the report + """ + + message = SlackMessage.objects.get(pk=message_id) + + # Ensure message was posted to public channel. For privacy reasons, we currently do not report private messages. + channel_details = channel_info(message.posted_to) + if channel_details['is_channel'] and not channel_details['is_private']: + report = ReportedMessage.objects.create(message=message, comments=comments, reported_by=reporter) + + # Send Exec a notification + blocks = views.reported_message_notification(reporter, report) + slack_post(settings.SLACK_TARGET_EXEC, text="You have a new flagged message to review", content=blocks, + username="Admin Console") + + # Add red flag to message (to inform sender their message has been reported) + # message_react(message.posted_to, message.ts, 'triangular_flag_on_post') + else: + message.public = False + message.save() + + post_ephemeral(message.posted_to, "This feature currently does not support reporting private messages. Please " + "contact a member of the executive board directly.", reporter) + connection.close() diff --git a/slack/models.py b/slack/models.py index 751a0cf9..3a7ec731 100755 --- a/slack/models.py +++ b/slack/models.py @@ -1,4 +1,6 @@ +import datetime from django.db import models +from .api import channel_info, channel_members, channel_latest_message # Create your models here. diff --git a/slack/urls.py b/slack/urls.py index 78988195..31259b93 100644 --- a/slack/urls.py +++ b/slack/urls.py @@ -1,12 +1,16 @@ from django.urls import re_path -from . import api, views +from . import api, views, event_handlers + +# URLs for DB users should start with `db/` + +# URLs for Slack app should start with nothing or `slack/` app_name = "Slack" urlpatterns = [ - re_path(r'^interactive-endpoint/$', api.handle_interaction, name="interactive-endpoint"), - re_path(r'^events/$', api.handle_event, name="event-endpoint"), - re_path(r'^moderate/$', views.report_list, name="moderate"), - re_path(r'^moderate/(?P\d+)/$', views.view_report, name="report") + re_path(r'^interactive-endpoint/$', event_handlers.handle_interaction, name="interactive-endpoint"), + re_path(r'^events/$', event_handlers.handle_event, name="event-endpoint"), + re_path(r'^db/moderate/$', views.report_list, name="moderate"), + re_path(r'^db/moderate/(?P\d+)/$', views.view_report, name="report"), ] diff --git a/slack/views.py b/slack/views.py index 163eb770..965c1051 100644 --- a/slack/views.py +++ b/slack/views.py @@ -2,7 +2,7 @@ from django.shortcuts import reverse, render, get_object_or_404 from django.http import HttpResponseRedirect -from .models import ReportedMessage +from .models import Channel, ReportedMessage from .api import lookup_user, user_profile, message_link, channel_info From 31d43ed88ed485e86d67cc9dc1f45f458ab4227a Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Wed, 1 Jan 2025 00:46:17 -0800 Subject: [PATCH 02/34] Add Slack Channel List and WIP Channel Detail --- site_tmpl/admin.nav.html | 4 +- site_tmpl/slack/slack_channel_detail.html | 20 +++++++ site_tmpl/slack/slack_channel_list.html | 28 +++++++++ slack/admin.py | 3 + slack/models.py | 69 +++++++++++++++++++++++ slack/urls.py | 2 + slack/views.py | 20 +++++++ 7 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 site_tmpl/slack/slack_channel_detail.html create mode 100644 site_tmpl/slack/slack_channel_list.html diff --git a/site_tmpl/admin.nav.html b/site_tmpl/admin.nav.html index 73510f84..0384a61d 100644 --- a/site_tmpl/admin.nav.html +++ b/site_tmpl/admin.nav.html @@ -121,7 +121,9 @@ {% permission request.user has 'emails.send' or request.user has 'events.edit_event_hours' %}
  • Send Message...
  • {% permission request.user has 'slack.view_reportedmessage' %} -
  • Moderate Slack
  • +
  • +
  • Slack Channels
  • +
  • Slack Moderation
  • {% endpermission %}
  • {% endpermission %} diff --git a/site_tmpl/slack/slack_channel_detail.html b/site_tmpl/slack/slack_channel_detail.html new file mode 100644 index 00000000..7dddcde9 --- /dev/null +++ b/site_tmpl/slack/slack_channel_detail.html @@ -0,0 +1,20 @@ +{% extends 'base_admin.html' %} +{% load permissionif %} +{% block title %}{{h2}} | Lens and Lights at WPI{% endblock %} +{% block content %} +
    +
    +
    +

    {% if channel.private %} {% else %}#{% endif %}{{ channel.name }}

    +
    +
    +

    {{ channel.num_members }} members

    +

    Last Updated: {{ channel.last_updated }}

    + Created: {{ channel.created }} + +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/site_tmpl/slack/slack_channel_list.html b/site_tmpl/slack/slack_channel_list.html new file mode 100644 index 00000000..57ba7e10 --- /dev/null +++ b/site_tmpl/slack/slack_channel_list.html @@ -0,0 +1,28 @@ +{% extends 'base_admin.html' %} +{% load permissionif %} +{% block title %}{{h2}} | Lens and Lights at WPI{% endblock %} +{% block content %} +

    {{ h2 }}

    + + + + + + + + + + + {% for channel in channels %} + + + + + + + + + {% endfor %} +
    Channel NameArchivedMembersLast UpdatedGroups AllowedGroups Required
    {% if channel.private %} {% else %}#{% endif %}{{ channel.name }}{{ channel.num_members }}{{ channel.last_updated }}{{ channel.groups_allowed }}{{ channel.groups_required }}
    +
    Total:{{ channels | length }}
    +{% endblock %} \ No newline at end of file diff --git a/slack/admin.py b/slack/admin.py index 8c38f3f3..1b000461 100755 --- a/slack/admin.py +++ b/slack/admin.py @@ -1,3 +1,6 @@ from django.contrib import admin +from slack.models import Channel + # Register your models here. +admin.site.register(Channel) \ No newline at end of file diff --git a/slack/models.py b/slack/models.py index 3a7ec731..0879893e 100755 --- a/slack/models.py +++ b/slack/models.py @@ -35,3 +35,72 @@ class ReportedMessage(models.Model): reported_by = models.CharField(max_length=12) reported_on = models.DateTimeField(auto_now_add=True) resolved = models.BooleanField(default=False) + +class Channel(models.Model): + """ + Used to store Slack channel ID and retrieve common information about Slack channels + """ + id = models.CharField(max_length=256, unique=True, primary_key=True) + + def __str__(self): + return self.name + + @property + def name(self) -> str: + try: + info = channel_info(self.id) + if 'name' in info: + return channel_info(self.id)['name'] + except: + pass + return "Unknown ("+str(self.id)+")" + + @property + def private(self) -> bool: + try: + info = channel_info(self.id) + return 'is_private' in info and channel_info(self.id)['is_private'] + except: + return True + + @property + def archived(self) -> bool: + try: + info = channel_info(self.id) + return 'is_archived' in info and channel_info(self.id)['is_archived'] + except: + return True + + @property + def num_members(self) -> int: + try: + info = channel_info(self.id, num_members=True) + return info['num_members'] + except: + return None + + @property + def members(self) -> list: + try: + return channel_members(self.id) + except: + return [] + + @property + def last_updated(self) -> datetime.datetime: + try: + msg = channel_latest_message(self.id) + return datetime.datetime.fromtimestamp(msg['ts']) + except: + return None + + @property + def created(self) -> datetime.datetime: + try: + info = channel_info(self.id) + return datetime.datetime.fromtimestamp(info['created']) + except: + return None + + class Meta: + permissions = () \ No newline at end of file diff --git a/slack/urls.py b/slack/urls.py index 31259b93..96894919 100644 --- a/slack/urls.py +++ b/slack/urls.py @@ -13,4 +13,6 @@ re_path(r'^events/$', event_handlers.handle_event, name="event-endpoint"), re_path(r'^db/moderate/$', views.report_list, name="moderate"), re_path(r'^db/moderate/(?P\d+)/$', views.view_report, name="report"), + re_path(r'^db/channels/$', views.channel_list, name="channel-list"), + re_path(r'^db/channel/(?P[^/]+)/$', views.channel_detail, name="channel"), ] diff --git a/slack/views.py b/slack/views.py index 965c1051..f7865cf7 100644 --- a/slack/views.py +++ b/slack/views.py @@ -7,6 +7,26 @@ # Slack Management Views + +@login_required +@permission_required('slack.view_channel', raise_exception=True) +def channel_list(request): + """ + View a list of all Slack channels + """ + channels = Channel.objects.all() + return render(request, 'slack/slack_channel_list.html', {'h2': 'Slack Channels', 'channels': channels}) + +@login_required +@permission_required('slack.view_channel', raise_exception=True) +def channel_detail(request, id): + """ + View details for a specific Slack channel + """ + channel = get_object_or_404(Channel, id=id) + return render(request, 'slack/slack_channel_detail.html', {'h2': "#"+channel.name+' Details', 'channel': channel}) + + @login_required @permission_required('slack.view_reportedmessage', raise_exception=True) def report_list(request): From 53bf10cb705767627c6d4e3ed40c17727b6d709c Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Wed, 1 Jan 2025 23:59:58 -0800 Subject: [PATCH 03/34] Improve channel details --- site_tmpl/slack/slack_channel_detail.html | 57 ++++++++++++++++++- ...ove_channel_channel_id_alter_channel_id.py | 23 ++++++++ slack/models.py | 27 +++++++-- slack/views.py | 8 ++- 4 files changed, 107 insertions(+), 8 deletions(-) create mode 100644 slack/migrations/0004_remove_channel_channel_id_alter_channel_id.py diff --git a/site_tmpl/slack/slack_channel_detail.html b/site_tmpl/slack/slack_channel_detail.html index 7dddcde9..4185c789 100644 --- a/site_tmpl/slack/slack_channel_detail.html +++ b/site_tmpl/slack/slack_channel_detail.html @@ -5,16 +5,67 @@
    -

    {% if channel.private %} {% else %}#{% endif %}{{ channel.name }}

    + +

    {% if channel.private %} {% else %}#{% endif %}{{ channel.name }}

    +

    {{ channel.num_members }} members

    Last Updated: {{ channel.last_updated }}

    - Created: {{ channel.created }} -
    + + Open in Slack +
    +
    + +

    Channel Configuration

    + + + + + + + + + +
    Groups Allowed{{ channel.groups_allowed }}
    Groups Required{{ channel.groups_required }}
    +

    Channel Details

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Visibility{% if channel.private != None %}{% if channel.private %} Private{% else %}Public{% endif %}{% endif %}
    Archived{{ channel.archived }}
    Topic{{ channel.info.topic.value}}
    Purpose{{ channel.info.purpose.value}}
    Created by{% if creator_name %}{{ creator_name }}{% endif %}
    Created on {{ channel.created_on }}
    Previous Names{{ channel.info.previous_names }}
    + + {% endblock %} \ No newline at end of file diff --git a/slack/migrations/0004_remove_channel_channel_id_alter_channel_id.py b/slack/migrations/0004_remove_channel_channel_id_alter_channel_id.py new file mode 100644 index 00000000..c674274e --- /dev/null +++ b/slack/migrations/0004_remove_channel_channel_id_alter_channel_id.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.13 on 2025-01-02 06:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("slack", "0003_channel"), + ] + + operations = [ + migrations.RemoveField( + model_name="channel", + name="channel_id", + ), + migrations.AlterField( + model_name="channel", + name="id", + field=models.CharField( + max_length=256, primary_key=True, serialize=False, unique=True + ), + ), + ] diff --git a/slack/models.py b/slack/models.py index 0879893e..361da42a 100755 --- a/slack/models.py +++ b/slack/models.py @@ -1,6 +1,7 @@ import datetime from django.db import models -from .api import channel_info, channel_members, channel_latest_message +from django.contrib.auth import get_user_model +from .api import channel_info, channel_members, channel_latest_message, user_profile # Create your models here. @@ -61,7 +62,7 @@ def private(self) -> bool: info = channel_info(self.id) return 'is_private' in info and channel_info(self.id)['is_private'] except: - return True + return None @property def archived(self) -> bool: @@ -69,7 +70,7 @@ def archived(self) -> bool: info = channel_info(self.id) return 'is_archived' in info and channel_info(self.id)['is_archived'] except: - return True + return None @property def num_members(self) -> int: @@ -95,12 +96,30 @@ def last_updated(self) -> datetime.datetime: return None @property - def created(self) -> datetime.datetime: + def created_on(self) -> datetime.datetime: try: info = channel_info(self.id) return datetime.datetime.fromtimestamp(info['created']) except: return None + + @property + def creator(self) -> str: + try: + slack_user_id = channel_info(self.id)['creator'] + slack_user = user_profile(slack_user_id) + if slack_user['ok']: + username = slack_user['user']['profile'].get('email', '').split('@')[0] + return get_user_model().objects.filter(username=username).first() + except: + return None + + @property + def info(self) -> dict: + try: + return channel_info(self.id) + except: + return None class Meta: permissions = () \ No newline at end of file diff --git a/slack/views.py b/slack/views.py index f7865cf7..05955c9b 100644 --- a/slack/views.py +++ b/slack/views.py @@ -24,7 +24,13 @@ def channel_detail(request, id): View details for a specific Slack channel """ channel = get_object_or_404(Channel, id=id) - return render(request, 'slack/slack_channel_detail.html', {'h2': "#"+channel.name+' Details', 'channel': channel}) + return render(request, 'slack/slack_channel_detail.html', + {'h2': "#"+channel.name+' Details', + 'channel': channel, + 'creator_name': channel.creator.get_full_name() if channel.creator else None, + 'groups_allowed': channel.allowed_groups.all(), # TODO: Fix implementation + 'groups_required': channel.required_groups.all()}) # TODO: Fix implementation + @login_required From 8edc09824d56af4a2c6353dcfdaa687774b90162 Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Thu, 2 Jan 2025 00:00:43 -0800 Subject: [PATCH 04/34] Update slack_channel_list.html --- site_tmpl/slack/slack_channel_list.html | 5 +++-- slack/views.py | 6 +++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/site_tmpl/slack/slack_channel_list.html b/site_tmpl/slack/slack_channel_list.html index 57ba7e10..232d5417 100644 --- a/site_tmpl/slack/slack_channel_list.html +++ b/site_tmpl/slack/slack_channel_list.html @@ -15,8 +15,9 @@

    {{ h2 }}

    {% for channel in channels %} - {% if channel.private %} {% else %}#{% endif %}{{ channel.name }} - + {% if channel.private %} {% else %}#{% endif %}{{ channel.name }} Open in Slack + + {{ channel.num_members }} {{ channel.last_updated }} {{ channel.groups_allowed }} diff --git a/slack/views.py b/slack/views.py index 05955c9b..03ad0461 100644 --- a/slack/views.py +++ b/slack/views.py @@ -2,6 +2,8 @@ from django.shortcuts import reverse, render, get_object_or_404 from django.http import HttpResponseRedirect +from lnldb import settings + from .models import Channel, ReportedMessage from .api import lookup_user, user_profile, message_link, channel_info @@ -15,7 +17,9 @@ def channel_list(request): View a list of all Slack channels """ channels = Channel.objects.all() - return render(request, 'slack/slack_channel_list.html', {'h2': 'Slack Channels', 'channels': channels}) + return render(request, 'slack/slack_channel_list.html', + {'h2': 'Slack Channels', + 'channels': channels, @login_required @permission_required('slack.view_channel', raise_exception=True) From b65e54df6bf89172d7c54430aefda9a701886dc4 Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Thu, 2 Jan 2025 00:01:03 -0800 Subject: [PATCH 05/34] Add env var for SLACK_BASE_URL --- lnldb/settings.py | 1 + slack/templatetags/slack.py | 14 +++++++++++++- slack/views.py | 2 ++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/lnldb/settings.py b/lnldb/settings.py index 2e881020..29e5ebad 100644 --- a/lnldb/settings.py +++ b/lnldb/settings.py @@ -104,6 +104,7 @@ def from_runtime(*x): GRAPH_API_SECRET = env.str('GRAPH_API_SECRET', '') GRAPH_API_ENDPOINT = env.str('GRAPH_API_ENDPOINT', '') +SLACK_BASE_URL = env.str('SLACK_BASE_URL', 'https://wpilnl.slack.com') SLACK_TOKEN = env.str('SLACK_BOT_TOKEN', None) # If True, the bot will automatically attempt to join new channels when they are created in Slack diff --git a/slack/templatetags/slack.py b/slack/templatetags/slack.py index 6e056b7d..b536a1dc 100644 --- a/slack/templatetags/slack.py +++ b/slack/templatetags/slack.py @@ -1,7 +1,9 @@ import re from django import template +from lnldb import settings from django.template.defaultfilters import stringfilter +from django.contrib.auth import get_user_model from ..api import user_profile, channel_info @@ -19,7 +21,7 @@ def slack(value): for m in it: r = m.groupdict() channel = r['channel_id'] - new_value = new_value.replace(m.group(), '[#%s](https://wpilnl.slack.com/app_redirect?channel=%s)' % + new_value = new_value.replace(m.group(), '[#%s]('+settings.SLACK_BASE_URL+'/app_redirect?channel=%s)' % (channel, channel)) return new_value @@ -33,6 +35,16 @@ def id_to_name(identifier): return slack_user['user']['profile']['real_name'] return identifier +@register.filter +@stringfilter +def id_to_user_pk(identifier): + """ Attempts to replace a Slack user's ID with their user pk """ + slack_user = user_profile(identifier) + if slack_user['ok']: + username = slack_user['user']['profile'].get('email', '').split('@')[0] + return get_user_model().objects.filter(username=username).first().pk + return None + @register.filter @stringfilter diff --git a/slack/views.py b/slack/views.py index 03ad0461..6f18227c 100644 --- a/slack/views.py +++ b/slack/views.py @@ -20,6 +20,7 @@ def channel_list(request): return render(request, 'slack/slack_channel_list.html', {'h2': 'Slack Channels', 'channels': channels, + 'slack_base_url': settings.SLACK_BASE_URL+'/archives/'}) @login_required @permission_required('slack.view_channel', raise_exception=True) @@ -32,6 +33,7 @@ def channel_detail(request, id): {'h2': "#"+channel.name+' Details', 'channel': channel, 'creator_name': channel.creator.get_full_name() if channel.creator else None, + 'slack_base_url': settings.SLACK_BASE_URL+'/archives/', 'groups_allowed': channel.allowed_groups.all(), # TODO: Fix implementation 'groups_required': channel.required_groups.all()}) # TODO: Fix implementation From 2cea5fdfc20b61c65ad368511c5f34b32c338bb7 Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Thu, 2 Jan 2025 00:01:23 -0800 Subject: [PATCH 06/34] Add channel migration --- slack/migrations/0003_channel.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 slack/migrations/0003_channel.py diff --git a/slack/migrations/0003_channel.py b/slack/migrations/0003_channel.py new file mode 100644 index 00000000..4aa78596 --- /dev/null +++ b/slack/migrations/0003_channel.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.13 on 2025-01-01 07:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("slack", "0002_reportedmessage"), + ] + + operations = [ + migrations.CreateModel( + name="Channel", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("channel_id", models.CharField(max_length=256, unique=True)), + ], + options={ + "permissions": (), + }, + ), + ] From 1410fca212f9e739c2150476fc3f5e0af983538c Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Thu, 2 Jan 2025 20:48:33 -0800 Subject: [PATCH 07/34] move slack pages to /db/slack/* --- lnldb/urls.py | 3 ++- slack/urls.py | 10 ++++------ slack/urls_app.py | 14 ++++++++++++++ 3 files changed, 20 insertions(+), 7 deletions(-) create mode 100644 slack/urls_app.py diff --git a/lnldb/urls.py b/lnldb/urls.py index 4da63fc3..3b690634 100644 --- a/lnldb/urls.py +++ b/lnldb/urls.py @@ -54,7 +54,8 @@ re_path(r'', include(('accounts.urls', 'accounts'), namespace='accounts')), re_path(r'', include(('members.urls', 'members'), namespace='members')), re_path(r'^api/', include(('api.urls', 'api'), namespace='api')), - re_path(r'^slack/', include(('slack.urls', 'slack'), namespace='slack')), + re_path(r'^db/slack/', include(('slack.urls', 'slack'), namespace='slack')), + re_path(r'^', include(('slack.urls_app', 'slackapp'), namespace='slackapp')), re_path(r'^mdm/', include(('devices.urls.mdm', 'mdm'), namespace="mdm")), re_path(r'^support/', include(('rt.urls', 'support'), namespace='support')), re_path(r'^spotify/', include(('spotify.urls', 'spotify'), namespace='spotify')), diff --git a/slack/urls.py b/slack/urls.py index 96894919..42e3c38d 100644 --- a/slack/urls.py +++ b/slack/urls.py @@ -9,10 +9,8 @@ app_name = "Slack" urlpatterns = [ - re_path(r'^interactive-endpoint/$', event_handlers.handle_interaction, name="interactive-endpoint"), - re_path(r'^events/$', event_handlers.handle_event, name="event-endpoint"), - re_path(r'^db/moderate/$', views.report_list, name="moderate"), - re_path(r'^db/moderate/(?P\d+)/$', views.view_report, name="report"), - re_path(r'^db/channels/$', views.channel_list, name="channel-list"), - re_path(r'^db/channel/(?P[^/]+)/$', views.channel_detail, name="channel"), + re_path(r'^moderate/$', views.report_list, name="moderate"), + re_path(r'^moderate/(?P\d+)/$', views.view_report, name="report"), + re_path(r'^channels/$', views.channel_list, name="channel-list"), + re_path(r'^channel/(?P[^/]+)/$', views.channel_detail, name="channel"), ] diff --git a/slack/urls_app.py b/slack/urls_app.py new file mode 100644 index 00000000..9a48c65a --- /dev/null +++ b/slack/urls_app.py @@ -0,0 +1,14 @@ +from django.urls import re_path +from . import api, views, event_handlers + +# URLs for DB users should start with `db/` + +# URLs for Slack app should start with nothing or `slack/` + + +app_name = "SlackApp" + +urlpatterns = [ + re_path(r'^interactive-endpoint/$', event_handlers.handle_interaction, name="interactive-endpoint"), + re_path(r'^events/$', event_handlers.handle_event, name="event-endpoint"), +] From c96bf73bf88579cb30deafd5c0095b7569ee10c4 Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Thu, 2 Jan 2025 22:56:33 -0800 Subject: [PATCH 08/34] Fix broken string formatting --- slack/templatetags/slack.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/slack/templatetags/slack.py b/slack/templatetags/slack.py index b536a1dc..c8d67fc6 100644 --- a/slack/templatetags/slack.py +++ b/slack/templatetags/slack.py @@ -21,8 +21,8 @@ def slack(value): for m in it: r = m.groupdict() channel = r['channel_id'] - new_value = new_value.replace(m.group(), '[#%s]('+settings.SLACK_BASE_URL+'/app_redirect?channel=%s)' % - (channel, channel)) + new_value = new_value.replace(m.group(), '[#%s](%s/app_redirect?channel=%s)' % + (channel, settings.SLACK_BASE_URL, channel)) return new_value From 9f8b9f2b7850f13eacf01d1e18bac0cc754461e1 Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Thu, 2 Jan 2025 23:03:58 -0800 Subject: [PATCH 09/34] Update models.py --- slack/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/slack/models.py b/slack/models.py index 361da42a..d22f6429 100755 --- a/slack/models.py +++ b/slack/models.py @@ -70,7 +70,7 @@ def archived(self) -> bool: info = channel_info(self.id) return 'is_archived' in info and channel_info(self.id)['is_archived'] except: - return None + return "" @property def num_members(self) -> int: @@ -101,7 +101,7 @@ def created_on(self) -> datetime.datetime: info = channel_info(self.id) return datetime.datetime.fromtimestamp(info['created']) except: - return None + return "" @property def creator(self) -> str: From f9dfec62e5349413a42eab24cceea85d273f104c Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Thu, 2 Jan 2025 23:10:12 -0800 Subject: [PATCH 10/34] Move channels for groups to channel model update details and list --- accounts/lookups.py | 20 ++++++++ lnldb/settings.py | 1 + site_tmpl/slack/slack_channel_detail.html | 46 +++++++++++++++++-- site_tmpl/slack/slack_channel_list.html | 12 ++++- ..._allowed_groups_channel_required_groups.py | 32 +++++++++++++ slack/models.py | 13 ++++++ slack/urls.py | 1 + slack/views.py | 26 +++++++++-- 8 files changed, 140 insertions(+), 11 deletions(-) create mode 100644 slack/migrations/0005_channel_allowed_groups_channel_required_groups.py diff --git a/accounts/lookups.py b/accounts/lookups.py index c92703fb..32622a40 100644 --- a/accounts/lookups.py +++ b/accounts/lookups.py @@ -1,6 +1,7 @@ from ajax_select import LookupChannel from django.contrib.auth import get_user_model from django.db.models import Q +from django.contrib.auth.models import Group from . import ldap from . import graph @@ -37,6 +38,25 @@ def format_item_display(self, obj): (self.get_result(obj), ", ".join(map(str, obj.groups.all()))) return ' %s' % self.get_result(obj) +class GroupLookup(LookupChannel): + model = Group + + def check_auth(self, request): + if request.user.groups.filter(Q(name="Alumni") | Q(name="Active") | Q(name="Officer")).exists(): + return True + + def get_query(self, q, request, search_ldap=True): + qs = Q() + for term in q.split(): + qs &= Q(name__icontains=term) + return Group.objects.filter(qs).distinct().all() + + def format_match(self, obj): + return self.format_item_display(obj) + + def format_item_display(self, obj): + return ' %s (%s users)' % \ + (self.get_result(obj), obj.user_set.count()) class OfficerLookup(LookupChannel): model = get_user_model() diff --git a/lnldb/settings.py b/lnldb/settings.py index 29e5ebad..d395700c 100644 --- a/lnldb/settings.py +++ b/lnldb/settings.py @@ -444,6 +444,7 @@ def from_runtime(*x): AJAX_LOOKUP_CHANNELS = { 'Users': ('accounts.lookups', 'UserLookup'), + 'Groups': ('accounts.lookups', 'GroupLookup'), 'Orgs': ('events.lookups', 'OrgLookup'), 'UserLimitedOrgs': ('events.lookups', 'UserLimitedOrgLookup'), 'Officers': ('accounts.lookups', 'OfficerLookup'), diff --git a/site_tmpl/slack/slack_channel_detail.html b/site_tmpl/slack/slack_channel_detail.html index 4185c789..e88d3094 100644 --- a/site_tmpl/slack/slack_channel_detail.html +++ b/site_tmpl/slack/slack_channel_detail.html @@ -15,7 +15,7 @@

    Last Updated: {{ channel.last_updated }}

    Open in Slack - + @@ -24,17 +24,53 @@

    Last Updated: {{ channel.last_updated }}

    -

    Channel Configuration

    +

    Channel Configuration + {% if form == None %} + Edit Groups + {% endif %} +

    +{% if form %} +
    +{% csrf_token %} +{{ form.media }} +{% endif %} - + - +
    Groups Allowed{{ channel.groups_allowed }} + {% if form %} + {{ form.allowed_groups }} + {% for error in form.username.errors %} + {{error}} + {% endfor %} + {% else %} + {% for group in channel.allowed_groups.all %} + {{ group.name }} + {% endfor %} + {% endif %} +
    Groups Required{{ channel.groups_required }} + {% if form %} + {{ form.required_groups }} + {% for error in form.username.errors %} + {{error}} + {% endfor %} + + {% else %} + {% for group in channel.required_groups.all %} + {{ group.name }} + {% endfor %} + {% endif %} +
    + {% if form %} + +
    + {% endif %}

    Channel Details

    @@ -55,7 +91,7 @@

    Channel Details

    - + diff --git a/site_tmpl/slack/slack_channel_list.html b/site_tmpl/slack/slack_channel_list.html index 232d5417..324e2cc5 100644 --- a/site_tmpl/slack/slack_channel_list.html +++ b/site_tmpl/slack/slack_channel_list.html @@ -20,8 +20,16 @@

    {{ h2 }}

    - - + + {% endfor %}
    Created by{% if creator_name %}{{ creator_name }}{% endif %}{% if channel.creator %}{{ channel.creator.get_full_name }}{% endif %}
    Created on {{ channel.num_members }} {{ channel.last_updated }}{{ channel.groups_allowed }}{{ channel.groups_required }} + {% for group in channel.allowed_groups.all %} + {{ group.name }} + {% endfor %} + + {% for group in channel.required_groups.all %} + {{ group.name }} + {% endfor %} +
    diff --git a/slack/migrations/0005_channel_allowed_groups_channel_required_groups.py b/slack/migrations/0005_channel_allowed_groups_channel_required_groups.py new file mode 100644 index 00000000..e4aa233a --- /dev/null +++ b/slack/migrations/0005_channel_allowed_groups_channel_required_groups.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.13 on 2025-01-03 07:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("slack", "0004_remove_channel_channel_id_alter_channel_id") + ] + + operations = [ + migrations.AddField( + model_name="channel", + name="allowed_groups", + field=models.ManyToManyField( + blank=True, + related_name="allowed_channels", + to="auth.group", + verbose_name="Allowed Groups", + ), + ), + migrations.AddField( + model_name="channel", + name="required_groups", + field=models.ManyToManyField( + blank=True, + related_name="required_channels", + to="auth.group", + verbose_name="Required Groups", + ), + ), + ] diff --git a/slack/models.py b/slack/models.py index d22f6429..e55408b6 100755 --- a/slack/models.py +++ b/slack/models.py @@ -1,6 +1,7 @@ import datetime from django.db import models from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from .api import channel_info, channel_members, channel_latest_message, user_profile @@ -42,6 +43,18 @@ class Channel(models.Model): Used to store Slack channel ID and retrieve common information about Slack channels """ id = models.CharField(max_length=256, unique=True, primary_key=True) + allowed_groups = models.ManyToManyField( + Group, + verbose_name="Allowed Groups", + related_name="allowed_channels", + blank=True, + ) + required_groups = models.ManyToManyField( + Group, + verbose_name="Required Groups", + related_name="required_channels", + blank=True, + ) def __str__(self): return self.name diff --git a/slack/urls.py b/slack/urls.py index 42e3c38d..73259527 100644 --- a/slack/urls.py +++ b/slack/urls.py @@ -12,5 +12,6 @@ re_path(r'^moderate/$', views.report_list, name="moderate"), re_path(r'^moderate/(?P\d+)/$', views.view_report, name="report"), re_path(r'^channels/$', views.channel_list, name="channel-list"), + re_path(r'^channel/(?P[^/]+)/edit/$', views.channel_detail_edit, name="channel-edit"), re_path(r'^channel/(?P[^/]+)/$', views.channel_detail, name="channel"), ] diff --git a/slack/views.py b/slack/views.py index 6f18227c..5f77e8eb 100644 --- a/slack/views.py +++ b/slack/views.py @@ -1,3 +1,5 @@ +from ajax_select.fields import AutoCompleteSelectMultipleField +from django import forms from django.contrib.auth.decorators import permission_required, login_required from django.shortcuts import reverse, render, get_object_or_404 from django.http import HttpResponseRedirect @@ -22,20 +24,36 @@ def channel_list(request): 'channels': channels, 'slack_base_url': settings.SLACK_BASE_URL+'/archives/'}) +class ChannelAssignGroupForm(forms.ModelForm): + allowed_groups = AutoCompleteSelectMultipleField('Groups', required=False) + required_groups = AutoCompleteSelectMultipleField('Groups', required=False) + class Meta: + model = Channel + fields = ('allowed_groups', 'required_groups') + @login_required @permission_required('slack.view_channel', raise_exception=True) -def channel_detail(request, id): +def channel_detail_edit(request, id): + return channel_detail(request, id, edit=True) + +@login_required +@permission_required('slack.view_channel', raise_exception=True) +def channel_detail(request, id, edit=False): """ View details for a specific Slack channel """ channel = get_object_or_404(Channel, id=id) + if request.method == 'POST': + form = ChannelAssignGroupForm(data=request.POST, instance=channel) + if form.is_valid(): + form.save(commit=True) + return HttpResponseRedirect(reverse('slack:channel', args=[id])) return render(request, 'slack/slack_channel_detail.html', {'h2': "#"+channel.name+' Details', 'channel': channel, - 'creator_name': channel.creator.get_full_name() if channel.creator else None, + #'creator_name': channel.creator.get_full_name() if channel.creator else None, 'slack_base_url': settings.SLACK_BASE_URL+'/archives/', - 'groups_allowed': channel.allowed_groups.all(), # TODO: Fix implementation - 'groups_required': channel.required_groups.all()}) # TODO: Fix implementation + 'form': ChannelAssignGroupForm(instance=channel) if edit else None}) From 3e0b5cf947b4f3f122def992c34971158ed7d87e Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Thu, 2 Jan 2025 23:28:42 -0800 Subject: [PATCH 11/34] Update appname in tests --- slack/tests.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/slack/tests.py b/slack/tests.py index a6fdbc26..9b51777e 100755 --- a/slack/tests.py +++ b/slack/tests.py @@ -24,7 +24,7 @@ def test_interaction_handler(self): # NOTE: Running this test could send messages to the LNL Laptop. That is ok. # Check that GET requests are not permitted - self.assertOk(self.client.get(reverse("slack:interactive-endpoint")), 405) + self.assertOk(self.client.get(reverse("slackapp:interactive-endpoint")), 405) # Test TFed global shortcut (unused fields omitted) shortcut_data = { @@ -39,7 +39,7 @@ def test_interaction_handler(self): # Expect 500 since trigger will be expired (Launch from within Slack to test 200 response) if requests.head("https://lnl-rt.wpi.edu/rt/Ticket/Display.html").status_code == 200: - self.assertOk(self.client.post(reverse("slack:interactive-endpoint"), urlencode(data), + self.assertOk(self.client.post(reverse("slackapp:interactive-endpoint"), urlencode(data), content_type="application/x-www-form-urlencoded"), 500) # Test TFed ticket submission (most unused fields omitted) @@ -85,11 +85,11 @@ def test_interaction_handler(self): if requests.head("https://lnl-rt.wpi.edu/rt/Ticket/Display.html").status_code == 200: if settings.SLACK_TOKEN not in ['', None]: if settings.RT_TOKEN in ['', None]: # Only run if RT token is not provided - self.assertOk(self.client.post(reverse("slack:interactive-endpoint"), urlencode(data), + self.assertOk(self.client.post(reverse("slackapp:interactive-endpoint"), urlencode(data), content_type="application/x-www-form-urlencoded")) else: # Should fail on user lookup - self.assertOk(self.client.post(reverse("slack:interactive-endpoint"), urlencode(data), + self.assertOk(self.client.post(reverse("slackapp:interactive-endpoint"), urlencode(data), content_type="application/x-www-form-urlencoded"), 500) # Test TFed ticket update form submission (unused fields omitted) @@ -141,7 +141,7 @@ def test_interaction_handler(self): "payload": json.dumps(new_ticket_data) } if settings.RT_TOKEN in [None, ''] and requests.head("https://lnl-rt.wpi.edu/rt/Ticket/Display.html").status_code == 200: - self.assertOk(self.client.post(reverse("slack:interactive-endpoint"), urlencode(data), + self.assertOk(self.client.post(reverse("slackapp:interactive-endpoint"), urlencode(data), content_type="application/x-www-form-urlencoded")) # Test TFed ticket message button actions (unused fields omitted) @@ -177,7 +177,7 @@ def test_interaction_handler(self): "payload": json.dumps(action_data) } if requests.head("https://lnl-rt.wpi.edu/rt/Ticket/Display.html").status_code == 200: - self.assertOk(self.client.post(reverse("slack:interactive-endpoint"), urlencode(data), + self.assertOk(self.client.post(reverse("slackapp:interactive-endpoint"), urlencode(data), content_type="application/x-www-form-urlencoded")) def test_event_url_verification(self): @@ -189,9 +189,9 @@ def test_event_url_verification(self): } # GET requests should not be permitted - self.assertOk(self.client.get(reverse("slack:event-endpoint")), 405) + self.assertOk(self.client.get(reverse("slackapp:event-endpoint")), 405) - resp = self.client.post(reverse("slack:event-endpoint"), validation_info, content_type="application/json") + resp = self.client.post(reverse("slackapp:event-endpoint"), validation_info, content_type="application/json") self.assertOk(resp) self.assertJSONEqual( str(resp.content, 'utf-8'), @@ -211,7 +211,7 @@ def test_welcome_message(self): } } - self.assertOk(self.client.post(reverse("slack:event-endpoint"), event_info, content_type="application/json")) + self.assertOk(self.client.post(reverse("slackapp:event-endpoint"), event_info, content_type="application/json")) class SlackTemplateTags(TestCase): From 6444eb98e9733c4154279254dd39b65c2519338d Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Thu, 2 Jan 2025 23:31:25 -0800 Subject: [PATCH 12/34] Update urls.py --- lnldb/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lnldb/urls.py b/lnldb/urls.py index 3b690634..a60c7a7b 100644 --- a/lnldb/urls.py +++ b/lnldb/urls.py @@ -55,7 +55,7 @@ re_path(r'', include(('members.urls', 'members'), namespace='members')), re_path(r'^api/', include(('api.urls', 'api'), namespace='api')), re_path(r'^db/slack/', include(('slack.urls', 'slack'), namespace='slack')), - re_path(r'^', include(('slack.urls_app', 'slackapp'), namespace='slackapp')), + re_path(r'^slack/', include(('slack.urls_app', 'slackapp'), namespace='slackapp')), re_path(r'^mdm/', include(('devices.urls.mdm', 'mdm'), namespace="mdm")), re_path(r'^support/', include(('rt.urls', 'support'), namespace='support')), re_path(r'^spotify/', include(('spotify.urls', 'spotify'), namespace='spotify')), From d7073dcd30ebe13732a4f12fb6814218b040b3dd Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Thu, 2 Jan 2025 23:44:10 -0800 Subject: [PATCH 13/34] Auto-create Channel object on channel creation --- slack/event_handlers.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/slack/event_handlers.py b/slack/event_handlers.py index 25d28927..866d3962 100644 --- a/slack/event_handlers.py +++ b/slack/event_handlers.py @@ -8,6 +8,7 @@ from django.views.decorators.http import require_POST from django.views.decorators.csrf import csrf_exempt from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from slack_sdk import WebClient from slack_sdk.errors import SlackApiError @@ -17,7 +18,7 @@ from slack.api import slack_post, user_profile, open_modal, post_ephemeral, retrieve_message, replace_message, \ channel_info, join_channel, message_react -from .models import SlackMessage, ReportedMessage +from .models import Channel, SlackMessage, ReportedMessage from . import views logger = logging.getLogger(__name__) @@ -43,6 +44,13 @@ def handle_event(request): elif event['type'] == "channel_created": if settings.SLACK_AUTO_JOIN: join_channel(event['channel']['id']) + channel = Channel.objects.create(id=event['channel']['id']) + if event['channel']['name'].startswith("active"): + channel.required_groups.add(*Group.objects.filter(name__in=["Active", "Officer"])) + # if event['channel']['name'].startswith("exec"): # Need to remove advisor from "Officers" before enabling + # channel.required_groups.add(*Group.objects.filter(name="Officer")) + if event['channel']['name'].startswith("ext"): + channel.allowed_groups.add(*Group.objects.filter(name__in=["Officer"])) return HttpResponse() return HttpResponse("Not implemented") From 55a48e647da973ab50020f052845c48614f8cc18 Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Fri, 3 Jan 2025 00:04:44 -0800 Subject: [PATCH 14/34] Re-add user to required channel if they leave --- slack/event_handlers.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/slack/event_handlers.py b/slack/event_handlers.py index 866d3962..14e8ca57 100644 --- a/slack/event_handlers.py +++ b/slack/event_handlers.py @@ -15,7 +15,7 @@ from accounts.models import UserPreferences from data.decorators import process_in_thread from rt import api as rt_api -from slack.api import slack_post, user_profile, open_modal, post_ephemeral, retrieve_message, replace_message, \ +from slack.api import slack_post, user_add, user_profile, open_modal, post_ephemeral, retrieve_message, replace_message, \ channel_info, join_channel, message_react from .models import Channel, SlackMessage, ReportedMessage @@ -51,6 +51,13 @@ def handle_event(request): # channel.required_groups.add(*Group.objects.filter(name="Officer")) if event['channel']['name'].startswith("ext"): channel.allowed_groups.add(*Group.objects.filter(name__in=["Officer"])) + elif event['type'] == 'member_left_channel': + slack_user = user_profile(event['user']) + if slack_user['ok']: + username = slack_user['user']['profile'].get('email', '').split('@')[0] + user = get_user_model().objects.filter(username=username).first() + if channel.required_groups.filter(name_in=user.groups.all()).exists(): + user_add(event['channel'], event['user']) return HttpResponse() return HttpResponse("Not implemented") From f4e9bf563bf94f0092e321ec8f0f48776a015317 Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Sat, 4 Jan 2025 02:52:05 -0800 Subject: [PATCH 15/34] Add slack_channel relations to BaseEvent and Org --- events/forms.py | 20 ++++++- ...lack_channel_organization_slack_channel.py | 36 ++++++++++++ events/models.py | 4 ++ site_tmpl/org_detail.html | 9 +++ site_tmpl/uglydetail.html | 11 ++++ slack/api.py | 55 +++++++++++++++++++ 6 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 events/migrations/0014_baseevent_slack_channel_organization_slack_channel.py diff --git a/events/forms.py b/events/forms.py index 39d8c70f..86650861 100644 --- a/events/forms.py +++ b/events/forms.py @@ -30,6 +30,7 @@ from events.widgets import ValueSelectField from helpers.form_text import markdown_at_msgs from helpers.util import curry_class +from slack.models import Channel LIGHT_EXTRAS = Extra.objects.exclude(disappear=True).filter(category__name="Lighting") LIGHT_EXTRAS_ID_NAME = LIGHT_EXTRAS.values_list('id', 'name') @@ -178,6 +179,7 @@ def __init__(self, request_user, *args, **kwargs): 'Contact', Field('name', css_class=read_only), 'exec_email', + 'slack_channel', 'address', Field('phone', css_class="bfh-phone", data_format="(ddd) ddd dddd"), ), @@ -211,10 +213,16 @@ def clean_worktag(self): raise ValidationError('What you entered is not a valid worktag. Here are some examples of what a worktag ' 'looks like: 1234-CC, 123-AG') return self.cleaned_data['worktag'] + + def clean_slack_channel(self): + if self.cleaned_data['slack_channel'] is not None and self.cleaned_data['slack_channel'] != '': + return Channel.get_or_create(self.cleaned_data['slack_channel']) + return None + class Meta: model = Organization - fields = ('name', 'exec_email', 'address', 'phone', 'associated_orgs', 'personal', + fields = ('name', 'exec_email', 'address', 'phone', 'associated_orgs', 'personal', 'slack_channel', 'workday_fund', 'worktag', 'user_in_charge', 'associated_users', 'notes', 'delinquent') # associated_orgs = make_ajax_field(Organization,'associated_orgs','Orgs',plugin_options = {'minLength':2}) @@ -224,6 +232,7 @@ class Meta: associated_users = AutoCompleteSelectMultipleField('Users', required=False) worktag = forms.CharField(required=False, help_text='Ends in -AG, -CC, -GF, -GR, or -DE') notes = forms.CharField(widget=EasyMDEEditor(), label="Internal Notes", required=False) + slack_channel = forms.CharField(required=False, label="Slack Channel", validators=[Channel.validate_field], help_text="Slack Channel ID, i.e. C4HB02R6H") class FieldAccess: def __init__(self): @@ -584,6 +593,7 @@ def __init__(self, request_user, *args, **kwargs): 'location', 'lnl_contact', 'reference_code', + 'slack_channel', Field('description'), DynamicFieldContainer('internal_notes'), HTML('
    '), @@ -719,7 +729,7 @@ class Meta: 'billed_in_bulk', 'contact', 'org', 'datetime_setup_complete', 'datetime_start', 'datetime_end', 'sensitive', 'test_event', 'entered_into_workday', 'send_survey', 'max_crew','cancelled_reason', - 'reference_code') + 'reference_code','slack_channel',) widgets = { 'description': EasyMDEEditor(), 'internal_notes': EasyMDEEditor(), @@ -745,6 +755,12 @@ class Meta: # `2022-ABNXQQ` reference_code =forms.CharField(validators=[RegexValidator(regex=r"[0-9]{4}-[A-Z]{6}")], required = False) + slack_channel = forms.CharField(required=False, label="Slack Channel", validators=[Channel.validate_field], help_text="Slack Channel ID, i.e. C4HB02R6H") + + def clean_slack_channel(self): + if self.cleaned_data['slack_channel'] is not None and self.cleaned_data['slack_channel'] != '': + return Channel.get_or_create(self.cleaned_data['slack_channel']) + return None class EventReviewForm(forms.ModelForm): diff --git a/events/migrations/0014_baseevent_slack_channel_organization_slack_channel.py b/events/migrations/0014_baseevent_slack_channel_organization_slack_channel.py new file mode 100644 index 00000000..14c2f6bf --- /dev/null +++ b/events/migrations/0014_baseevent_slack_channel_organization_slack_channel.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.13 on 2025-01-03 08:11 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("slack", "0005_channel_allowed_groups_channel_required_groups"), + ("events", "0013_alter_baseevent_polymorphic_ctype_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="baseevent", + name="slack_channel", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="event", + to="slack.channel", + ), + ), + migrations.AddField( + model_name="organization", + name="slack_channel", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="organization", + to="slack.channel", + ), + ), + ] diff --git a/events/models.py b/events/models.py index 5dec46a4..030c5b4e 100755 --- a/events/models.py +++ b/events/models.py @@ -257,6 +257,8 @@ class BaseEvent(PolymorphicModel): sensitive = models.BooleanField(default=False, help_text="Nobody besides those directly involved should know about this event") test_event = models.BooleanField(default=False, help_text="Check to lower the VP's blood pressure after they see the short-notice S4/L4") + slack_channel = models.ForeignKey('slack.Channel', on_delete=models.PROTECT, null=True, blank=True, related_name='event', help_text="Slack Channel ID, i.e. C4HB02R6H") + # Status Indicators approved = models.BooleanField(default=False) approved_on = models.DateTimeField(null=True, blank=True) @@ -1181,6 +1183,8 @@ class Organization(models.Model): locked = models.BooleanField(default=False, blank=True) + slack_channel = models.ForeignKey('slack.Channel', on_delete=models.PROTECT, null=True, blank=True, related_name='organization', help_text="Slack Channel ID, i.e. C4HB02R6H") + def __str__(self): return self.name diff --git a/site_tmpl/org_detail.html b/site_tmpl/org_detail.html index 00564a74..4c4fbd05 100644 --- a/site_tmpl/org_detail.html +++ b/site_tmpl/org_detail.html @@ -21,6 +21,9 @@

    {{org }}

    ({{ org.shortname }}) {% endif %}
    + {% if request.user.is_lnl and org.slack_channel %} + Open Slack + {% endif %} {% permission user has 'events.edit_org' %} Edit {% if user.is_superuser %} @@ -42,6 +45,12 @@

    {{org }}

    Contact

    + {% if request.user.is_lnl and org.slack_channel %} + + + + + {% endif %} diff --git a/site_tmpl/uglydetail.html b/site_tmpl/uglydetail.html index 0e3262ca..df0b57c3 100644 --- a/site_tmpl/uglydetail.html +++ b/site_tmpl/uglydetail.html @@ -19,6 +19,9 @@

    Status: {{ event.status }}

    + {% if request.user.is_lnl and event.slack_channel %} + Open Slack + {% endif %} {% permission request.user has 'events.view_event_reports' of event %} PDF {% endpermission %} @@ -232,6 +235,14 @@

    Confirm Reopen Event

    + {% if request.user.is_lnl and event.slack_channel %} + + + + + {% endif %} + + + + + + + +
    Slack Channel {% if org.slack_channel.private %} {% else %}#{% endif %}{{ org.slack_channel.name }}
    Address {{org.address}} Location {{event.location}} ({{ event.location.building }})
    Slack Channel + {% if event.slack_channel.private %} {% else %}#{% endif %}{{ event.slack_channel.name }} +
    Submitted by diff --git a/slack/api.py b/slack/api.py index bd68bfeb..0f492e37 100755 --- a/slack/api.py +++ b/slack/api.py @@ -63,6 +63,61 @@ def channel_info(id, num_members=False): assert e.response['ok'] is False return None +def validate_channel(id): + """ + Checks if a channel exists + + :param id: The ID of the channel + :return: True if the channel exists, False otherwise + """ + + if not settings.SLACK_TOKEN: + return False + + client = WebClient(token=settings.SLACK_TOKEN) + + try: + response = client.conversations_info(channel=id) + assert response['ok'] is True + return True + except SlackApiError or AssertionError as e: + return False + +def get_id_from_name(name): + """ + Retrieves the ID of a channel by its name + + :param name: The name of the channel + :return: The ID of the channel + """ + + if not settings.SLACK_TOKEN: + return None + + client = WebClient(token=settings.SLACK_TOKEN) + + try: + response = client.conversations_list(limit=200) + assert response['ok'] is True + + if response['response_metadata']['next_cursor']: + cursor = response['response_metadata']['next_cursor'] + channels = response['channels'] + for channel in channels: + if channel['name'] == name: + return channel['id'] + while cursor: + response = client.conversations_members(channel=id, cursor=cursor) + assert response['ok'] is True + channels = response['channels'] + for channel in channels: + if channel['name'] == name: + return channel['id'] + cursor = response['response_metadata']['next_cursor'] + except SlackApiError as e: + assert e.response['ok'] is False + return None + def channel_latest_message(id): """ Retrieves the latest message in a channel From baa1c44a48e06fc36e89e5111da5c219bb6068bb Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Sat, 4 Jan 2025 02:53:46 -0800 Subject: [PATCH 16/34] Add event/org fields in channel configuration form --- events/lookups.py | 23 ++++++++++++- lnldb/settings.py | 1 + site_tmpl/slack/slack_channel_detail.html | 39 ++++++++++++++++++----- site_tmpl/slack/slack_channel_list.html | 11 +++++++ slack/views.py | 21 ++++++++++-- 5 files changed, 84 insertions(+), 11 deletions(-) diff --git a/events/lookups.py b/events/lookups.py index 96a5f4b7..174dd5ff 100644 --- a/events/lookups.py +++ b/events/lookups.py @@ -2,9 +2,30 @@ from django.db.models import Q from django.utils.html import escape -from events.models import Organization +from events.models import BaseEvent, Organization +class EventLookup(LookupChannel): + model = BaseEvent + + def check_auth(self, request): + return request.user.is_authenticated + + def get_query(self, q, request): + if request.user.groups.filter(name="Officer").exists(): + return BaseEvent.objects.filter(Q(event_name__icontains=q) | Q(description__icontains=q)).distinct() + return BaseEvent.objects.filter(Q(event_name__icontains=q) | Q(description__icontains=q)).\ + filter(approved=True, closed=False, cancelled=False, test_event=False, sensitive=False).distinct() + + def get_result(self, obj): + return obj.event_name + + def format_match(self, obj): + return self.format_item_display(obj) + + def format_item_display(self, obj: BaseEvent): + return ' %s (%s)' % (escape(obj.event_name), escape(obj.status)) + class OrgLookup(LookupChannel): model = Organization diff --git a/lnldb/settings.py b/lnldb/settings.py index d395700c..dea08139 100644 --- a/lnldb/settings.py +++ b/lnldb/settings.py @@ -445,6 +445,7 @@ def from_runtime(*x): AJAX_LOOKUP_CHANNELS = { 'Users': ('accounts.lookups', 'UserLookup'), 'Groups': ('accounts.lookups', 'GroupLookup'), + 'Events': ('events.lookups', 'EventLookup'), 'Orgs': ('events.lookups', 'OrgLookup'), 'UserLimitedOrgs': ('events.lookups', 'UserLimitedOrgLookup'), 'Officers': ('accounts.lookups', 'OfficerLookup'), diff --git a/site_tmpl/slack/slack_channel_detail.html b/site_tmpl/slack/slack_channel_detail.html index e88d3094..7c07576b 100644 --- a/site_tmpl/slack/slack_channel_detail.html +++ b/site_tmpl/slack/slack_channel_detail.html @@ -26,7 +26,7 @@

    Last Updated: {{ channel.last_updated }}

    -->

    Channel Configuration {% if form == None %} - Edit Groups + Edit Configuration {% endif %}

    {% if form %} @@ -40,9 +40,7 @@

    Channel Configuration

    {% if form %} {{ form.allowed_groups }} - {% for error in form.username.errors %} - {{error}} - {% endfor %} + {% for error in form.allowed_groups.errors %}{{error}}{% endfor %} {% else %} {% for group in channel.allowed_groups.all %} {{ group.name }} @@ -55,10 +53,7 @@

    Channel Configuration

    {% if form %} {{ form.required_groups }} - {% for error in form.username.errors %} - {{error}} - {% endfor %} - + {% for error in form.required_groups.errors %}{{error}}{% endfor %} {% else %} {% for group in channel.required_groups.all %} {{ group.name }} @@ -66,6 +61,34 @@

    Channel Configuration {% endif %}

    Linked Event{% if channel.event.count > 1 %}s{%endif%} + {% if form %} + {{ form.event }} + {% for error in form.event.errors %}{{error}}{% endfor %} + {% else %} + {% for event in channel.event.all %} + {{ event.event_name }} + {% if not forloop.last %}, {% endif %} + {% endfor %} + {% endif %} +
    Linked Organization{% if channel.organization.count > 1 %}s{%endif%} + {% if form %} + {{ form.organization }} + {% for error in form.organization.errors %}{{error}}{% endfor %} + {% else %} + {% for org in channel.organization.all %} + {{ org }} + {% if not forloop.last %}, {% endif %} + {% endfor %} + {% endif %} +
    {% if form %} diff --git a/site_tmpl/slack/slack_channel_list.html b/site_tmpl/slack/slack_channel_list.html index 324e2cc5..bb8270b8 100644 --- a/site_tmpl/slack/slack_channel_list.html +++ b/site_tmpl/slack/slack_channel_list.html @@ -12,6 +12,8 @@

    {{ h2 }}

    Last Updated Groups Allowed Groups Required + Event + Organizations {% for channel in channels %} @@ -30,6 +32,15 @@

    {{ h2 }}

    {{ group.name }} {% endfor %} + + {% for event in channel.event.all %} + {{ event.event_name }}{% if not forloop.last %}, {% endif %} + {% endfor %} + + + {% for org in channel.organization.all %} + {{ org }}{% if not forloop.last %}, {% endif %} + {% endfor %} {% endfor %} diff --git a/slack/views.py b/slack/views.py index 5f77e8eb..60dca846 100644 --- a/slack/views.py +++ b/slack/views.py @@ -4,6 +4,7 @@ from django.shortcuts import reverse, render, get_object_or_404 from django.http import HttpResponseRedirect +from events.models import BaseEvent, Organization from lnldb import settings from .models import Channel, ReportedMessage @@ -27,12 +28,22 @@ def channel_list(request): class ChannelAssignGroupForm(forms.ModelForm): allowed_groups = AutoCompleteSelectMultipleField('Groups', required=False) required_groups = AutoCompleteSelectMultipleField('Groups', required=False) + event = AutoCompleteSelectMultipleField('Events', required=False) + organization = AutoCompleteSelectMultipleField('Orgs', required=False) + + def __init__(self, *args, **kwargs): + super(ChannelAssignGroupForm, self).__init__(*args, **kwargs) + self.fields['allowed_groups'].initial = kwargs['instance'].allowed_groups.all() + self.fields['required_groups'].initial = kwargs['instance'].required_groups.all() + self.fields['event'].initial = kwargs['instance'].event.all() + self.fields['organization'].initial = kwargs['instance'].organization.all() + class Meta: model = Channel - fields = ('allowed_groups', 'required_groups') + fields = ('allowed_groups', 'required_groups', 'event', 'organization') @login_required -@permission_required('slack.view_channel', raise_exception=True) +@permission_required('slack.change_channel', raise_exception=True) def channel_detail_edit(request, id): return channel_detail(request, id, edit=True) @@ -47,6 +58,12 @@ def channel_detail(request, id, edit=False): form = ChannelAssignGroupForm(data=request.POST, instance=channel) if form.is_valid(): form.save(commit=True) + for event in ( (event_set := BaseEvent.objects.filter(pk__in=form.cleaned_data['event'])) | channel.event.all() ).distinct(): + event.slack_channel = channel if event in event_set else None + event.save() + for org in ( (org_set := Organization.objects.filter(pk__in=form.cleaned_data['organization'])) | channel.organization.all() ).distinct(): + org.slack_channel = channel if org in org_set else None + org.save() return HttpResponseRedirect(reverse('slack:channel', args=[id])) return render(request, 'slack/slack_channel_detail.html', {'h2': "#"+channel.name+' Details', From 254ad802f2ead3af6be3bf21c467e7105cb3be50 Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Sat, 4 Jan 2025 02:54:58 -0800 Subject: [PATCH 17/34] Add Slack Channel Directory --- site_tmpl/admin.html | 1 + site_tmpl/slack/slack_channel_directory.html | 41 ++++++++++++++++++++ site_tmpl/userdetail.html | 1 + slack/models.py | 30 +++++++++++++- slack/urls.py | 3 ++ slack/views.py | 30 +++++++++++++- 6 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 site_tmpl/slack/slack_channel_directory.html diff --git a/site_tmpl/admin.html b/site_tmpl/admin.html index 34558bdd..44869fba 100644 --- a/site_tmpl/admin.html +++ b/site_tmpl/admin.html @@ -42,6 +42,7 @@

    Quick Links

    Exec Sharepoint {% endif %} Slack + Channel Directory {% permission request.user has 'inventory.view_equipment' %} Snipe {% endpermission %} diff --git a/site_tmpl/slack/slack_channel_directory.html b/site_tmpl/slack/slack_channel_directory.html new file mode 100644 index 00000000..585fde1a --- /dev/null +++ b/site_tmpl/slack/slack_channel_directory.html @@ -0,0 +1,41 @@ +{% extends 'base_admin.html' %} +{% load permissionif %} +{% block title %}{{h2}} | Lens and Lights at WPI{% endblock %} +{% block content %} +

    {{ h2 }}

    +{% if error %} +
    {{ error }}
    +{% endif %} +
    + {% for channel in channels %} + +
    +
    +

    {% if channel.private %} {% else %}#{% endif %}{{ channel.name }}

    +

    {{ channel.topic | truncatechars_html:25}}

    +
    +
    + {{ channel.num_members }} members, last updated {{ channel.last_updated | date:"D, N j Y, P"}}{% if channel.archived %}, Archived{% endif %} +
    + +
    + + {% endfor %} +
    +
    +
    Total: {{ channels | length }}
    +{% endblock %} \ No newline at end of file diff --git a/site_tmpl/userdetail.html b/site_tmpl/userdetail.html index 9140e7ae..aa41360e 100644 --- a/site_tmpl/userdetail.html +++ b/site_tmpl/userdetail.html @@ -34,6 +34,7 @@

    {{ u.groups.all|join:", " }}

    {% endpermission %} {% if request.user == u and request.user.is_lnl %} Preferences + Channel Directory {% endif %} {% endif %}
    diff --git a/slack/models.py b/slack/models.py index e55408b6..cfa8ced0 100755 --- a/slack/models.py +++ b/slack/models.py @@ -2,7 +2,13 @@ from django.db import models from django.contrib.auth import get_user_model from django.contrib.auth.models import Group -from .api import channel_info, channel_members, channel_latest_message, user_profile +from django.forms import ValidationError + +from lnldb import settings +from .api import channel_info, channel_members, channel_latest_message, get_id_from_name, user_profile, validate_channel +import re + +SLACK_CHANNEL_ID_REGEX = r'/(C0\w+)' # Create your models here. @@ -59,6 +65,28 @@ class Channel(models.Model): def __str__(self): return self.name + @staticmethod + def get_or_create(channel_id): + if type(channel_id) == Channel: + return channel_id + if (match := re.search(SLACK_CHANNEL_ID_REGEX, channel_id)): + channel_id = match.group(1) + try: + return Channel.objects.get(id=channel_id) + except Channel.DoesNotExist: + if True: #validate_channel(channel_id): #TODO: Test validation logic + return Channel.objects.create(id=channel_id) + elif (channel_id := get_id_from_name(channel_id.removeprefix("#"))): + return Channel.get_or_create(channel_id) + else: + return None + + @staticmethod + def validate_field(value): + pass + # if not Channel.get_or_create(value): # TODO: Test channel validation + # raise ValidationError("Invalid channel ID") + @property def name(self) -> str: try: diff --git a/slack/urls.py b/slack/urls.py index 73259527..9730bf78 100644 --- a/slack/urls.py +++ b/slack/urls.py @@ -14,4 +14,7 @@ re_path(r'^channels/$', views.channel_list, name="channel-list"), re_path(r'^channel/(?P[^/]+)/edit/$', views.channel_detail_edit, name="channel-edit"), re_path(r'^channel/(?P[^/]+)/$', views.channel_detail, name="channel"), + re_path(r'^directory/$', views.channel_directory, name="channel-directory"), + re_path(r'^join/(?P[^/]+)/$', views.channel_join_and_redirect, name="channel-join"), + ] diff --git a/slack/views.py b/slack/views.py index 60dca846..bac481db 100644 --- a/slack/views.py +++ b/slack/views.py @@ -1,5 +1,6 @@ from ajax_select.fields import AutoCompleteSelectMultipleField from django import forms +from django.db.models import Value from django.contrib.auth.decorators import permission_required, login_required from django.shortcuts import reverse, render, get_object_or_404 from django.http import HttpResponseRedirect @@ -8,7 +9,7 @@ from lnldb import settings from .models import Channel, ReportedMessage -from .api import lookup_user, user_profile, message_link, channel_info +from .api import lookup_user, user_add, user_profile, message_link, channel_info # Slack Management Views @@ -72,7 +73,32 @@ def channel_detail(request, id, edit=False): 'slack_base_url': settings.SLACK_BASE_URL+'/archives/', 'form': ChannelAssignGroupForm(instance=channel) if edit else None}) - +@login_required +@permission_required('slack.view_channel', raise_exception=True) +def channel_directory(request, error=None): + """ + View a directory of all Slack channels + """ + channels = ( Channel.objects.filter(allowed_groups__in=request.user.groups.all()).annotate(order=Value(1)) | + Channel.objects.filter(required_groups__in=request.user.groups.all()).annotate(order=Value(2)) ).distinct().order_by('order') + return render(request, 'slack/slack_channel_directory.html', {'h2': 'Slack Channel Directory', 'channels': channels, 'error': error}) + +@login_required +@permission_required('slack.view_channel', raise_exception=True) +def channel_join_and_redirect(request, id): + """ + Join a Slack channel and redirect to the Slack workspace + """ + channel = get_object_or_404(Channel, id=id) + if channel not in (channels := ( Channel.objects.filter(allowed_groups__in=request.user.groups.all()) | + Channel.objects.filter(required_groups__in=request.user.groups.all()) ).distinct()): + return channel_directory(request, error='You do not have permission to join %s' % channel.name) + else: + response = user_add(channel.id, request.user.username) + if response['ok']: + return HttpResponseRedirect(channel.link) + else: + return channel_directory(request, error="Error joining #%s: %s" % (channel.name, response['error'])) @login_required @permission_required('slack.view_reportedmessage', raise_exception=True) From 9fd6c8cffde4dbeaf40854431f4152c62dfb8668 Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Sat, 4 Jan 2025 02:55:21 -0800 Subject: [PATCH 18/34] Cleanup: Update links, date formatting --- site_tmpl/slack/slack_channel_detail.html | 4 ++-- site_tmpl/slack/slack_channel_list.html | 4 ++-- slack/models.py | 4 ++++ slack/views.py | 5 +---- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/site_tmpl/slack/slack_channel_detail.html b/site_tmpl/slack/slack_channel_detail.html index 7c07576b..1888bdd2 100644 --- a/site_tmpl/slack/slack_channel_detail.html +++ b/site_tmpl/slack/slack_channel_detail.html @@ -11,10 +11,10 @@

    {% if channel.private %}

    {{ channel.num_members }} members

    -

    Last Updated: {{ channel.last_updated }}

    +

    Last Updated: {{ channel.last_updated | date:"D, N j Y"}}

    - Open in Slack + Open in Slack

    diff --git a/site_tmpl/slack/slack_channel_list.html b/site_tmpl/slack/slack_channel_list.html index bb8270b8..f31c7fd7 100644 --- a/site_tmpl/slack/slack_channel_list.html +++ b/site_tmpl/slack/slack_channel_list.html @@ -17,11 +17,11 @@

    {{ h2 }}

    {% for channel in channels %} - {% if channel.private %} {% else %}#{% endif %}{{ channel.name }} Open in Slack + {% if channel.private %} {% else %}#{% endif %}{{ channel.name }} Open in Slack {{ channel.num_members }} - {{ channel.last_updated }} + {{ channel.last_updated | date:"D, N j Y, P" }} {% for group in channel.allowed_groups.all %} {{ group.name }} diff --git a/slack/models.py b/slack/models.py index cfa8ced0..589ca5ed 100755 --- a/slack/models.py +++ b/slack/models.py @@ -161,6 +161,10 @@ def info(self) -> dict: return channel_info(self.id) except: return None + + @property + def link(self) -> str: + return settings.SLACK_BASE_URL+"/archives/"+self.id+"/" class Meta: permissions = () \ No newline at end of file diff --git a/slack/views.py b/slack/views.py index bac481db..b8cc271a 100644 --- a/slack/views.py +++ b/slack/views.py @@ -20,11 +20,9 @@ def channel_list(request): """ View a list of all Slack channels """ - channels = Channel.objects.all() return render(request, 'slack/slack_channel_list.html', {'h2': 'Slack Channels', - 'channels': channels, - 'slack_base_url': settings.SLACK_BASE_URL+'/archives/'}) + 'channels': Channel.objects.all()}) class ChannelAssignGroupForm(forms.ModelForm): allowed_groups = AutoCompleteSelectMultipleField('Groups', required=False) @@ -70,7 +68,6 @@ def channel_detail(request, id, edit=False): {'h2': "#"+channel.name+' Details', 'channel': channel, #'creator_name': channel.creator.get_full_name() if channel.creator else None, - 'slack_base_url': settings.SLACK_BASE_URL+'/archives/', 'form': ChannelAssignGroupForm(instance=channel) if edit else None}) @login_required From 2c8e28a4cf47c34b2633136e50731d73f14ecac1 Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Sun, 5 Jan 2025 00:34:50 -0800 Subject: [PATCH 19/34] Update api.py --- slack/api.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/slack/api.py b/slack/api.py index 0f492e37..a53585bb 100755 --- a/slack/api.py +++ b/slack/api.py @@ -512,6 +512,28 @@ def user_profile(user_id): assert e.response['ok'] is False return e.response +def user_profile_image(user_id, size='original'): + """ + Get user profile image + + :param user_id: The identifier for the user in Slack (i.e. U123456789) + :param size: The size of the image to retrieve (original, 24, 32, 48, 72, 192, 512) + :return: URL of the user's profile image + """ + + if not settings.SLACK_TOKEN: + return None + + client = WebClient(token=settings.SLACK_TOKEN) + + try: + response = client.users_info(user=user_id) + assert response['ok'] is True + return response['user']['profile']['image_'+size] + except SlackApiError as e: + assert e.response['ok'] is False + return None + def lookup_user(email): """ From 74911a36ac6cb1710126ecb8422bdc77c7741fcd Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Sun, 5 Jan 2025 00:35:47 -0800 Subject: [PATCH 20/34] Add users in required_channel group to channel --- slack/models.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/slack/models.py b/slack/models.py index 589ca5ed..ce98d709 100755 --- a/slack/models.py +++ b/slack/models.py @@ -1,12 +1,15 @@ import datetime +import re from django.db import models from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.forms import ValidationError +from django.dispatch import receiver +from django.db.models.signals import pre_save from lnldb import settings -from .api import channel_info, channel_members, channel_latest_message, get_id_from_name, user_profile, validate_channel -import re +from django.forms import ValidationError +from .api import channel_info, channel_members, channel_latest_message, get_id_from_name, user_add, user_profile SLACK_CHANNEL_ID_REGEX = r'/(C0\w+)' @@ -167,4 +170,19 @@ def link(self) -> str: return settings.SLACK_BASE_URL+"/archives/"+self.id+"/" class Meta: - permissions = () \ No newline at end of file + permissions = () + +@receiver(pre_save, sender=Channel) +def update_channel_members_on_save(sender, instance:Channel, *args, **kwargs): + try: + channel = sender.objects.get(pk=instance.pk) + except sender.DoesNotExist: + pass # Object is new + else: + if not channel.required_groups == instance.required_groups: + for group in instance.required_groups.all().exclude(pk__in=channel.required_groups.all()): + usernames = group.user_set.all().values_list('username', flat=True) + response = user_add(channel.id, usernames) + if not response['ok']: + raise Exception(response) + super().save(*args, **kwargs) \ No newline at end of file From 273a82819c6fd9ffe2cc04e1e0d3adba4767bb40 Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Sun, 5 Jan 2025 00:36:02 -0800 Subject: [PATCH 21/34] Add CCs to event channel when CCs added or event channel added --- events/forms.py | 5 +++++ events/views/mkedrm.py | 9 +++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/events/forms.py b/events/forms.py index 86650861..31fb529a 100644 --- a/events/forms.py +++ b/events/forms.py @@ -30,6 +30,7 @@ from events.widgets import ValueSelectField from helpers.form_text import markdown_at_msgs from helpers.util import curry_class +from slack.api import user_add from slack.models import Channel LIGHT_EXTRAS = Extra.objects.exclude(disappear=True).filter(category__name="Lighting") @@ -1386,6 +1387,10 @@ def save(self, commit=True): obj.event = self.event if commit: obj.save() + usernames = obj.ccinstances.all().values_list('username', flat=True) + response = user_add(obj.slack_channel.id, usernames) + if not response['ok']: + raise Exception(response) return obj class Meta: diff --git a/events/views/mkedrm.py b/events/views/mkedrm.py index c4ef95d4..815b87c2 100644 --- a/events/views/mkedrm.py +++ b/events/views/mkedrm.py @@ -10,7 +10,7 @@ from accounts.models import UserPreferences from emails.generators import EventEmailGenerator from slack.views import event_edited_notification -from slack.api import slack_post, lookup_user +from slack.api import slack_post, lookup_user, user_add from events.forms import InternalEventForm, InternalEventForm2019, ServiceInstanceForm from events.models import BaseEvent, Event2019, ServiceInstance from helpers.revision import set_revision_comment @@ -72,7 +72,7 @@ def eventnew(request, id=None): if instance: set_revision_comment('Edited', form) - obj = form.save() + obj:BaseEvent = form.save() # 25Live parsing if is_event2019 and obj.event_id is None: @@ -85,6 +85,11 @@ def eventnew(request, id=None): obj.save() except requests.JSONDecodeError: pass + if obj.slack_channel and obj.slack_channel.channel_id: + usernames = obj.ccinstances.all().values_list('username', flat=True) + response = user_add(obj.slack_channel.channel_id, usernames) + if not response['ok']: + raise Exception(response) if is_event2019: services_formset.save() From e5ae6a57d431598567347abf8395aa8310fccc9e Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Sun, 5 Jan 2025 22:30:49 -0500 Subject: [PATCH 22/34] Update forms.py --- events/forms.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/events/forms.py b/events/forms.py index 31fb529a..87b14cc2 100644 --- a/events/forms.py +++ b/events/forms.py @@ -1376,7 +1376,7 @@ def clean(self): return cleaned_data def save(self, commit=True): - obj = super(CCIForm, self).save(commit=False) + obj:EventCCInstance = super(CCIForm, self).save(commit=False) try: obj.category except Category.DoesNotExist: @@ -1387,7 +1387,7 @@ def save(self, commit=True): obj.event = self.event if commit: obj.save() - usernames = obj.ccinstances.all().values_list('username', flat=True) + usernames = obj.event.ccinstances.all().values_list('username', flat=True) response = user_add(obj.slack_channel.id, usernames) if not response['ok']: raise Exception(response) From 673b03ebd55d729323152ef7d93b4d5c36a882bd Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Mon, 6 Jan 2025 01:53:15 -0500 Subject: [PATCH 23/34] Update forms.py --- events/forms.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/events/forms.py b/events/forms.py index 87b14cc2..4b91bb05 100644 --- a/events/forms.py +++ b/events/forms.py @@ -30,7 +30,7 @@ from events.widgets import ValueSelectField from helpers.form_text import markdown_at_msgs from helpers.util import curry_class -from slack.api import user_add +from slack.api import lookup_user, user_add from slack.models import Channel LIGHT_EXTRAS = Extra.objects.exclude(disappear=True).filter(category__name="Lighting") @@ -1387,8 +1387,8 @@ def save(self, commit=True): obj.event = self.event if commit: obj.save() - usernames = obj.event.ccinstances.all().values_list('username', flat=True) - response = user_add(obj.slack_channel.id, usernames) + slack_ids = [lookup_user(cci.crew_chief.email) for cci in obj.event.ccinstances.all()] + response = user_add(obj.slack_channel.id, slack_ids) if not response['ok']: raise Exception(response) return obj From 26e370bfc0b0ceef318895c4e5e6308c7d3f8c7e Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Mon, 6 Jan 2025 02:51:54 -0500 Subject: [PATCH 24/34] Move slack user lookup logic to `slack/api.py` --- accounts/views.py | 6 +++--- events/forms.py | 5 +++-- events/signals.py | 2 +- events/views/flow.py | 4 ++-- events/views/mkedrm.py | 13 +++++++------ rt/views.py | 2 +- slack/api.py | 39 ++++++++++++++++++++++++++++++++++++++- slack/views.py | 2 +- 8 files changed, 56 insertions(+), 17 deletions(-) diff --git a/accounts/views.py b/accounts/views.py index f43270b2..dcf735e8 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -84,12 +84,12 @@ def form_valid(self, form): officer_info.save() # Kick user from exec chat in Slack (if applicable) - slack_user = lookup_user(self.object.email) + slack_user = lookup_user(self.object) if slack_user and exec_group in oldgroups: user_kick(settings.SLACK_TARGET_EXEC, slack_user) elif exec_group not in oldgroups: # Attempt to add user to exec chat in Slack - slack_user = lookup_user(self.object.email) + slack_user = lookup_user(self.object) if slack_user: user_add(settings.SLACK_TARGET_EXEC, slack_user) @@ -127,7 +127,7 @@ def get_context_data(self, **kwargs): context['hour_total'] = u.hours.aggregate(hours=Sum('hours')) context['ccs'] = u.ccinstances.select_related('event').all() - slack_id = lookup_user(u.email) + slack_id = lookup_user(u) slack_profile = user_profile(slack_id) if slack_profile['ok']: context['slack_id'] = slack_id diff --git a/events/forms.py b/events/forms.py index 4b91bb05..b84acdfd 100644 --- a/events/forms.py +++ b/events/forms.py @@ -1387,8 +1387,9 @@ def save(self, commit=True): obj.event = self.event if commit: obj.save() - slack_ids = [lookup_user(cci.crew_chief.email) for cci in obj.event.ccinstances.all()] - response = user_add(obj.slack_channel.id, slack_ids) + if obj.event.slack_channel: + slack_ids = [lookup_user(cci.crew_chief) for cci in obj.event.ccinstances.all()] + response = user_add(obj.event.slack_channel.id, slack_ids) if not response['ok']: raise Exception(response) return obj diff --git a/events/signals.py b/events/signals.py index 19cb503a..bc7e934c 100644 --- a/events/signals.py +++ b/events/signals.py @@ -59,7 +59,7 @@ def email_cc_notification(sender, instance, created, raw=False, **kwargs): e.send() if 'Slack Notification' in str(prefs.cc_add_subscriptions).split(', '): blocks = cc_add_notification(instance) - slack_user = lookup_user(instance.crew_chief.email) + slack_user = lookup_user(instance.crew_chief) if slack_user: message = "You've been added as a crew chief for the event %s." % instance.event.event_name slack_post(slack_user, text=message, content=blocks) diff --git a/events/views/flow.py b/events/views/flow.py index 90a15f5f..2d396140 100644 --- a/events/views/flow.py +++ b/events/views/flow.py @@ -243,7 +243,7 @@ def reviewremind(request, id, uid): if send_notification and prefs.cc_report_reminders in ['slack', 'all']: message = "This is a reminder that you have a pending crew chief report for %s." % event.event_name blocks = cc_report_reminder(cci) - slack_user = lookup_user(cci.crew_chief.email) + slack_user = lookup_user(cci.crew_chief) if slack_user: slack_post(slack_user, text=message, content=blocks) messages.add_message(request, messages.INFO, 'Reminder Sent') @@ -286,7 +286,7 @@ def remindall(request, id): if prefs.cc_report_reminders in ['slack', 'all']: message = "This is a reminder that you have a pending crew chief report for %s." % event.event_name blocks = cc_report_reminder(cci) - slack_user = lookup_user(cci.crew_chief.email) + slack_user = lookup_user(cci.crew_chief) if slack_user: slack_post(slack_user, text=message, content=blocks) diff --git a/events/views/mkedrm.py b/events/views/mkedrm.py index 815b87c2..6000dfb0 100644 --- a/events/views/mkedrm.py +++ b/events/views/mkedrm.py @@ -10,7 +10,7 @@ from accounts.models import UserPreferences from emails.generators import EventEmailGenerator from slack.views import event_edited_notification -from slack.api import slack_post, lookup_user, user_add +from slack.api import lookup_user, slack_post, user_add from events.forms import InternalEventForm, InternalEventForm2019, ServiceInstanceForm from events.models import BaseEvent, Event2019, ServiceInstance from helpers.revision import set_revision_comment @@ -85,11 +85,12 @@ def eventnew(request, id=None): obj.save() except requests.JSONDecodeError: pass - if obj.slack_channel and obj.slack_channel.channel_id: - usernames = obj.ccinstances.all().values_list('username', flat=True) - response = user_add(obj.slack_channel.channel_id, usernames) + if obj.slack_channel: + slack_ids = [lookup_user(cci.crew_chief) for cci in obj.ccinstances.all()] + response = user_add(obj.slack_channel.id, slack_ids) if not response['ok']: - raise Exception(response) + messages.add_message(request, messages.WARNING, "There was an error adding the crew chiefs " + "to the Slack channel. (Slack error: %s)" % response['error']) if is_event2019: services_formset.save() @@ -101,7 +102,7 @@ def eventnew(request, id=None): bcc.append(ccinstance.crew_chief.email) if 'slack' in methods: blocks = event_edited_notification(obj, request.user, form.changed_data) - slack_user = lookup_user(ccinstance.crew_chief.email) + slack_user = lookup_user(ccinstance.crew_chief) if slack_user: slack_post(slack_user, text="%s was just edited" % obj.event_name, content=blocks) if obj.reviewed: diff --git a/rt/views.py b/rt/views.py index 096de2f8..6a1d1b47 100644 --- a/rt/views.py +++ b/rt/views.py @@ -33,7 +33,7 @@ def new_ticket(request): messages.success(request, "Your ticket has been submitted. Thank you!") # Send Slack notification - slack_user = lookup_user(request.user.email) + slack_user = lookup_user(request.user) if not slack_user: slack_user = request.user.username ticket_info = { diff --git a/slack/api.py b/slack/api.py index a53585bb..99e6395f 100755 --- a/slack/api.py +++ b/slack/api.py @@ -1,8 +1,11 @@ import logging +from typing import Iterable from django.conf import settings from slack_sdk import WebClient from slack_sdk.errors import SlackApiError +from accounts.models import User + logger = logging.getLogger(__name__) @@ -535,7 +538,7 @@ def user_profile_image(user_id, size='original'): return None -def lookup_user(email): +def lookup_user_by_email(email): """ Will search for a user in the Slack workspace using their email address @@ -556,6 +559,40 @@ def lookup_user(email): assert e.response['ok'] is False return None +def lookup_user(user:User): + """ + Will search for a user in the Slack workspace using their email address + + :param user: User object + :return: The identifier for the user in Slack (`None` if the search returns nothing) + """ + lookup_user_by_email(user.email) + +def lookup_users(users: Iterable[User]): + """ + Will search for a list of users in the Slack workspace using their email addresses + + :param users: A list of User objects + :return: A dictionary mapping email addresses to user IDs + """ + + if not settings.SLACK_TOKEN: + return [] + + client = WebClient(token=settings.SLACK_TOKEN) + + user_ids = [] + for user in users: + try: + response = client.users_lookupByEmail(email=user.email) + assert response['ok'] is True + user_ids.append(response['user']['id']) + except SlackApiError as e: + assert e.response['ok'] is False + + return user_ids + + def check_presence(user): """ diff --git a/slack/views.py b/slack/views.py index b8cc271a..2b542242 100644 --- a/slack/views.py +++ b/slack/views.py @@ -876,7 +876,7 @@ def event_edited_notification(event, triggered_by, fields_changed): """ user = triggered_by.get_full_name() - slack_user = user_profile(lookup_user(triggered_by.email)) + slack_user = user_profile(lookup_user(triggered_by)) if slack_user['ok']: user = "@" + slack_user['user']['name'] From 3a87bc00588d3ed283b14700710289aa4ffbd969 Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Mon, 6 Jan 2025 02:52:16 -0500 Subject: [PATCH 25/34] Remove exceptions for slack channel user add --- events/forms.py | 3 ++- slack/models.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/events/forms.py b/events/forms.py index b84acdfd..5f06001a 100644 --- a/events/forms.py +++ b/events/forms.py @@ -1391,7 +1391,8 @@ def save(self, commit=True): slack_ids = [lookup_user(cci.crew_chief) for cci in obj.event.ccinstances.all()] response = user_add(obj.event.slack_channel.id, slack_ids) if not response['ok']: - raise Exception(response) + #raise ValidationError(f"Slack Error: {response['error']}") # TODO: Add error catching for slack channel adding CCs + pass return obj class Meta: diff --git a/slack/models.py b/slack/models.py index ce98d709..359cdbe7 100755 --- a/slack/models.py +++ b/slack/models.py @@ -184,5 +184,6 @@ def update_channel_members_on_save(sender, instance:Channel, *args, **kwargs): usernames = group.user_set.all().values_list('username', flat=True) response = user_add(channel.id, usernames) if not response['ok']: - raise Exception(response) + # raise Exception(response) # TODO: Add exception catching for Slack channel member updates + pass super().save(*args, **kwargs) \ No newline at end of file From 27453d4213fa3c5cefbb5449f82419fc9840b766 Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Mon, 6 Jan 2025 04:09:32 -0500 Subject: [PATCH 26/34] Create Event Channel modal --- events/urls/events.py | 1 + events/views/flow.py | 30 +++++++++++++++++++++++++++++- site_tmpl/uglydetail.html | 35 +++++++++++++++++++++++++++++++++++ slack/api.py | 21 +++++++++++++++++++++ slack/models.py | 10 +++++++++- 5 files changed, 95 insertions(+), 2 deletions(-) diff --git a/events/urls/events.py b/events/urls/events.py index 93ef770e..a732593a 100644 --- a/events/urls/events.py +++ b/events/urls/events.py @@ -71,6 +71,7 @@ def generate_date_patterns(func, name): re_path(r'^close/(?P[0-9a-f]+)/$', flow_views.close, name="close"), re_path(r'^cancel/(?P[0-9a-f]+)/$', flow_views.cancel, name="cancel"), re_path(r'^reopen/(?P[0-9a-f]+)/$', flow_views.reopen, name="reopen"), + re_path(r'^createchannel/(?P[0-9a-f]+)/$', flow_views.createchannel, name="create-channel"), re_path(r'^crew/(?P[0-9a-f]+)/$', flow_views.assigncrew, name="add-crew"), re_path(r'^rmcrew/(?P[0-9a-f]+)/(?P[0-9a-f]+)/$', flow_views.rmcrew, name="remove-crew"), diff --git a/events/views/flow.py b/events/views/flow.py index 2d396140..22d7307e 100644 --- a/events/views/flow.py +++ b/events/views/flow.py @@ -21,8 +21,9 @@ from accounts.models import UserPreferences from emails.generators import (ReportReminderEmailGenerator, EventEmailGenerator, BillingEmailGenerator, DefaultLNLEmailGenerator as DLEG, send_survey_if_necessary) +from slack.models import Channel from slack.views import cc_report_reminder -from slack.api import lookup_user, slack_post +from slack.api import create_channel, get_id_from_name, lookup_user, slack_post, validate_channel from events.forms import ( AttachmentForm, BillingForm, BillingUpdateForm, MultiBillingForm, MultiBillingUpdateForm, CCIForm, CrewAssign, EventApprovalForm, @@ -365,6 +366,33 @@ def reopen(request, id): return HttpResponseRedirect(reverse('events:detail', args=(event.id,))) +@login_required +@require_POST +def createchannel(request, id): + """ Create new Slack channel for event. POST only. """ + event = get_object_or_404(BaseEvent, pk=id) + channel_name = request.POST.get('channel_name', '') + if not request.user.has_perm('slack.change_channel', event): + raise PermissionDenied + if not channel_name: + messages.add_message(request, messages.ERROR, 'No channel name provided.') + if validate_channel(channel_name): + event.slack_channel = Channel.get_or_create(get_id_from_name(channel_name)) + messages.add_message(request, messages.INFO, 'Slack channel #%s found and added to event.' % channel_name) + else: + response = create_channel(name=channel_name) #test: {'ok':True,'channel':{'id':'C4GHVAZRP'}} + if not response['ok']: + messages.add_message(request, messages.ERROR, 'Error creating channel. (Slack error: %s)' % response['error']) + else: + event.slack_channel = Channel.get_or_create(response['channel']['id']) + messages.add_message(request, messages.INFO, 'Slack channel #%s created and added to event.' % channel_name) + if event.slack_channel: + if event.ccinstances.exists(): + event.slack_channel.add_ccs_to_channel() + messages.add_message(request, messages.INFO, 'Event CCs added to channel.' % channel_name) + event.save() + + return HttpResponseRedirect(reverse('events:detail', args=(event.id,))) @login_required def rmcrew(request, id, user): diff --git a/site_tmpl/uglydetail.html b/site_tmpl/uglydetail.html index df0b57c3..8ae75a3d 100644 --- a/site_tmpl/uglydetail.html +++ b/site_tmpl/uglydetail.html @@ -22,6 +22,11 @@

    Status: {{ event.status }}

    {% if request.user.is_lnl and event.slack_channel %} Open Slack {% endif %} + {% permission request.user has 'slack.change_channel' %} + {% if not event.slack_channel %} + Create Slack Channel + {% endif %} + {% endpermission %} {% permission request.user has 'events.view_event_reports' of event %} PDF {% endpermission %} @@ -172,6 +177,36 @@

    Confirm Reopen Event

    {% endpermission %} + {% permission request.user has 'events.reopen_event' of event %} + + + {% endpermission %} + +
    diff --git a/slack/api.py b/slack/api.py index 99e6395f..57e67114 100755 --- a/slack/api.py +++ b/slack/api.py @@ -242,6 +242,27 @@ def upload(attachment, filename, title=None, message=None, channels=None): assert e.response['ok'] is False return e.response +def create_channel(name, is_private=False): + """ + Create a new channel in Slack + + :param name: The name of the new channel + :param is_private: Boolean - Create a private channel + :returns: Response object (Dictionary) + """ + + if not settings.SLACK_TOKEN: + return {'ok': False, 'error': 'config_error'} + + client = WebClient(token=settings.SLACK_TOKEN) + + try: + response = client.conversations_create(name=name, is_private=is_private) + assert response['ok'] is True + return {'ok': True, 'channel': response['channel']} + except SlackApiError as e: + assert e.response['ok'] is False + return e.response def slack_post(channel, thread=None, text=None, content=None, username=None, icon_url=None, attachment=None): """ diff --git a/slack/models.py b/slack/models.py index 359cdbe7..7feac924 100755 --- a/slack/models.py +++ b/slack/models.py @@ -9,7 +9,7 @@ from lnldb import settings from django.forms import ValidationError -from .api import channel_info, channel_members, channel_latest_message, get_id_from_name, user_add, user_profile +from .api import channel_info, channel_members, channel_latest_message, get_id_from_name, lookup_user, user_add, user_profile SLACK_CHANNEL_ID_REGEX = r'/(C0\w+)' @@ -168,6 +168,14 @@ def info(self) -> dict: @property def link(self) -> str: return settings.SLACK_BASE_URL+"/archives/"+self.id+"/" + + def add_ccs_to_channel(self): + for event in self.events.all(): + slack_ids = [lookup_user(cci.crew_chief) for cci in event.ccinstances.all()] + response = user_add(self.id, slack_ids) + if not response['ok']: + # raise Exception(response) # TODO: Add exception catching for Slack channel member updates + pass class Meta: permissions = () From 51157cb1b942638ffad07949cbdf9e448136c174 Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Mon, 6 Jan 2025 04:16:21 -0500 Subject: [PATCH 27/34] Switch to add_ccs_to_channel() Attempting better DRY ("Don't Repeat Yourself") --- events/forms.py | 7 +------ events/views/mkedrm.py | 9 ++++----- slack/models.py | 8 +++++++- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/events/forms.py b/events/forms.py index 5f06001a..55f29a6f 100644 --- a/events/forms.py +++ b/events/forms.py @@ -30,7 +30,6 @@ from events.widgets import ValueSelectField from helpers.form_text import markdown_at_msgs from helpers.util import curry_class -from slack.api import lookup_user, user_add from slack.models import Channel LIGHT_EXTRAS = Extra.objects.exclude(disappear=True).filter(category__name="Lighting") @@ -1388,11 +1387,7 @@ def save(self, commit=True): if commit: obj.save() if obj.event.slack_channel: - slack_ids = [lookup_user(cci.crew_chief) for cci in obj.event.ccinstances.all()] - response = user_add(obj.event.slack_channel.id, slack_ids) - if not response['ok']: - #raise ValidationError(f"Slack Error: {response['error']}") # TODO: Add error catching for slack channel adding CCs - pass + obj.event.slack_channel.add_ccs_to_channel() return obj class Meta: diff --git a/events/views/mkedrm.py b/events/views/mkedrm.py index 6000dfb0..f1b9f38a 100644 --- a/events/views/mkedrm.py +++ b/events/views/mkedrm.py @@ -10,7 +10,7 @@ from accounts.models import UserPreferences from emails.generators import EventEmailGenerator from slack.views import event_edited_notification -from slack.api import lookup_user, slack_post, user_add +from slack.api import lookup_user, slack_post from events.forms import InternalEventForm, InternalEventForm2019, ServiceInstanceForm from events.models import BaseEvent, Event2019, ServiceInstance from helpers.revision import set_revision_comment @@ -86,11 +86,10 @@ def eventnew(request, id=None): except requests.JSONDecodeError: pass if obj.slack_channel: - slack_ids = [lookup_user(cci.crew_chief) for cci in obj.ccinstances.all()] - response = user_add(obj.slack_channel.id, slack_ids) - if not response['ok']: + success = obj.slack_channel.add_ccs_to_channel() + if not success: messages.add_message(request, messages.WARNING, "There was an error adding the crew chiefs " - "to the Slack channel. (Slack error: %s)" % response['error']) + "to the Slack channel. (Slack error: %s)" % None) # response['error']) if is_event2019: services_formset.save() diff --git a/slack/models.py b/slack/models.py index 7feac924..084610e6 100755 --- a/slack/models.py +++ b/slack/models.py @@ -169,13 +169,19 @@ def info(self) -> dict: def link(self) -> str: return settings.SLACK_BASE_URL+"/archives/"+self.id+"/" - def add_ccs_to_channel(self): + def add_ccs_to_channel(self) -> bool: + ''' + Adds all crew chiefs for events in this channel to the channel + + :return: True if successful, False if not + ''' for event in self.events.all(): slack_ids = [lookup_user(cci.crew_chief) for cci in event.ccinstances.all()] response = user_add(self.id, slack_ids) if not response['ok']: # raise Exception(response) # TODO: Add exception catching for Slack channel member updates pass + return True class Meta: permissions = () From 57033c63c3639b20e58c3cb50f6fa15812b5d921 Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Mon, 6 Jan 2025 04:17:58 -0500 Subject: [PATCH 28/34] Update models.py --- slack/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack/models.py b/slack/models.py index 084610e6..c242471f 100755 --- a/slack/models.py +++ b/slack/models.py @@ -175,7 +175,7 @@ def add_ccs_to_channel(self) -> bool: :return: True if successful, False if not ''' - for event in self.events.all(): + for event in self.event.all(): slack_ids = [lookup_user(cci.crew_chief) for cci in event.ccinstances.all()] response = user_add(self.id, slack_ids) if not response['ok']: From caade8cd60a5ff0a8539d9f4e9a6d940fcca0444 Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Mon, 6 Jan 2025 04:47:32 -0500 Subject: [PATCH 29/34] Add event checkbox to notify slack channel w/edits --- events/forms.py | 6 ++- ...notifications_in_slack_channel_and_more.py | 47 +++++++++++++++++++ events/models.py | 1 + events/views/flow.py | 5 +- events/views/mkedrm.py | 3 ++ 5 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 events/migrations/0015_baseevent_notifications_in_slack_channel_and_more.py diff --git a/events/forms.py b/events/forms.py index 55f29a6f..fa4b2927 100644 --- a/events/forms.py +++ b/events/forms.py @@ -435,6 +435,7 @@ def __init__(self, request_user, *args, **kwargs): 'billed_in_bulk', 'sensitive', 'test_event', + 'notifications_in_slack_channel', active=True ), Tab( @@ -557,7 +558,7 @@ class Meta: fields = ('event_name', 'event_status', 'location', 'lnl_contact', 'description', 'internal_notes', 'billing_org', 'billed_in_bulk', 'contact', 'org', 'datetime_setup_complete', 'datetime_start', 'datetime_end', 'lighting', 'lighting_reqs', 'sound', 'sound_reqs', 'projection', 'proj_reqs', 'otherservices', 'otherservice_reqs', 'sensitive', - 'test_event') + 'test_event', 'notifications_in_slack_channel') widgets = { 'description': EasyMDEEditor(), 'internal_notes': EasyMDEEditor(), @@ -603,6 +604,7 @@ def __init__(self, request_user, *args, **kwargs): 'billed_in_bulk', 'sensitive', 'test_event', + 'notifications_in_slack_channel', 'entered_into_workday', 'send_survey', active=True @@ -727,7 +729,7 @@ class Meta: model = Event2019 fields = ('event_name', 'event_status', 'location', 'lnl_contact', 'description', 'internal_notes', 'billing_org', 'billed_in_bulk', 'contact', 'org', 'datetime_setup_complete', 'datetime_start', - 'datetime_end', 'sensitive', 'test_event', + 'datetime_end', 'sensitive', 'test_event','notifications_in_slack_channel', 'entered_into_workday', 'send_survey', 'max_crew','cancelled_reason', 'reference_code','slack_channel',) widgets = { diff --git a/events/migrations/0015_baseevent_notifications_in_slack_channel_and_more.py b/events/migrations/0015_baseevent_notifications_in_slack_channel_and_more.py new file mode 100644 index 00000000..3fce2eca --- /dev/null +++ b/events/migrations/0015_baseevent_notifications_in_slack_channel_and_more.py @@ -0,0 +1,47 @@ +# Generated by Django 4.2.13 on 2025-01-06 09:44 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("slack", "0005_channel_allowed_groups_channel_required_groups"), + ("events", "0014_baseevent_slack_channel_organization_slack_channel"), + ] + + operations = [ + migrations.AddField( + model_name="baseevent", + name="notifications_in_slack_channel", + field=models.BooleanField( + default=True, + help_text="Check to send event edited notifications to the event slack channel", + verbose_name="Send edit notifications to Slack channel", + ), + ), + migrations.AlterField( + model_name="baseevent", + name="slack_channel", + field=models.ForeignKey( + blank=True, + help_text="Slack Channel ID, i.e. C4HB02R6H", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="event", + to="slack.channel", + ), + ), + migrations.AlterField( + model_name="organization", + name="slack_channel", + field=models.ForeignKey( + blank=True, + help_text="Slack Channel ID, i.e. C4HB02R6H", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="organization", + to="slack.channel", + ), + ), + ] diff --git a/events/models.py b/events/models.py index 030c5b4e..6938c3cc 100755 --- a/events/models.py +++ b/events/models.py @@ -256,6 +256,7 @@ class BaseEvent(PolymorphicModel): billed_in_bulk = models.BooleanField(default=False, db_index=True, help_text="Check if billing of this event will be deferred so that it can be combined with other events in a single invoice") sensitive = models.BooleanField(default=False, help_text="Nobody besides those directly involved should know about this event") test_event = models.BooleanField(default=False, help_text="Check to lower the VP's blood pressure after they see the short-notice S4/L4") + notifications_in_slack_channel = models.BooleanField(default=True, help_text="Check to send event edited notifications to the event slack channel", verbose_name="Send edit notifications to Slack channel") slack_channel = models.ForeignKey('slack.Channel', on_delete=models.PROTECT, null=True, blank=True, related_name='event', help_text="Slack Channel ID, i.e. C4HB02R6H") diff --git a/events/views/flow.py b/events/views/flow.py index 22d7307e..22fbab02 100644 --- a/events/views/flow.py +++ b/events/views/flow.py @@ -22,7 +22,7 @@ from emails.generators import (ReportReminderEmailGenerator, EventEmailGenerator, BillingEmailGenerator, DefaultLNLEmailGenerator as DLEG, send_survey_if_necessary) from slack.models import Channel -from slack.views import cc_report_reminder +from slack.views import cc_report_reminder, event_edited_notification from slack.api import create_channel, get_id_from_name, lookup_user, slack_post, validate_channel from events.forms import ( AttachmentForm, BillingForm, BillingUpdateForm, MultiBillingForm, @@ -803,6 +803,9 @@ def assignattach(request, id): event.save() # for revision to be created should_send_email = not event.test_event if should_send_email: + if event.notifications_in_slack_channel: + blocks = event_edited_notification(event, request.user, 'attachments') + slack_post(event.slack_channel, text="The attachments for %s were just edited" % event.event_name, content=blocks) to = [settings.EMAIL_TARGET_VP_DB] if hasattr(event, 'projection') and event.projection \ or event.serviceinstance_set.filter(service__category__name='Projection').exists(): diff --git a/events/views/mkedrm.py b/events/views/mkedrm.py index f1b9f38a..4a7f3e47 100644 --- a/events/views/mkedrm.py +++ b/events/views/mkedrm.py @@ -104,6 +104,9 @@ def eventnew(request, id=None): slack_user = lookup_user(ccinstance.crew_chief) if slack_user: slack_post(slack_user, text="%s was just edited" % obj.event_name, content=blocks) + if obj.notifications_in_slack_channel: + blocks = event_edited_notification(obj, request.user, form.changed_data) + slack_post(obj.slack_channel, text="%s was just edited" % obj.event_name, content=blocks) if obj.reviewed: subject = "Reviewed Event Edited" email_body = "The following event was edited by %s after the event was reviewed for billing." \ From 841ed408a15e0920988f4b17da4fcc54f42dd902 Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Mon, 6 Jan 2025 05:00:51 -0500 Subject: [PATCH 30/34] Refactor add group to channel for DRY --- slack/models.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/slack/models.py b/slack/models.py index c242471f..13a2dd14 100755 --- a/slack/models.py +++ b/slack/models.py @@ -182,6 +182,20 @@ def add_ccs_to_channel(self) -> bool: # raise Exception(response) # TODO: Add exception catching for Slack channel member updates pass return True + + def add_group_to_channel(self, group:Group) -> bool: + ''' + Adds all members of a group to the channel + + :param group: Group to add + :return: True if successful, False if not + ''' + slack_ids = [lookup_user(user) for user in group.user_set.all()] + response = user_add(self.id, slack_ids) + if not response['ok']: + # raise Exception(response) # TODO: Add exception catching for Slack channel member updates + pass + return True class Meta: permissions = () @@ -195,9 +209,5 @@ def update_channel_members_on_save(sender, instance:Channel, *args, **kwargs): else: if not channel.required_groups == instance.required_groups: for group in instance.required_groups.all().exclude(pk__in=channel.required_groups.all()): - usernames = group.user_set.all().values_list('username', flat=True) - response = user_add(channel.id, usernames) - if not response['ok']: - # raise Exception(response) # TODO: Add exception catching for Slack channel member updates - pass + channel.add_group_to_channel(group) super().save(*args, **kwargs) \ No newline at end of file From f69f43baafcca9653aa7a2b820f9bfc21587a510 Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Mon, 6 Jan 2025 05:01:16 -0500 Subject: [PATCH 31/34] Add exec to new event channels --- events/views/flow.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/events/views/flow.py b/events/views/flow.py index 22fbab02..c3e22e32 100644 --- a/events/views/flow.py +++ b/events/views/flow.py @@ -4,6 +4,7 @@ from django.conf import settings from django.contrib import messages from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.contrib.auth.decorators import login_required, permission_required from django.core.exceptions import PermissionDenied, ValidationError from django.db.models import Avg, Count @@ -385,6 +386,7 @@ def createchannel(request, id): messages.add_message(request, messages.ERROR, 'Error creating channel. (Slack error: %s)' % response['error']) else: event.slack_channel = Channel.get_or_create(response['channel']['id']) + event.slack_channel.add_group_to_channel(get_object_or_404(Group, name='Officer')) messages.add_message(request, messages.INFO, 'Slack channel #%s created and added to event.' % channel_name) if event.slack_channel: if event.ccinstances.exists(): From 79c7b2b1b3ea9ea15db7acfb5db99f18dc2bfb05 Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Mon, 6 Jan 2025 05:29:58 -0500 Subject: [PATCH 32/34] Set channel topic once DB identifies a channel --- lnldb/settings.py | 1 + slack/api.py | 22 ++++++++++++++++++++++ slack/models.py | 9 +++++++-- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/lnldb/settings.py b/lnldb/settings.py index dea08139..b6ce8249 100644 --- a/lnldb/settings.py +++ b/lnldb/settings.py @@ -106,6 +106,7 @@ def from_runtime(*x): SLACK_BASE_URL = env.str('SLACK_BASE_URL', 'https://wpilnl.slack.com') SLACK_TOKEN = env.str('SLACK_BOT_TOKEN', None) +SLACK_CHANNEL_TOPIC_FOOTER = env.str('SLACK_CHANNEL_TOPIC_FOOTER', ":warning: :ln: This channel is managed by LNLDB :ln: :warning:") # If True, the bot will automatically attempt to join new channels when they are created in Slack SLACK_AUTO_JOIN = env.bool('SLACK_AUTO_JOIN', default=False) diff --git a/slack/api.py b/slack/api.py index 57e67114..218538f1 100755 --- a/slack/api.py +++ b/slack/api.py @@ -264,6 +264,28 @@ def create_channel(name, is_private=False): assert e.response['ok'] is False return e.response +def set_channel_topic(channel, topic): + """ + Set the topic of a channel + + :param channel: The identifier of the Slack channel + :param topic: The new topic to set, 250 characters max + :returns: Response object (Dictionary) + """ + + if not settings.SLACK_TOKEN: + return {'ok': False, 'error': 'config_error'} + + client = WebClient(token=settings.SLACK_TOKEN) + + try: + response = client.conversations_setTopic(channel=channel, topic=topic) + assert response['ok'] is True + return {'ok': True, 'topic': response['topic']} + except SlackApiError as e: + assert e.response['ok'] is False + return e.response + def slack_post(channel, thread=None, text=None, content=None, username=None, icon_url=None, attachment=None): """ Post a message on Slack diff --git a/slack/models.py b/slack/models.py index 13a2dd14..224e1b1f 100755 --- a/slack/models.py +++ b/slack/models.py @@ -9,7 +9,7 @@ from lnldb import settings from django.forms import ValidationError -from .api import channel_info, channel_members, channel_latest_message, get_id_from_name, lookup_user, user_add, user_profile +from .api import channel_info, channel_members, channel_latest_message, get_id_from_name, lookup_user, set_channel_topic, user_add, user_profile SLACK_CHANNEL_ID_REGEX = r'/(C0\w+)' @@ -78,7 +78,12 @@ def get_or_create(channel_id): return Channel.objects.get(id=channel_id) except Channel.DoesNotExist: if True: #validate_channel(channel_id): #TODO: Test validation logic - return Channel.objects.create(id=channel_id) + channel = Channel.objects.create(id=channel_id) + if settings.SLACK_CHANNEL_TOPIC_FOOTER: + topic:str = channel_info(channel_id)['topic']['value'] + topic = topic.replace("\n"+settings.SLACK_CHANNEL_TOPIC_FOOTER, "") + set_channel_topic(channel_id, topic+"\n"+settings.SLACK_CHANNEL_TOPIC_FOOTER) + return channel elif (channel_id := get_id_from_name(channel_id.removeprefix("#"))): return Channel.get_or_create(channel_id) else: From cabc70aa2c42ee50cd3e767a08648b6b8dbf9f3f Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Mon, 6 Jan 2025 05:30:18 -0500 Subject: [PATCH 33/34] Set topic when linking or creating event channel --- events/views/flow.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/events/views/flow.py b/events/views/flow.py index 7d5fe883..061533ed 100644 --- a/events/views/flow.py +++ b/events/views/flow.py @@ -1,6 +1,7 @@ import math from datetime import timedelta from decimal import Decimal +import re from django.conf import settings from django.contrib import messages from django.contrib.auth import get_user_model @@ -24,7 +25,7 @@ DefaultLNLEmailGenerator as DLEG, send_survey_if_necessary) from slack.models import Channel from slack.views import cc_report_reminder, event_edited_notification -from slack.api import create_channel, get_id_from_name, lookup_user, slack_post, validate_channel +from slack.api import channel_info, create_channel, get_id_from_name, lookup_user, slack_post, validate_channel from events.forms import ( AttachmentForm, BillingForm, BillingUpdateForm, MultiBillingForm, MultiBillingUpdateForm, CCIForm, CrewAssign, EventApprovalForm, @@ -394,6 +395,9 @@ def createchannel(request, id): if event.ccinstances.exists(): event.slack_channel.add_ccs_to_channel() messages.add_message(request, messages.INFO, 'Event CCs added to channel.' % channel_name) + topic = channel_info(event.slack_channel.id)['topic']['value'] + topic = re.sub(r'https:\/\/lnl\.wpi\.edu\/db\/events\/view\/\d+\/', '', topic) + event.slack_channel.set_channel_topic(reverse('events:detail', args=(event.id,))+topic) event.save() return HttpResponseRedirect(reverse('events:detail', args=(event.id,))) From 1eab710b53c8a41a3a02ec9cc084ead9e9d0c0ff Mon Sep 17 00:00:00 2001 From: Benjamin A Date: Mon, 6 Jan 2025 05:31:11 -0500 Subject: [PATCH 34/34] Update settings.py --- lnldb/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lnldb/settings.py b/lnldb/settings.py index b6ce8249..2d8a65e0 100644 --- a/lnldb/settings.py +++ b/lnldb/settings.py @@ -106,7 +106,7 @@ def from_runtime(*x): SLACK_BASE_URL = env.str('SLACK_BASE_URL', 'https://wpilnl.slack.com') SLACK_TOKEN = env.str('SLACK_BOT_TOKEN', None) -SLACK_CHANNEL_TOPIC_FOOTER = env.str('SLACK_CHANNEL_TOPIC_FOOTER', ":warning: :ln: This channel is managed by LNLDB :ln: :warning:") +SLACK_CHANNEL_TOPIC_FOOTER = env.str('SLACK_CHANNEL_TOPIC_FOOTER', None)#":warning: :ln: This channel is managed by LNLDB :ln: :warning:") # If True, the bot will automatically attempt to join new channels when they are created in Slack SLACK_AUTO_JOIN = env.bool('SLACK_AUTO_JOIN', default=False)