From 7c27049b50ea57e1dfc6b75153393e29e5056836 Mon Sep 17 00:00:00 2001 From: Krishna Kaushal <104532938+tsu-ki@users.noreply.github.com> Date: Sun, 19 Jan 2025 21:03:31 +0530 Subject: [PATCH 01/13] Anonymous can post suggestions and Improved UI (#3158) * initial commit * second commit * fixed pre-commit * fixed pre-commit * fixed pre-commit * pre-commit * CORS policy fix --------- Co-authored-by: DonnieBLT <128622481+DonnieBLT@users.noreply.github.com> --- website/templates/feature_suggestion.html | 414 ++++++++++++---------- website/views/core.py | 7 +- 2 files changed, 231 insertions(+), 190 deletions(-) diff --git a/website/templates/feature_suggestion.html b/website/templates/feature_suggestion.html index 28cfa29ea..a7deb10b9 100644 --- a/website/templates/feature_suggestion.html +++ b/website/templates/feature_suggestion.html @@ -14,146 +14,223 @@ -
-

Suggest Features

+
+

Suggest Features

    {% for suggestion in suggestions %} -
  • -

    - {{ suggestion.title }} -

    -

    - Description: {{ suggestion.description }} -

    -

    - User:{{ suggestion.user }} -

    - -
    - - - - +
  • +

    {{ suggestion.title }}

    +
    +

    {{ suggestion.description }}

    +
    +
    + Posted by + {% if suggestion.user %} + {{ suggestion.user }} + {% else %} + Anonymous + {% endif %} + +
    + + +
    +
  • {% endfor %}
-

Suggest us Features

-
+

Submit Your Suggestion

+ {% csrf_token %} - - - - -
- +
+ + +
+
+ + +
+
@@ -162,86 +239,44 @@

Suggest us Features

setvote(); }; - function setvote(){ + function setvote() { document.querySelectorAll('p[id^="suggestion-id-"]').forEach(function(element) { const id = element.id.split('---')[1]; const upvoteBtn = document.getElementById(`upvote---${id}`); const downvoteBtn = document.getElementById(`downvote---${id}`); + fetch('/suggestion/set-vote-status/', { - method:'POST', - headers:{ - 'Content-Type' : 'application/json', + method: 'POST', + headers: { + 'Content-Type': 'application/json', 'X-CSRFToken': getCookie('csrftoken'), }, - body : JSON.stringify({id:id}), + body: JSON.stringify({id: id}), }) - .then(response=>response.json()) - .then(data=>{ - if(data.up_vote){ + .then(response => response.json()) + .then(data => { + if(data.up_vote) { upvoteBtn.classList.add('active'); - } - else if(data.down_vote){ + } else if(data.down_vote) { downvoteBtn.classList.add('active'); } - }); + }) + .catch(error => console.error('Error:', error)); }); } - function upvote(event) { - const button = event.target; - const id = button.id.split('---')[1]; - - const upvoteBtn = document.getElementById(`upvote---${id}`); - const downvoteBtn = document.getElementById(`downvote---${id}`); - const upvotesLabel = document.getElementById(`up_vote-${id}`); - const downvotesLabel = document.getElementById(`down_vote-${id}`); - - if (!upvoteBtn || !downvoteBtn || !upvotesLabel || !downvotesLabel) { - console.error('Element not found for id:', id); - return; - } - - let Up_Vote; - let Down_Vote; - - if (upvoteBtn.classList.contains('active')) { - upvotesLabel.innerHTML = parseInt(upvotesLabel.innerHTML) - 1; - upvoteBtn.classList.remove('active'); - Up_Vote=false; - } - else { - upvotesLabel.innerHTML = parseInt(upvotesLabel.innerHTML) + 1; - upvoteBtn.classList.add('active'); - Up_Vote = true; - - if (downvoteBtn.classList.contains('active')) { - downvoteBtn.classList.remove('active'); - downvotesLabel.innerHTML = parseInt(downvotesLabel.innerHTML) - 1; - Down_Vote = false; - } - } - fetch('/suggestion/vote/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': getCookie('csrftoken'), - }, - body: JSON.stringify({ suggestion_id: id, up_vote: Up_Vote, down_vote: Down_Vote }), - }) - .then(response => response.json()) - .then(data => { - console.log('Vote saved:', data.votes); - }) - .catch(error => { - console.error('Error:', error); - }); + handleVote(event, true); } function downvote(event) { - const button = event.target; - const id = button.id.split('---')[1]; + handleVote(event, false); + } + function handleVote(event, isUpvote) { + const button = event.target.closest('button'); + const id = button.id.split('---')[1]; + const upvoteBtn = document.getElementById(`upvote---${id}`); const downvoteBtn = document.getElementById(`downvote---${id}`); const upvotesLabel = document.getElementById(`up_vote-${id}`); @@ -252,23 +287,36 @@

Suggest us Features

return; } - let Up_Vote; - let Down_Vote; + let upVote = false; + let downVote = false; - if (downvoteBtn.classList.contains('active')) { - downvotesLabel.innerHTML = parseInt(downvotesLabel.innerHTML) - 1; - downvoteBtn.classList.remove('active'); - Down_Vote = false; - } - else { - downvotesLabel.innerHTML = parseInt(downvotesLabel.innerHTML) + 1; - downvoteBtn.classList.add('active'); - Down_Vote = true; - + if (isUpvote) { if (upvoteBtn.classList.contains('active')) { - upvoteBtn.classList.remove('active'); upvotesLabel.innerHTML = parseInt(upvotesLabel.innerHTML) - 1; - Up_Vote = false; + upvoteBtn.classList.remove('active'); + } else { + upvotesLabel.innerHTML = parseInt(upvotesLabel.innerHTML) + 1; + upvoteBtn.classList.add('active'); + upVote = true; + + if (downvoteBtn.classList.contains('active')) { + downvoteBtn.classList.remove('active'); + downvotesLabel.innerHTML = parseInt(downvotesLabel.innerHTML) - 1; + } + } + } else { + if (downvoteBtn.classList.contains('active')) { + downvotesLabel.innerHTML = parseInt(downvotesLabel.innerHTML) - 1; + downvoteBtn.classList.remove('active'); + } else { + downvotesLabel.innerHTML = parseInt(downvotesLabel.innerHTML) + 1; + downvoteBtn.classList.add('active'); + downVote = true; + + if (upvoteBtn.classList.contains('active')) { + upvoteBtn.classList.remove('active'); + upvotesLabel.innerHTML = parseInt(upvotesLabel.innerHTML) - 1; + } } } @@ -276,35 +324,25 @@

Suggest us Features

method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-CSRFToken': getCookie('csrftoken'), // Fetch CSRF token from cookies + 'X-CSRFToken': getCookie('csrftoken'), }, - body: JSON.stringify({ suggestion_id: id, up_vote: Up_Vote, down_vote: Down_Vote }), + body: JSON.stringify({ + suggestion_id: id, + up_vote: upVote, + down_vote: downVote + }), }) .then(response => response.json()) - .then(data => { - console.log('Vote saved:', data.votes); - }) - .catch(error => { - console.error('Error:', error); - }); + .then(data => console.log('Vote saved:', data.votes)) + .catch(error => console.error('Error:', error)); } function getCookie(name) { - let cookieValue = null; - if (document.cookie && document.cookie !== '') { - const cookies = document.cookie.split(';'); - for (let i = 0; i < cookies.length; i++) { - const cookie = cookies[i].trim(); - if (cookie.startsWith(name + '=')) { - cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); - break; - } - } - } - return cookieValue; + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop().split(';').shift(); } - function Savefeature() { const form = document.getElementById("suggestionForm"); const formData = new FormData(form); @@ -312,7 +350,7 @@

Suggest us Features

const description = formData.get('description'); if (!title) { - alert('Please fill all the required fields.'); + alert('Please enter a title for your suggestion.'); return; } @@ -322,19 +360,21 @@

Suggest us Features

'X-CSRFToken': formData.get('csrfmiddlewaretoken'), 'Content-Type': 'application/json' }, - body: JSON.stringify({title: title,description: description}), + body: JSON.stringify({ + title: title, + description: description + }), }) .then(response => response.json()) .then(data => { if (data.status === 'success') { form.reset(); + location.reload(); } else { - alert('Error: Please fill all the fields.'); + alert('Error: Please fill all required fields.'); } }) - .catch((error) => { - console.error('Error:', error); - }); + .catch(error => console.error('Error:', error)); } {% endblock content %} diff --git a/website/views/core.py b/website/views/core.py index 02f8db300..e89f1aa79 100644 --- a/website/views/core.py +++ b/website/views/core.py @@ -521,14 +521,13 @@ def set_vote_status(request): return JsonResponse({"success": False, "error": "Invalid request method"}, status=400) -@login_required def add_suggestions(request): if request.method == "POST": - user = request.user + user = request.user if request.user.is_authenticated else None data = json.loads(request.body) title = data.get("title") description = data.get("description", "") - if title and description and user: + if title and description: suggestion = Suggestion(user=user, title=title, description=description) suggestion.save() messages.success(request, "Suggestion added successfully.") @@ -536,6 +535,8 @@ def add_suggestions(request): else: messages.error(request, "Please fill all the fields.") return JsonResponse({"status": "error"}, status=400) + else: + return JsonResponse({"status": "error", "message": "Method not allowed"}, status=405) class GoogleLogin(SocialLoginView): From b51339cf64375d90cf5a6677401609b6a4908335 Mon Sep 17 00:00:00 2001 From: Krishna Kaushal <104532938+tsu-ki@users.noreply.github.com> Date: Sun, 19 Jan 2025 21:26:25 +0530 Subject: [PATCH 02/13] Creating slack-bot for OWASP slack workspace (#3145) * slackbot initial commit * slack bot integration * major changes * resolving CI/CD * CI/CD * fixed pre-commit * fixing --- .env.example | 3 +- blt/urls.py | 4 +- website/views/company.py | 4 +- website/views/slackbot.py | 408 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 416 insertions(+), 3 deletions(-) create mode 100644 website/views/slackbot.py diff --git a/.env.example b/.env.example index 2dd50371d..4302469c6 100644 --- a/.env.example +++ b/.env.example @@ -29,7 +29,8 @@ SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0 SLACK_CLIENT_ID= SLACK_CLIENT_SECRET= - +SLACK_BOT_TOKEN= +SLACK_SIGNING_SECRET= #BlueSky User Details BLUESKY_USERNAME=example.bsky.social diff --git a/blt/urls.py b/blt/urls.py index adfa7f302..286b4e691 100644 --- a/blt/urls.py +++ b/blt/urls.py @@ -187,6 +187,7 @@ select_contribution, ) from website.views.slack_handlers import slack_events +from website.views.slackbot import slack_commands from website.views.teams import ( TeamOverview, add_member, @@ -289,7 +290,8 @@ re_path(r"^auth/github/connect/$", GithubConnect.as_view(), name="github_connect"), re_path(r"^auth/google/connect/$", GoogleConnect.as_view(), name="google_connect"), path("auth/github/url/", github_views.oauth2_login), - path("oauth/slack/callback/", SlackCallbackView.as_view(), name="slack_callback"), + path("oauth/slack/callback/", SlackCallbackView.as_view(), name="slack_oauth_callback"), + path("slack/commands/", slack_commands, name="slack_commands"), path("auth/google/url/", google_views.oauth2_login), path("auth/facebook/url/", facebook_views.oauth2_callback), path("socialaccounts/", SocialAccountListView.as_view(), name="social_account_list"), diff --git a/website/views/company.py b/website/views/company.py index 71122a552..4d94868e7 100644 --- a/website/views/company.py +++ b/website/views/company.py @@ -1020,7 +1020,9 @@ def exchange_code_for_token(self, code, request): client_secret = os.getenv("SLACK_CLIENT_SECRET") host = request.get_host() scheme = request.META.get("HTTP_X_FORWARDED_PROTO", request.scheme) - redirect_uri = f"{scheme}://{host}/oauth/slack/callback" + redirect_uri = os.environ.get( + "OAUTH_REDIRECT_URL", f"{request.scheme}://{request.get_host()}/oauth/slack/callback" + ) url = "https://slack.com/api/oauth.v2.access" data = { diff --git a/website/views/slackbot.py b/website/views/slackbot.py new file mode 100644 index 000000000..5ab7d502e --- /dev/null +++ b/website/views/slackbot.py @@ -0,0 +1,408 @@ +import logging +import math +import os +import re +import time + +import requests +from django.http import HttpResponse, JsonResponse +from django.views.decorators.csrf import csrf_exempt +from slack_bolt import App +from slack_bolt.adapter.django import SlackRequestHandler + +if os.getenv("ENV") != "production": + from dotenv import load_dotenv + + load_dotenv() + +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + +SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN") +SLACK_SIGNING_SECRET = os.environ.get("SLACK_SIGNING_SECRET") + +if not SLACK_BOT_TOKEN or not SLACK_SIGNING_SECRET: + logger.warning("Slack environment not set. Slack integration disabled.") + app = None + handler = None +else: + app = App(token=SLACK_BOT_TOKEN, signing_secret=SLACK_SIGNING_SECRET) + handler = SlackRequestHandler(app) + +pagination_data = {} + +repo_cache = {"timestamp": 0, "data": []} +CACHE_DURATION = 3600 + + +def get_all_owasp_repos(): + """Fetch ALL repos from the OWASP org by paginating through the results.""" + current_time = time.time() + if repo_cache["data"] and (current_time - repo_cache["timestamp"] < CACHE_DURATION): + logger.debug("Using cached OWASP repositories.") + return repo_cache["data"] + + all_repos = [] + page = 1 + while True: + resp = requests.get( + f"https://api.github.com/orgs/OWASP/repos?page={page}&per_page=100", + headers={"Accept": "application/vnd.github.mercy-preview+json"}, + ) + if resp.status_code != 200: + logger.error(f"Failed to fetch repos (page {page}): {resp.text}") + break + + page_data = resp.json() + if not page_data: + break # no more repositories + all_repos.extend(page_data) + page += 1 + + repo_cache["data"] = all_repos + repo_cache["timestamp"] = current_time + logger.debug("Fetched and cached OWASP repositories.") + + return all_repos + + +if app: + + @app.command("/discover") + def handle_discover_command(ack, client, command): + try: + ack() + + # Extract the search term from the command text + search_term = command.get("text", "").strip() + + # If search term exists then search for OWASP projects + if search_term: + repos = get_all_owasp_repos() + if not repos: + send_dm(client, command["user_id"], "Failed to fetch OWASP repositories.") + return + + matched = [] + url_pattern = re.compile(r"https?://\S+") + + for idx, repo in enumerate(repos, start=1): + name_desc = (repo["name"] + " " + (repo["description"] or "")).lower() + lang = (repo["language"] or "").lower() + topics = [t.lower() for t in repo.get("topics", [])] + + if ( + search_term.lower() in name_desc + or search_term.lower() in lang + or search_term.lower() in topics + ): + desc = repo["description"] or "No description provided." + + found_urls = url_pattern.findall(desc) + if found_urls: + link = found_urls[0] + link_label = "Website" + else: + link = f"https://owasp.org/www-project-{repo['name'].lower()}" + link_label = "Wiki" + + matched.append( + { + "owner_repo": repo["full_name"], + "name": repo["name"], + "description": desc, + "link_label": link_label, + "link": link, + "html_url": repo["html_url"], + } + ) + + if not matched: + send_dm( + client, + command["user_id"], + f"No OWASP projects found matching '{search_term}'.", + ) + return + + pagination_data[command["user_id"]] = { + "matched": matched, + "current_page": 0, + "page_size": 8, + } + + send_paged_results(client, command["user_id"], search_term) + + else: + try: + client.conversations_join(channel=command["channel_id"]) + except Exception as channel_error: + logger.debug(f"Could not join channel: {channel_error}") + pass + + try: + gh_response = requests.get("https://api.github.com/orgs/OWASP-BLT/repos") + if gh_response.status_code == 200: + repos = gh_response.json() + if not repos: + send_dm( + client, command["user_id"], "No repositories found for OWASP-BLT." + ) + else: + repo_list = [] + for idx, repo in enumerate(repos, start=1): + desc = ( + repo["description"] + if repo["description"] + else "No description provided." + ) + repo_list.append( + f"{idx}. <{repo['html_url']}|{repo['name']}> - {desc}" + ) + + blocks = [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Here are the OWASP BLT project repositories:\n" + + "\n".join(repo_list), + }, + }, + { + "type": "actions", + "elements": [ + { + "type": "static_select", + "placeholder": { + "type": "plain_text", + "text": "Select a repository to view issues", + }, + "options": [ + { + "text": { + "type": "plain_text", + "text": f"{repo['name']}", + }, + "value": f"OWASP-BLT/{repo['name']}", + } + for repo in repos + ], + "action_id": "select_repository", + } + ], + }, + ] + + send_dm( + client, + command["user_id"], + "Please select a repository to view its latest issues:", + blocks, + ) + else: + send_dm( + client, + command["user_id"], + "Failed to fetch repositories from OWASP-BLT.", + ) + + except Exception as e: + logger.error(f"Error processing repositories: {e}") + send_dm( + client, + command["user_id"], + "An error occurred while processing your request.", + ) + + except Exception as e: + logger.error(f"Error handling /discover command: {e}") + + app.action("select_repository") + + def handle_repository_selection(ack, body, client): + try: + ack() + user_id = body["user"]["id"] + selected_repo = body["actions"][0]["selected_option"]["value"] + logger.debug(f"User {user_id} selected repository: {selected_repo}") + + # Fetch latest issues from the selected GitHub repository + issues_response = requests.get(f"https://api.github.com/repos/{selected_repo}/issues") + if issues_response.status_code == 200: + issues = issues_response.json() + issues = [issue for issue in issues if "pull_request" not in issue] + if not issues: + send_dm(client, user_id, "No issues found for this repository.") + else: + issues_list = [ + f"- <{issue['html_url']}|{issue['title']}> (#{issue['number']})" + for issue in issues[:5] + ] + issues_text = "Here are the latest issues:\n" + "\n".join(issues_list) + send_dm(client, user_id, issues_text) + + else: + send_dm(client, user_id, "Failed to fetch issues for the selected repository.") + + except Exception as e: + logger.error(f"Error handling repository selection: {e}") + + @app.action("pagination_prev") + def handle_pagination_prev(ack, body, client): + """Handles the 'Previous' pagination button.""" + try: + ack() + user_id = body["user"]["id"] + search_term = body.get("state", {}).get("values", {}).get("search_term", "Topic") + + if user_id not in pagination_data: + send_dm(client, user_id, "No pagination data found.") + return + + data = pagination_data[user_id] + data["current_page"] = max(0, data["current_page"] - 1) + + send_paged_results(client, user_id, search_term) + + except Exception as e: + logger.error(f"Error handling pagination action: {e}") + + @app.action("pagination_next") + def handle_pagination_next(ack, body, client): + """Handles the 'Next' pagination button""" + try: + ack() + user_id = body["user"]["id"] + search_term = body.get("state", {}).get("values", {}).get("search_term", "Topic") + + if user_id not in pagination_data: + send_dm(client, user_id, "No pagination data found.") + return + + data = pagination_data[user_id] + data["current_page"] += 1 + + total_pages = math.ceil(len(data["matched"]) / data["page_size"]) + + data["current_page"] = min(data["current_page"], total_pages - 1) + send_paged_results(client, user_id, search_term) + + except Exception as e: + logger.error(f"Error handling pagination action: {e}") + + def send_paged_results(client, user_id, search_term): + """Sends the current page of matched projects to the user with next/prev buttons if needed.""" + data = pagination_data[user_id] + matched = data["matched"] + page_size = data["page_size"] + total_pages = math.ceil(len(matched) / page_size) + current_page = data["current_page"] + + start_idx = current_page * page_size + end_idx = start_idx + page_size + chunk = matched[start_idx:end_idx] + + text_chunk = "\n".join( + [ + f"{idx + start_idx + 1}. <{project['html_url']}|{project['name']}> - {project['description']}\n {project['link_label']}: <{project['link']}|Link>" + for idx, project in enumerate(chunk) + ] + ) + + blocks = [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ( + f"Here are the OWASP Projects matching *{search_term}* " + f"(page {current_page + 1}/{total_pages}):\n{text_chunk}" + ), + }, + }, + {"type": "actions", "elements": []}, + ] + + # Add Prev button if not on the first page already + if current_page > 0: + blocks[1]["elements"].append( + { + "type": "button", + "text": {"type": "plain_text", "text": "Previous"}, + "value": "PREV", + "action_id": "pagination_prev", + } + ) + + # Next button if not on last page already + if current_page < (total_pages - 1): + blocks[1]["elements"].append( + { + "type": "button", + "text": {"type": "plain_text", "text": "Next"}, + "value": "NEXT", + "action_id": "pagination_next", + } + ) + + options = [ + { + "text": {"type": "plain_text", "text": project["name"]}, + "value": project["owner_repo"], + } + for project in chunk + ] + + # Also keep the static_select for issues + if options: + blocks[1]["elements"].append( + { + "type": "static_select", + "placeholder": { + "type": "plain_text", + "text": "Select a repository to view its latest issues", + }, + "options": options, + "action_id": "select_repository", + } + ) + send_dm(client, user_id, f"Found {len(matched)} matching OWASP projects.", blocks) + + def send_dm(client, user_id, text, blocks=None): + """Utility function to open a DM channel with user and send them a message.""" + try: + dm_response = client.conversations_open(users=[user_id]) + if not dm_response["ok"]: + logger.error(f"Failed to open DM channel: {dm_response['error']}") + return + + dm_channel_id = dm_response["channel"]["id"] + message_response = client.chat_postMessage( + channel=dm_channel_id, + text=text, + blocks=blocks, + mrkdwn=True, + unfurl_links=False, + unfurl_media=False, + ) + if not message_response["ok"]: + logger.error(f"Failed to send DM: {message_response.get('error', 'Unknown error')}") + return + + logger.debug(f"Successfully sent DM to user {user_id} in channel {dm_channel_id}") + + except Exception as e: + logger.error(f"Error sending DM to user {user_id}: {e}") + + +@csrf_exempt +def slack_commands(request): + logger.debug(f"Received Slack command with content type: {request.content_type}") + if not handler: + return JsonResponse({"error": "Slack integration is disabled."}, status=400) + if request.method == "POST": + if request.content_type != "application/x-www-form-urlencoded": + return JsonResponse({"error": "Invalid content type"}, status=415) + return HttpResponse(handler.handle(request)) + return JsonResponse({"error": "Method not allowed"}, status=405) From ca4368099930d11babb532b838784b4db2114fcd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jan 2025 00:22:36 +0000 Subject: [PATCH 03/13] chore(deps): Bump django-simple-captcha from 0.6.0 to 0.6.1 Bumps [django-simple-captcha](https://github.com/mbi/django-simple-captcha) from 0.6.0 to 0.6.1. - [Release notes](https://github.com/mbi/django-simple-captcha/releases) - [Changelog](https://github.com/mbi/django-simple-captcha/blob/master/CHANGES) - [Commits](https://github.com/mbi/django-simple-captcha/compare/v0.6.0...v0.6.1) --- updated-dependencies: - dependency-name: django-simple-captcha dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- poetry.lock | 9 +++++---- pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 87a16f4ca..0e5750448 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1220,18 +1220,19 @@ hiredis = ["redis[hiredis] (>=3,!=4.0.0,!=4.0.1)"] [[package]] name = "django-simple-captcha" -version = "0.6.0" +version = "0.6.1" description = "A very simple, yet powerful, Django captcha application" optional = false python-versions = "*" files = [ - {file = "django-simple-captcha-0.6.0.tar.gz", hash = "sha256:d188516d326fadd2d5ad076eb89649d55c02cabafe3fdcc2154ac18e9f6d4b97"}, - {file = "django_simple_captcha-0.6.0-py2.py3-none-any.whl", hash = "sha256:3ae9a7e650cb0cdbcfd4a75aa91fdf25dcc523ef541a7b1f004bd4357798fc03"}, + {file = "django_simple_captcha-0.6.1-py2.py3-none-any.whl", hash = "sha256:75c4a89cf54011be54dd96fc4bfc2286f92bed88c46d247b9e504b7fc4c7cfe5"}, + {file = "django_simple_captcha-0.6.1.tar.gz", hash = "sha256:9df5cf24e38b91af62d601f8e5189b0f6d28dbaa55a363a8b9d727703f9c35de"}, ] [package.dependencies] Django = ">=4.2" django-ranged-response = "0.2.0" +djangorestframework = ">=3.15.0" Pillow = ">=6.2.0" [package.extras] @@ -5020,4 +5021,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "3.11.2" -content-hash = "780e52e5dabecef51e218b0095e719e55efd253cd2da92ce4c64c9412a66c18e" +content-hash = "2e10bc9deac62aa184f495199d5e750dfa93a00cfeed1592cbbc72154ee71306" diff --git a/pyproject.toml b/pyproject.toml index 085a622ff..4f1b2b803 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,7 @@ stripe = "^8.4.0" django-environ = "^0.12.0" django-humanize = "^0.1.2" drf-yasg = "^1.21.8" -django-simple-captcha = "^0.6.0" +django-simple-captcha = "^0.6.1" django-filter = "^24.3" webdriver-manager = "^4.0.2" pillow = "^10.4.0" From 3932b23682ebba304e51756948db17467b3fcbf3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jan 2025 00:45:35 +0000 Subject: [PATCH 04/13] chore(deps): Bump unstructured from 0.16.13 to 0.16.14 Bumps [unstructured](https://github.com/Unstructured-IO/unstructured) from 0.16.13 to 0.16.14. - [Release notes](https://github.com/Unstructured-IO/unstructured/releases) - [Changelog](https://github.com/Unstructured-IO/unstructured/blob/main/CHANGELOG.md) - [Commits](https://github.com/Unstructured-IO/unstructured/compare/0.16.13...0.16.14) --- updated-dependencies: - dependency-name: unstructured dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 0e5750448..a67d8e2f5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4509,13 +4509,13 @@ files = [ [[package]] name = "unstructured" -version = "0.16.13" +version = "0.16.14" description = "A library that prepares raw documents for downstream ML tasks." optional = false python-versions = "<3.13,>=3.9.0" files = [ - {file = "unstructured-0.16.13-py3-none-any.whl", hash = "sha256:d578d3ebd78c6bf3ea837a13b7e2942671920f9e7361e8532c5eb00f9cf359e6"}, - {file = "unstructured-0.16.13.tar.gz", hash = "sha256:6195744a203e65bf6b8460cbfccd9bef67a1f5d44e79229a13e7e37f528abbcd"}, + {file = "unstructured-0.16.14-py3-none-any.whl", hash = "sha256:7b3c2eb21e65d2f61240de7a5241fd7734d97be2c9cfa5f70934e10470318131"}, + {file = "unstructured-0.16.14.tar.gz", hash = "sha256:cec819461090226cd478429c1e0fda19a66ba49ab9ade1ea1fd9ec79c279d7ac"}, ] [package.dependencies] @@ -5021,4 +5021,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "3.11.2" -content-hash = "2e10bc9deac62aa184f495199d5e750dfa93a00cfeed1592cbbc72154ee71306" +content-hash = "725d9cc170028170f65cbcddc8b14056aa3908bf6eba0560112bdcc66e022c85" diff --git a/pyproject.toml b/pyproject.toml index 4f1b2b803..4d7be395b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,7 @@ sentry-sdk = "^2.20.0" bitcash = "^1.0.2" pydantic = "^2.10.5" pydantic_core = "^2.18.4" -unstructured = "^0.16.13" +unstructured = "^0.16.14" Markdown = "^3.6" faiss-cpu = "^1.8.0" psutil = "^5.9.8" From 492aa7a74fceeeba620e9462a1235bb5a901ed0b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jan 2025 00:54:44 +0000 Subject: [PATCH 05/13] chore(deps): Bump selenium from 4.27.1 to 4.28.0 Bumps [selenium](https://github.com/SeleniumHQ/Selenium) from 4.27.1 to 4.28.0. - [Release notes](https://github.com/SeleniumHQ/Selenium/releases) - [Commits](https://github.com/SeleniumHQ/Selenium/commits/selenium-4.28.0) --- updated-dependencies: - dependency-name: selenium dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- poetry.lock | 10 +++++----- pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/poetry.lock b/poetry.lock index a67d8e2f5..5badaf308 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4111,13 +4111,13 @@ wrapt = ">=1.10,<2.0" [[package]] name = "selenium" -version = "4.27.1" +version = "4.28.0" description = "Official Python bindings for Selenium WebDriver" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "selenium-4.27.1-py3-none-any.whl", hash = "sha256:b89b1f62b5cfe8025868556fe82360d6b649d464f75d2655cb966c8f8447ea18"}, - {file = "selenium-4.27.1.tar.gz", hash = "sha256:5296c425a75ff1b44d0d5199042b36a6d1ef76c04fb775b97b40be739a9caae2"}, + {file = "selenium-4.28.0-py3-none-any.whl", hash = "sha256:3d6a2e8e1b850a1078884ea19f4e011ecdc12263434d87a0b78769836fb82dd8"}, + {file = "selenium-4.28.0.tar.gz", hash = "sha256:a9fae6eef48d470a1b0c6e45185d96f0dafb025e8da4b346cc41e4da3ac54fa0"}, ] [package.dependencies] @@ -5021,4 +5021,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "3.11.2" -content-hash = "725d9cc170028170f65cbcddc8b14056aa3908bf6eba0560112bdcc66e022c85" +content-hash = "7dcea59c7a5133a3d027748f6fd77ef6d250dbf6ba09ff82535ce7606d2b25f7" diff --git a/pyproject.toml b/pyproject.toml index 4d7be395b..e6b12dbc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ Unidecode = "^1.3.8" user-agents = "^2.2.0" whitenoise = "^6.8.2" django-debug-toolbar = "^4.4.6" -selenium = "^4.27.1" +selenium = "^4.28.0" pylibmc = "^1.6.1" psycopg2-binary = "^2.9.10" boto = "^2.49.0" From e82b168b2434d511d6dfc5e03449a158b109e471 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Jan 2025 00:51:32 +0000 Subject: [PATCH 06/13] chore(deps): Bump tablib from 3.7.0 to 3.8.0 Bumps [tablib](https://github.com/jazzband/tablib) from 3.7.0 to 3.8.0. - [Release notes](https://github.com/jazzband/tablib/releases) - [Changelog](https://github.com/jazzband/tablib/blob/master/HISTORY.md) - [Commits](https://github.com/jazzband/tablib/compare/v3.7.0...v3.8.0) --- updated-dependencies: - dependency-name: tablib dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 5badaf308..0c5da61b2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4323,13 +4323,13 @@ files = [ [[package]] name = "tablib" -version = "3.7.0" +version = "3.8.0" description = "Format agnostic tabular data library (XLS, JSON, YAML, CSV, etc.)" optional = false python-versions = ">=3.9" files = [ - {file = "tablib-3.7.0-py3-none-any.whl", hash = "sha256:9a6930037cfe0f782377963ca3f2b1dae3fd4cdbf0883848f22f1447e7bb718b"}, - {file = "tablib-3.7.0.tar.gz", hash = "sha256:f9db84ed398df5109bd69c11d46613d16cc572fb9ad3213f10d95e2b5f12c18e"}, + {file = "tablib-3.8.0-py3-none-any.whl", hash = "sha256:35bdb9d4ec7052232f8803908f9c7a9c3c65807188b70618fa7a7d8ccd560b4d"}, + {file = "tablib-3.8.0.tar.gz", hash = "sha256:94d8bcdc65a715a0024a6d5b701a5f31e45bd159269e62c73731de79f048db2b"}, ] [package.extras] @@ -5021,4 +5021,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "3.11.2" -content-hash = "7dcea59c7a5133a3d027748f6fd77ef6d250dbf6ba09ff82535ce7606d2b25f7" +content-hash = "6a485f0828d702e51ebfe96f113838203009e972b36edf24b4aef7264d036d02" diff --git a/pyproject.toml b/pyproject.toml index e6b12dbc5..3a4dd018d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ pytz = "^2024.1" requests = "^2.32.3" requests-oauthlib = "^1.3.1" six = "^1.16.0" -tablib = "^3.2.0" +tablib = "^3.8.0" ua-parser = "^1.0.0" djangorestframework = "^3.15.2" cffi = "^1.17.1" From ea90582dcccef377f2606f7ad500b60f5e6b748f Mon Sep 17 00:00:00 2001 From: Altafur Rahman Date: Fri, 24 Jan 2025 01:32:19 +0600 Subject: [PATCH 07/13] Enhance Slack integration and configuration (#3256) * Enhance Slack integration and configuration * Add welcome message field to SlackIntegration model and update related views and tests --- .env.example | 8 +- blt/urls.py | 3 +- .../0181_slackintegration_welcome_message.py | 21 + website/models.py | 6 + .../organization/add_slack_integration.html | 47 +++ website/test_slack.py | 175 ++++---- website/views/company.py | 21 +- website/views/slack_handlers.py | 380 ++++++++++++------ 8 files changed, 457 insertions(+), 204 deletions(-) create mode 100644 website/migrations/0181_slackintegration_welcome_message.py diff --git a/.env.example b/.env.example index 4302469c6..2867dfebe 100644 --- a/.env.example +++ b/.env.example @@ -27,10 +27,10 @@ DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:${POSTGR #Sentry DSN SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0 -SLACK_CLIENT_ID= -SLACK_CLIENT_SECRET= -SLACK_BOT_TOKEN= -SLACK_SIGNING_SECRET= +SLACK_ID_CLIENT=your_slack_client_id_here +SLACK_SECRET_CLIENT=your_slack_client_secret_here +SLACK_BOT_TOKEN=your_slack_bot_token_here +SLACK_SIGNING_SECRET=your_slack_signing_secret_here #BlueSky User Details BLUESKY_USERNAME=example.bsky.social diff --git a/blt/urls.py b/blt/urls.py index 286b4e691..4ede4e748 100644 --- a/blt/urls.py +++ b/blt/urls.py @@ -186,8 +186,7 @@ distribute_bacon, select_contribution, ) -from website.views.slack_handlers import slack_events -from website.views.slackbot import slack_commands +from website.views.slack_handlers import slack_commands, slack_events from website.views.teams import ( TeamOverview, add_member, diff --git a/website/migrations/0181_slackintegration_welcome_message.py b/website/migrations/0181_slackintegration_welcome_message.py new file mode 100644 index 000000000..a75140843 --- /dev/null +++ b/website/migrations/0181_slackintegration_welcome_message.py @@ -0,0 +1,21 @@ +# Generated by Django 5.1.3 on 2025-01-23 18:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("website", "0180_rename_project_visit_count_repo_repo_visit_count"), + ] + + operations = [ + migrations.AddField( + model_name="slackintegration", + name="welcome_message", + field=models.TextField( + blank=True, + help_text="Custom welcome message for new members. Use Slack markdown formatting.", + null=True, + ), + ), + ] diff --git a/website/models.py b/website/models.py index 9172cabc6..fe328d9e7 100644 --- a/website/models.py +++ b/website/models.py @@ -106,6 +106,12 @@ class SlackIntegration(models.Model): validators=[MinValueValidator(0), MaxValueValidator(23)], # Valid hours: 0–23 help_text="The hour of the day (0-23) to send daily updates", ) + # Add welcome message field + welcome_message = models.TextField( + null=True, + blank=True, + help_text="Custom welcome message for new members. Use Slack markdown formatting.", + ) def __str__(self): return f"Slack Integration for {self.integration.organization.name}" diff --git a/website/templates/organization/add_slack_integration.html b/website/templates/organization/add_slack_integration.html index 76dbc247e..aa93549ff 100644 --- a/website/templates/organization/add_slack_integration.html +++ b/website/templates/organization/add_slack_integration.html @@ -90,6 +90,23 @@

Configure Slack Bot:

+
+
+ +
+ +
+

+ You can use Slack's markdown formatting: + *bold*, _italic_, ~strikethrough~, `code`, and >quote +

+
+
{% endblock body %} +{% block extra_head %} + + +{% endblock %} +{% block extra_js %} + +{% endblock %} diff --git a/website/test_slack.py b/website/test_slack.py index 5b4dfe8c1..d414589a8 100644 --- a/website/test_slack.py +++ b/website/test_slack.py @@ -1,89 +1,128 @@ +import json from unittest.mock import MagicMock, patch from django.test import TestCase -from website.views.slack_handlers import ( - _handle_contribute_message, - _handle_team_join, - extract_text_from_blocks, - handle_message, -) +from website.models import Integration, Organization, SlackIntegration +from website.views.slack_handlers import slack_commands, slack_events -class SlackFunctionTests(TestCase): +class SlackHandlerTests(TestCase): def setUp(self): - self.mock_client = MagicMock() - - def test_extract_text_from_blocks(self): - """Test extracting text from Slack block format""" - # Test rich text blocks - blocks = [ - { - "type": "rich_text", - "elements": [ - { - "type": "rich_text_section", - "elements": [{"type": "text", "text": "I want to contribute"}], - } - ], - } - ] - - self.assertEqual(extract_text_from_blocks(blocks), "I want to contribute") - - # Test empty blocks - self.assertEqual(extract_text_from_blocks([]), "") - - # Test invalid blocks - self.assertEqual(extract_text_from_blocks(None), "") - - @patch("website.views.slack_handlers.client") - def test_handle_contribute_message(self, mock_client): - """Test contribute message handler""" - message = { - "user": "U123", - "channel": "C123", - "text": "How do I contribute?", - "subtype": None, + # Create test organization and integration + self.organization = Organization.objects.create(name="Test Org", url="https://test.org") + self.integration = Integration.objects.create( + organization=self.organization, service_name="slack" + ) + self.slack_integration = SlackIntegration.objects.create( + integration=self.integration, + bot_access_token="xoxb-test-token", + workspace_name="T070JPE5BQQ", # Test workspace ID + welcome_message="Welcome {user} to our workspace!", + ) + + @patch("website.views.slack_handlers.verify_slack_signature", return_value=True) + @patch("website.views.slack_handlers.WebClient") + def test_team_join_with_custom_message(self, mock_webclient, mock_verify): + # Mock the Slack client + mock_client = MagicMock() + mock_webclient.return_value = mock_client + mock_client.conversations_open.return_value = {"ok": True, "channel": {"id": "D123"}} + mock_client.chat_postMessage.return_value = {"ok": True} + + # Create test event data + event_data = { + "token": "test-token", + "team_id": "T070JPE5BQQ", + "event": {"type": "team_join", "user": {"id": "U123"}}, + "type": "event_callback", } - _handle_contribute_message(message) + # Create test request + request = MagicMock() + request.body = json.dumps(event_data).encode() + request.method = "POST" + request.headers = { + "X-Slack-Request-Timestamp": "1234567890", + "X-Slack-Signature": "v0=test", + } - mock_client.chat_postMessage.assert_called_once() + # Call the event handler + response = slack_events(request) - # Test message without contribute keyword - message["text"] = "Hello world" - mock_client.chat_postMessage.reset_mock() - _handle_contribute_message(message) - mock_client.chat_postMessage.assert_not_called() + # Verify DM was opened + mock_client.conversations_open.assert_called_once_with(users=["U123"]) - @patch("website.views.slack_handlers.client") - def test_handle_team_join(self, mock_client): - """Test team join handler""" + # Verify welcome message was sent with custom message + mock_client.chat_postMessage.assert_called_once() + call_args = mock_client.chat_postMessage.call_args[1] + self.assertEqual(call_args["text"], "Welcome {user} to our workspace!") + + @patch("website.views.slack_handlers.verify_slack_signature", return_value=True) + @patch("website.views.slack_handlers.WebClient") + def test_team_join_owasp_workspace(self, mock_webclient, mock_verify): + # Mock the Slack client + mock_client = MagicMock() + mock_webclient.return_value = mock_client mock_client.conversations_open.return_value = {"ok": True, "channel": {"id": "D123"}} + mock_client.chat_postMessage.return_value = {"ok": True} + + # Create test event data for OWASP workspace + event_data = { + "token": "test-token", + "team_id": "T04T40NHX", # OWASP workspace ID + "event": {"type": "team_join", "user": {"id": "U123"}}, + "type": "event_callback", + } - _handle_team_join("U123") + # Create test request + request = MagicMock() + request.body = json.dumps(event_data).encode() + request.method = "POST" + request.headers = { + "X-Slack-Request-Timestamp": "1234567890", + "X-Slack-Signature": "v0=test", + } - # Should send welcome message in joins channel - mock_client.chat_postMessage.assert_called() + # Call the event handler + response = slack_events(request) - # Should try to open DM + # Verify DM was opened mock_client.conversations_open.assert_called_once_with(users=["U123"]) - @patch("website.views.slack_handlers.client") - def test_handle_message(self, mock_client): - """Test main message handler""" - # Mock bot user ID - mock_client.auth_test.return_value = {"user_id": "BOT123"} + # Verify default OWASP welcome message was sent + mock_client.chat_postMessage.assert_called_once() + call_args = mock_client.chat_postMessage.call_args[1] + self.assertIn("Welcome to the OWASP Slack Community", call_args["text"]) + + @patch("website.views.slack_handlers.verify_slack_signature", return_value=True) + @patch("website.views.slack_handlers.WebClient") + def test_slack_command_contrib(self, mock_webclient, mock_verify): + # Mock the Slack client + mock_client = MagicMock() + mock_webclient.return_value = mock_client + mock_client.conversations_open.return_value = {"ok": True, "channel": {"id": "D123"}} + mock_client.chat_postMessage.return_value = {"ok": True} + + # Create test request + request = MagicMock() + request.method = "POST" + request.POST = { + "command": "/contrib", + "user_id": "U123", + "team_id": "T070JPE5BQQ", + "team_domain": "test", + } + request.headers = { + "X-Slack-Request-Timestamp": "1234567890", + "X-Slack-Signature": "v0=test", + } - # Test normal user message - payload = {"user": "U123", "text": "contribute", "channel": "C123"} + response = slack_commands(request) - handle_message(payload) - mock_client.chat_postMessage.assert_called() + # Verify DM was opened + mock_client.conversations_open.assert_called_once_with(users=["U123"]) - # Test bot message (should be ignored) - payload["user"] = "BOT123" - mock_client.chat_postMessage.reset_mock() - handle_message(payload) - mock_client.chat_postMessage.assert_not_called() + # Verify contribute message was sent + mock_client.chat_postMessage.assert_called_once() + self.assertEqual(response.status_code, 200) diff --git a/website/views/company.py b/website/views/company.py index 4d94868e7..01efe51dc 100644 --- a/website/views/company.py +++ b/website/views/company.py @@ -870,12 +870,13 @@ def get(self, request, id, *args, **kwargs): "slack_integration": slack_integration, "channels": channels_list, "hours": hours, + "welcome_message": slack_integration.welcome_message, }, ) # Redirect to Slack OAuth flow if no integration exists - client_id = os.getenv("SLACK_CLIENT_ID") - scopes = "channels:read,chat:write,groups:read,channels:join" + client_id = os.getenv("SLACK_ID_CLIENT") + scopes = "channels:read,chat:write,groups:read,channels:join,im:write,users:read,team:read,commands" host = request.get_host() scheme = request.META.get("HTTP_X_FORWARDED_PROTO", request.scheme) redirect_uri = f"{scheme}://{host}/oauth/slack/callback" @@ -921,6 +922,7 @@ def post(self, request, id, *args, **kwargs): "default_channel": request.POST.get("target_channel"), "daily_sizzle_timelogs_status": request.POST.get("daily_sizzle_timelogs_status"), "daily_sizzle_timelogs_hour": request.POST.get("daily_sizzle_timelogs_hour"), + "welcome_message": request.POST.get("welcome_message"), # Add this } slack_integration = ( SlackIntegration.objects.filter( @@ -940,6 +942,8 @@ def post(self, request, id, *args, **kwargs): slack_integration.default_channel_name = slack_data["default_channel"] slack_integration.daily_updates = bool(slack_data["daily_sizzle_timelogs_status"]) slack_integration.daily_update_time = slack_data["daily_sizzle_timelogs_hour"] + # Add welcome message + slack_integration.welcome_message = slack_data["welcome_message"] slack_integration.save() return redirect("organization_manage_integrations", id=id) @@ -995,8 +999,8 @@ def get(self, request, *args, **kwargs): organization_id = int(organization_id) # Safely cast to int after validation - # Exchange code for token - access_token = self.exchange_code_for_token(code, request) + # Exchange code for token and get team info + token_data = self.exchange_code_for_token(code, request) integration = Integration.objects.create( organization_id=organization_id, @@ -1004,7 +1008,8 @@ def get(self, request, *args, **kwargs): ) SlackIntegration.objects.create( integration=integration, - bot_access_token=access_token, + bot_access_token=token_data["access_token"], + workspace_name=token_data["team"]["id"], ) dashboard_url = reverse("organization_manage_integrations", args=[organization_id]) @@ -1016,8 +1021,8 @@ def get(self, request, *args, **kwargs): def exchange_code_for_token(self, code, request): """Exchanges OAuth code for Slack access token.""" - client_id = os.getenv("SLACK_CLIENT_ID") - client_secret = os.getenv("SLACK_CLIENT_SECRET") + client_id = os.getenv("SLACK_ID_CLIENT") + client_secret = os.getenv("SLACK_SECRET_CLIENT") host = request.get_host() scheme = request.META.get("HTTP_X_FORWARDED_PROTO", request.scheme) redirect_uri = os.environ.get( @@ -1036,7 +1041,7 @@ def exchange_code_for_token(self, code, request): token_data = response.json() if token_data.get("ok"): - return token_data["access_token"] + return token_data # Return the full token data instead of just the access token else: raise Exception(f"Error exchanging code for token: {token_data.get('error')}") diff --git a/website/views/slack_handlers.py b/website/views/slack_handlers.py index 70f125e59..7d2f8e62c 100644 --- a/website/views/slack_handlers.py +++ b/website/views/slack_handlers.py @@ -6,18 +6,17 @@ from django.http import HttpResponse, JsonResponse from django.views.decorators.csrf import csrf_exempt -from dotenv import load_dotenv from slack import WebClient from slack_sdk.errors import SlackApiError -load_dotenv() +from website.models import SlackIntegration -DEPLOYS_CHANNEL_NAME = "#project-blt-lettuce-deploys" -JOINS_CHANNEL_ID = "C076DAG65AT" -CONTRIBUTE_ID = "C077QBBLY1Z" +if os.getenv("ENV") != "production": + from dotenv import load_dotenv -SLACK_TOKEN = os.getenv("SLACK_TOKEN") -SIGNING_SECRET = os.getenv("SIGNING_SECRET") + load_dotenv() +SLACK_TOKEN = os.environ.get("SLACK_BOT_TOKEN") +SIGNING_SECRET = os.environ.get("SLACK_SIGNING_SECRET") client = WebClient(token=SLACK_TOKEN) @@ -25,17 +24,34 @@ def verify_slack_signature(request): timestamp = request.headers.get("X-Slack-Request-Timestamp", "") signature = request.headers.get("X-Slack-Signature", "") - # Verify timestamp to prevent replay attacks - if abs(time.time() - float(timestamp)) > 60 * 5: + # Check if required headers are present + if not timestamp or not signature: return False - sig_basestring = f"v0:{timestamp}:{request.body.decode()}" - my_signature = ( - "v0=" - + hmac.new(SIGNING_SECRET.encode(), sig_basestring.encode(), hashlib.sha256).hexdigest() - ) + try: + # Verify timestamp to prevent replay attacks + current_time = time.time() + request_time = float(timestamp) + time_diff = abs(current_time - request_time) + + if time_diff > 60 * 5: + return False + + # Create the signature base string + sig_basestring = f"v0:{timestamp}:{request.body.decode()}" + + # Calculate our signature + my_signature = ( + "v0=" + + hmac.new(SIGNING_SECRET.encode(), sig_basestring.encode(), hashlib.sha256).hexdigest() + ) - return hmac.compare_digest(my_signature, signature) + # Compare signatures + is_valid = hmac.compare_digest(my_signature, signature) + return is_valid + + except (ValueError, TypeError) as e: + return False @csrf_exempt @@ -48,6 +64,11 @@ def slack_events(request): data = json.loads(request.body) + # Check if this is a retry event + is_retry = request.headers.get("X-Slack-Retry-Num") + if is_retry: + return HttpResponse(status=200) + if "challenge" in data: return JsonResponse({"challenge": data["challenge"]}) @@ -62,131 +83,246 @@ def slack_events(request): user_id = event.get("user") if user_id: - _handle_team_join(user_id) - - elif event_type == "message": - handle_message(event) + _handle_team_join(user_id, request) return HttpResponse(status=200) return HttpResponse(status=405) -def extract_text_from_blocks(blocks): - """Extracts message text from Slack's 'blocks' format""" - if not blocks: - return "" - - text_parts = [] - for block in blocks: - if block.get("type") == "rich_text": - for element in block.get("elements", []): - if element.get("type") == "rich_text_section": - for item in element.get("elements", []): - if item.get("type") == "text": - text_parts.append(item.get("text", "")) +def _handle_team_join(user_id, request): + try: + event_data = json.loads(request.body) + team_id = event_data["team_id"] + try: + slack_integration = SlackIntegration.objects.get(workspace_name=team_id) - return " ".join(text_parts).strip() + # If integration exists and has welcome message + if slack_integration.welcome_message: + welcome_message = slack_integration.welcome_message + workspace_client = WebClient(token=slack_integration.bot_access_token) + else: + # If no welcome message but it's OWASP workspace + if team_id == "T04T40NHX": + workspace_client = WebClient(token=SLACK_TOKEN) + welcome_message = ( + f":tada: *Welcome to the OWASP Slack Community, <@{user_id}>!* :tada:\n\n" + "We're thrilled to have you here! Whether you're new to OWASP or a long-time contributor, " + "this Slack workspace is the perfect place to connect, collaborate, and stay informed about all things OWASP.\n\n" + ":small_blue_diamond: *Get Involved:*\n" + "• Check out the *#contribute* channel to find ways to get involved with OWASP projects and initiatives.\n" + "• Explore individual project channels, which are named *#project-name*, to dive into specific projects that interest you.\n" + "• Join our chapter channels, named *#chapter-name*, to connect with local OWASP members in your area.\n\n" + ":small_blue_diamond: *Stay Updated:*\n" + "• Visit *#newsroom* for the latest updates and announcements.\n" + "• Follow *#external-activities* for news about OWASP's engagement with the wider security community.\n\n" + ":small_blue_diamond: *Connect and Learn:*\n" + "• *#jobs*: Looking for new opportunities? Check out the latest job postings here.\n" + "• *#leaders*: Connect with OWASP leaders and stay informed about leadership activities.\n" + "• *#project-committee*: Engage with the committee overseeing OWASP projects.\n" + "• *#gsoc*: Stay updated on Google Summer of Code initiatives.\n" + "• *#github-admins*: Get support and discuss issues related to OWASP's GitHub repositories.\n" + "• *#learning*: Share and find resources to expand your knowledge in the field of application security.\n\n" + "We're excited to see the amazing contributions you'll make. If you have any questions or need assistance, don't hesitate to ask. " + "Let's work together to make software security visible and improve the security of the software we all rely on.\n\n" + "Welcome aboard! :rocket:" + ) + else: + workspace_client = WebClient(token=slack_integration.bot_access_token) + welcome_message = ( + f"Welcome <@{user_id}>! 👋\n\n" + "Your workspace admin hasn't set up a custom welcome message yet. " + "They can configure this in the organization's integration settings." + ) + + except SlackIntegration.DoesNotExist: + # If no integration exists but it's OWASP workspace + if team_id == "T04T40NHX": + workspace_client = WebClient(token=SLACK_TOKEN) + # Use the default OWASP welcome message + welcome_message = ( + f":tada: *Welcome to the OWASP Slack Community, <@{user_id}>!* :tada:\n\n" + "We're thrilled to have you here! Whether you're new to OWASP or a long-time contributor, " + "this Slack workspace is the perfect place to connect, collaborate, and stay informed about all things OWASP.\n\n" + ":small_blue_diamond: *Get Involved:*\n" + "• Check out the *#contribute* channel to find ways to get involved with OWASP projects and initiatives.\n" + "• Explore individual project channels, which are named *#project-name*, to dive into specific projects that interest you.\n" + "• Join our chapter channels, named *#chapter-name*, to connect with local OWASP members in your area.\n\n" + ":small_blue_diamond: *Stay Updated:*\n" + "• Visit *#newsroom* for the latest updates and announcements.\n" + "• Follow *#external-activities* for news about OWASP's engagement with the wider security community.\n\n" + ":small_blue_diamond: *Connect and Learn:*\n" + "• *#jobs*: Looking for new opportunities? Check out the latest job postings here.\n" + "• *#leaders*: Connect with OWASP leaders and stay informed about leadership activities.\n" + "• *#project-committee*: Engage with the committee overseeing OWASP projects.\n" + "• *#gsoc*: Stay updated on Google Summer of Code initiatives.\n" + "• *#github-admins*: Get support and discuss issues related to OWASP's GitHub repositories.\n" + "• *#learning*: Share and find resources to expand your knowledge in the field of application security.\n\n" + "We're excited to see the amazing contributions you'll make. If you have any questions or need assistance, don't hesitate to ask. " + "Let's work together to make software security visible and improve the security of the software we all rely on.\n\n" + "Welcome aboard! :rocket:" + ) + else: + return + # Add delay to ensure user is fully joined + time.sleep(2) # Wait 2 seconds before sending message -def _handle_contribute_message(message): - text = message.get("text", "").lower() - user = message.get("user") - channel = message.get("channel") + # Try to open DM first + try: + dm_response = workspace_client.conversations_open(users=[user_id]) + if not dm_response["ok"]: + return - if message.get("subtype") is None and any( - keyword in text for keyword in ["contribute", "contributing", "contributes"] - ): - response = client.chat_postMessage( - channel=channel, - text=f"Hello <@{user}>! Please check <#{CONTRIBUTE_ID}> for contributing guidelines today!", - ) + dm_channel = dm_response["channel"]["id"] + welcome_blocks = [ + {"type": "section", "text": {"type": "mrkdwn", "text": welcome_message}} + ] -def _handle_team_join(user_id): - # Send message to joins channel - join_response = client.chat_postMessage( - channel=JOINS_CHANNEL_ID, text=f"Welcome <@{user_id}> to the team! 🎉" - ) + # Send message using appropriate client + welcome_response = workspace_client.chat_postMessage( + channel=dm_channel, text=welcome_message, blocks=welcome_blocks + ) - try: - # Try to open DM first - dm_response = client.conversations_open(users=[user_id]) - if not dm_response["ok"]: + except SlackApiError as e: return - dm_channel = dm_response["channel"]["id"] - - # Define welcome message - welcome_message = ( - f":tada: *Welcome to the OWASP Slack Community, <@{user_id}>!* :tada:\n\n" - "We're thrilled to have you here! Whether you're new to OWASP or a long-time contributor, " - "this Slack workspace is the perfect place to connect, collaborate, and stay informed about all things OWASP.\n\n" - ":small_blue_diamond: *Get Involved:*\n" - "• Check out the *#contribute* channel to find ways to get involved with OWASP projects and initiatives.\n" - "• Explore individual project channels, which are named *#project-name*, to dive into specific projects that interest you.\n" - "• Join our chapter channels, named *#chapter-name*, to connect with local OWASP members in your area.\n\n" - ":small_blue_diamond: *Stay Updated:*\n" - "• Visit *#newsroom* for the latest updates and announcements.\n" - "• Follow *#external-activities* for news about OWASP's engagement with the wider security community.\n\n" - ":small_blue_diamond: *Connect and Learn:*\n" - "• *#jobs*: Looking for new opportunities? Check out the latest job postings here.\n" - "• *#leaders*: Connect with OWASP leaders and stay informed about leadership activities.\n" - "• *#project-committee*: Engage with the committee overseeing OWASP projects.\n" - "• *#gsoc*: Stay updated on Google Summer of Code initiatives.\n" - "• *#github-admins*: Get support and discuss issues related to OWASP's GitHub repositories.\n" - "• *#learning*: Share and find resources to expand your knowledge in the field of application security.\n\n" - "We're excited to see the amazing contributions you'll make. If you have any questions or need assistance, don't hesitate to ask. " - "Let's work together to make software security visible and improve the security of the software we all rely on.\n\n" - "Welcome aboard! :rocket:" - ) - - welcome_blocks = [{"type": "section", "text": {"type": "mrkdwn", "text": welcome_message}}] - - welcome_response = client.chat_postMessage( - channel=dm_channel, text=welcome_message, blocks=welcome_blocks - ) - except SlackApiError as e: - return HttpResponse(status=500) - - -def handle_message(payload): - # Get bot user ID - response = client.auth_test() - bot_user_id = response["user_id"] - - # Skip if message is from the bot - if payload.get("user") == bot_user_id: return - # Get message content from both text and blocks - text = payload.get("text", "") - blocks_text = extract_text_from_blocks(payload.get("blocks", [])) - # Use text from blocks if direct text is empty - message_text = text or blocks_text - - # Create message object with the extracted text - message = { - "user": payload.get("user"), - "channel": payload.get("channel"), - "text": message_text, - "subtype": payload.get("subtype"), - "channel_type": payload.get("channel_type"), - } - - _handle_contribute_message(message) - _handle_direct_message(message, bot_user_id) +@csrf_exempt +def slack_commands(request): + """Handle Slack slash commands""" + if request.method == "POST": + # Verify the request is from Slack + is_valid = verify_slack_signature(request) + if not is_valid: + return HttpResponse(status=403) -def _handle_direct_message(message, bot_user_id): - if message.get("channel_type") == "im": - user = message["user"] - text = message.get("text", "") + command = request.POST.get("command") + user_id = request.POST.get("user_id") + team_id = request.POST.get("team_id") + team_domain = request.POST.get("team_domain") # Get the team domain + + if command == "/contrib": + try: + # First try to get custom integration + try: + slack_integration = SlackIntegration.objects.get(workspace_name=team_id) + workspace_client = WebClient(token=slack_integration.bot_access_token) + except SlackIntegration.DoesNotExist: + # If no custom integration and it's OWASP workspace, use default token + if team_domain == "owasp": + workspace_client = WebClient(token=SLACK_TOKEN) + else: + return JsonResponse( + { + "response_type": "ephemeral", + "text": "This workspace is not properly configured. Please contact the workspace admin.", + } + ) + + contribute_message = [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":rocket: *Contributing to OWASP Projects*\n\n" + " 🔹 *Join the OWASP Slack Channel:* Find guidance and check pinned posts for projects seeking contributors.\n" + " 🔹 *Explore OWASP Projects Page:* Identify projects that align with your skills and interests.", + }, + }, + {"type": "divider"}, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":loudspeaker: *Engaging on Slack*\n\n" + " Many projects have dedicated project channels for collaboration.\n\n" + " 🔍 *Find and Join a Project Channel:*", + }, + }, + { + "type": "actions", + "elements": [ + { + "type": "channels_select", + "placeholder": { + "type": "plain_text", + "text": "🔍 Search and Select a Project Channel", + "emoji": True, + }, + "action_id": "select_project_channel", + } + ], + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": " 🛠 *GSOC Projects:* View this year's participating GSOC projects .", + }, + }, + {"type": "divider"}, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":busts_in_silhouette: *Identifying Key People and Activity*\n\n" + " • Visit the *OWASP Projects* page to find project leaders and contributors.\n" + " • Review *GitHub commit history* for active developers.\n" + " • Check *Slack activity* for updates on project progress.", + }, + }, + {"type": "divider"}, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":pushpin: *Communication Guidelines*\n\n" + " ✅ *Check pinned messages* in project channels for updates.\n" + " ✅ *Ask questions* in relevant project channels.\n" + " ✅ *Introduce yourself* while keeping personal details private.", + }, + }, + {"type": "divider"}, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":hammer_and_wrench: *How to Contribute*\n\n" + " 1️⃣ *Select a project* and review its contribution guidelines.\n" + " 2️⃣ *Work on an open GitHub issue* or propose a new one.\n" + " 3️⃣ *Coordinate with project leaders* to prevent overlaps.\n" + " 4️⃣ *Submit a pull request* and keep the team informed.\n\n" + " 💡 *Focus on clear communication and teamwork!* 🚀", + }, + }, + ] + + # Open DM channel first + dm_response = workspace_client.conversations_open(users=[user_id]) + if not dm_response["ok"]: + return HttpResponse(status=500) + + dm_channel = dm_response["channel"]["id"] + + # Send message to DM channel + message_response = workspace_client.chat_postMessage( + channel=dm_channel, blocks=contribute_message, mrkdwn=True + ) + + # Send ephemeral message in the channel where command was used + return JsonResponse( + { + "response_type": "ephemeral", + "text": "I've sent you a DM with information about contributing! 🚀", + } + ) + + except SlackApiError as e: + return HttpResponse(status=500) - try: - if message.get("user") != bot_user_id: - client.chat_postMessage(channel=JOINS_CHANNEL_ID, text=f"<@{user}> said {text}") - client.chat_postMessage(channel=user, text=f"Hello <@{user}>, you said: {text}") - except SlackApiError as e: - return HttpResponse(status=500) + return HttpResponse(status=405) From b7985791553994559fb6bd931873993475058b3b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Jan 2025 00:44:23 +0000 Subject: [PATCH 08/13] chore(deps-dev): Bump ruff from 0.9.2 to 0.9.3 Bumps [ruff](https://github.com/astral-sh/ruff) from 0.9.2 to 0.9.3. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.9.2...0.9.3) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- poetry.lock | 40 ++++++++++++++++++++-------------------- pyproject.toml | 2 +- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/poetry.lock b/poetry.lock index 0c5da61b2..d0ad219bc 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4006,29 +4006,29 @@ pyasn1 = ">=0.1.3" [[package]] name = "ruff" -version = "0.9.2" +version = "0.9.3" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.9.2-py3-none-linux_armv6l.whl", hash = "sha256:80605a039ba1454d002b32139e4970becf84b5fee3a3c3bf1c2af6f61a784347"}, - {file = "ruff-0.9.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b9aab82bb20afd5f596527045c01e6ae25a718ff1784cb92947bff1f83068b00"}, - {file = "ruff-0.9.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fbd337bac1cfa96be615f6efcd4bc4d077edbc127ef30e2b8ba2a27e18c054d4"}, - {file = "ruff-0.9.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b35259b0cbf8daa22a498018e300b9bb0174c2bbb7bcba593935158a78054d"}, - {file = "ruff-0.9.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b6a9701d1e371bf41dca22015c3f89769da7576884d2add7317ec1ec8cb9c3c"}, - {file = "ruff-0.9.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9cc53e68b3c5ae41e8faf83a3b89f4a5d7b2cb666dff4b366bb86ed2a85b481f"}, - {file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8efd9da7a1ee314b910da155ca7e8953094a7c10d0c0a39bfde3fcfd2a015684"}, - {file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3292c5a22ea9a5f9a185e2d131dc7f98f8534a32fb6d2ee7b9944569239c648d"}, - {file = "ruff-0.9.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a605fdcf6e8b2d39f9436d343d1f0ff70c365a1e681546de0104bef81ce88df"}, - {file = "ruff-0.9.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c547f7f256aa366834829a08375c297fa63386cbe5f1459efaf174086b564247"}, - {file = "ruff-0.9.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d18bba3d3353ed916e882521bc3e0af403949dbada344c20c16ea78f47af965e"}, - {file = "ruff-0.9.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b338edc4610142355ccf6b87bd356729b62bf1bc152a2fad5b0c7dc04af77bfe"}, - {file = "ruff-0.9.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:492a5e44ad9b22a0ea98cf72e40305cbdaf27fac0d927f8bc9e1df316dcc96eb"}, - {file = "ruff-0.9.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:af1e9e9fe7b1f767264d26b1075ac4ad831c7db976911fa362d09b2d0356426a"}, - {file = "ruff-0.9.2-py3-none-win32.whl", hash = "sha256:71cbe22e178c5da20e1514e1e01029c73dc09288a8028a5d3446e6bba87a5145"}, - {file = "ruff-0.9.2-py3-none-win_amd64.whl", hash = "sha256:c5e1d6abc798419cf46eed03f54f2e0c3adb1ad4b801119dedf23fcaf69b55b5"}, - {file = "ruff-0.9.2-py3-none-win_arm64.whl", hash = "sha256:a1b63fa24149918f8b37cef2ee6fff81f24f0d74b6f0bdc37bc3e1f2143e41c6"}, - {file = "ruff-0.9.2.tar.gz", hash = "sha256:b5eceb334d55fae5f316f783437392642ae18e16dcf4f1858d55d3c2a0f8f5d0"}, + {file = "ruff-0.9.3-py3-none-linux_armv6l.whl", hash = "sha256:7f39b879064c7d9670197d91124a75d118d00b0990586549949aae80cdc16624"}, + {file = "ruff-0.9.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a187171e7c09efa4b4cc30ee5d0d55a8d6c5311b3e1b74ac5cb96cc89bafc43c"}, + {file = "ruff-0.9.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c59ab92f8e92d6725b7ded9d4a31be3ef42688a115c6d3da9457a5bda140e2b4"}, + {file = "ruff-0.9.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dc153c25e715be41bb228bc651c1e9b1a88d5c6e5ed0194fa0dfea02b026439"}, + {file = "ruff-0.9.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:646909a1e25e0dc28fbc529eab8eb7bb583079628e8cbe738192853dbbe43af5"}, + {file = "ruff-0.9.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a5a46e09355695fbdbb30ed9889d6cf1c61b77b700a9fafc21b41f097bfbba4"}, + {file = "ruff-0.9.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c4bb09d2bbb394e3730d0918c00276e79b2de70ec2a5231cd4ebb51a57df9ba1"}, + {file = "ruff-0.9.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:96a87ec31dc1044d8c2da2ebbed1c456d9b561e7d087734336518181b26b3aa5"}, + {file = "ruff-0.9.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb7554aca6f842645022fe2d301c264e6925baa708b392867b7a62645304df4"}, + {file = "ruff-0.9.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cabc332b7075a914ecea912cd1f3d4370489c8018f2c945a30bcc934e3bc06a6"}, + {file = "ruff-0.9.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:33866c3cc2a575cbd546f2cd02bdd466fed65118e4365ee538a3deffd6fcb730"}, + {file = "ruff-0.9.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:006e5de2621304c8810bcd2ee101587712fa93b4f955ed0985907a36c427e0c2"}, + {file = "ruff-0.9.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ba6eea4459dbd6b1be4e6bfc766079fb9b8dd2e5a35aff6baee4d9b1514ea519"}, + {file = "ruff-0.9.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:90230a6b8055ad47d3325e9ee8f8a9ae7e273078a66401ac66df68943ced029b"}, + {file = "ruff-0.9.3-py3-none-win32.whl", hash = "sha256:eabe5eb2c19a42f4808c03b82bd313fc84d4e395133fb3fc1b1516170a31213c"}, + {file = "ruff-0.9.3-py3-none-win_amd64.whl", hash = "sha256:040ceb7f20791dfa0e78b4230ee9dce23da3b64dd5848e40e3bf3ab76468dcf4"}, + {file = "ruff-0.9.3-py3-none-win_arm64.whl", hash = "sha256:800d773f6d4d33b0a3c60e2c6ae8f4c202ea2de056365acfa519aa48acf28e0b"}, + {file = "ruff-0.9.3.tar.gz", hash = "sha256:8293f89985a090ebc3ed1064df31f3b4b56320cdfcec8b60d3295bddb955c22a"}, ] [[package]] @@ -5021,4 +5021,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "3.11.2" -content-hash = "6a485f0828d702e51ebfe96f113838203009e972b36edf24b4aef7264d036d02" +content-hash = "37c2c20e44336bb3a5c07037884bb7eca9ac4268cd175dfca77be350e9e76da2" diff --git a/pyproject.toml b/pyproject.toml index 3a4dd018d..1eb25a0bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,7 +87,7 @@ newrelic = "^10.4.0" [tool.poetry.group.dev.dependencies] black = "^24.8.0" isort = "^5.13.2" -ruff = "^0.9.2" +ruff = "^0.9.3" pre-commit = "^3.8.0" [tool.isort] From 1145424780893f38a8ba79b51396d9e07f1698de Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Jan 2025 00:53:16 +0000 Subject: [PATCH 09/13] chore(deps): Bump unstructured from 0.16.14 to 0.16.15 Bumps [unstructured](https://github.com/Unstructured-IO/unstructured) from 0.16.14 to 0.16.15. - [Release notes](https://github.com/Unstructured-IO/unstructured/releases) - [Changelog](https://github.com/Unstructured-IO/unstructured/blob/main/CHANGELOG.md) - [Commits](https://github.com/Unstructured-IO/unstructured/compare/0.16.14...0.16.15) --- updated-dependencies: - dependency-name: unstructured dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- poetry.lock | 16 ++++++++-------- pyproject.toml | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/poetry.lock b/poetry.lock index d0ad219bc..6258904fd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4509,13 +4509,13 @@ files = [ [[package]] name = "unstructured" -version = "0.16.14" +version = "0.16.15" description = "A library that prepares raw documents for downstream ML tasks." optional = false python-versions = "<3.13,>=3.9.0" files = [ - {file = "unstructured-0.16.14-py3-none-any.whl", hash = "sha256:7b3c2eb21e65d2f61240de7a5241fd7734d97be2c9cfa5f70934e10470318131"}, - {file = "unstructured-0.16.14.tar.gz", hash = "sha256:cec819461090226cd478429c1e0fda19a66ba49ab9ade1ea1fd9ec79c279d7ac"}, + {file = "unstructured-0.16.15-py3-none-any.whl", hash = "sha256:5b0931eb92fb858b983fada18111efdf9c2a0c861ef8e9b58c4e05b1daa50e35"}, + {file = "unstructured-0.16.15.tar.gz", hash = "sha256:18fb850d47b5a2a6ea45b2f7e0eda687f903a2f2e58909b1defd48e2b3126ff4"}, ] [package.dependencies] @@ -4543,19 +4543,19 @@ unstructured-client = "*" wrapt = "*" [package.extras] -all-docs = ["effdet", "google-cloud-vision", "markdown", "networkx", "onnx", "openpyxl", "pandas", "pdf2image", "pdfminer.six", "pi-heif", "pikepdf", "pypandoc", "pypdf", "python-docx (>=1.1.2)", "python-pptx (>=1.0.1)", "unstructured-inference (==0.8.1)", "unstructured.pytesseract (>=0.3.12)", "xlrd"] +all-docs = ["effdet", "google-cloud-vision", "markdown", "networkx", "onnx", "openpyxl", "pandas", "pdf2image", "pdfminer.six", "pi-heif", "pikepdf", "pypandoc", "pypdf", "python-docx (>=1.1.2)", "python-pptx (>=1.0.1)", "unstructured-inference (>=0.8.6)", "unstructured.pytesseract (>=0.3.12)", "xlrd"] csv = ["pandas"] doc = ["python-docx (>=1.1.2)"] docx = ["python-docx (>=1.1.2)"] epub = ["pypandoc"] huggingface = ["langdetect", "sacremoses", "sentencepiece", "torch", "transformers"] -image = ["effdet", "google-cloud-vision", "onnx", "pdf2image", "pdfminer.six", "pi-heif", "pikepdf", "pypdf", "unstructured-inference (==0.8.1)", "unstructured.pytesseract (>=0.3.12)"] -local-inference = ["effdet", "google-cloud-vision", "markdown", "networkx", "onnx", "openpyxl", "pandas", "pdf2image", "pdfminer.six", "pi-heif", "pikepdf", "pypandoc", "pypdf", "python-docx (>=1.1.2)", "python-pptx (>=1.0.1)", "unstructured-inference (==0.8.1)", "unstructured.pytesseract (>=0.3.12)", "xlrd"] +image = ["effdet", "google-cloud-vision", "onnx", "pdf2image", "pdfminer.six", "pi-heif", "pikepdf", "pypdf", "unstructured-inference (>=0.8.6)", "unstructured.pytesseract (>=0.3.12)"] +local-inference = ["effdet", "google-cloud-vision", "markdown", "networkx", "onnx", "openpyxl", "pandas", "pdf2image", "pdfminer.six", "pi-heif", "pikepdf", "pypandoc", "pypdf", "python-docx (>=1.1.2)", "python-pptx (>=1.0.1)", "unstructured-inference (>=0.8.6)", "unstructured.pytesseract (>=0.3.12)", "xlrd"] md = ["markdown"] odt = ["pypandoc", "python-docx (>=1.1.2)"] org = ["pypandoc"] paddleocr = ["paddlepaddle (==3.0.0b1)", "unstructured.paddleocr (==2.8.1.0)"] -pdf = ["effdet", "google-cloud-vision", "onnx", "pdf2image", "pdfminer.six", "pi-heif", "pikepdf", "pypdf", "unstructured-inference (==0.8.1)", "unstructured.pytesseract (>=0.3.12)"] +pdf = ["effdet", "google-cloud-vision", "onnx", "pdf2image", "pdfminer.six", "pi-heif", "pikepdf", "pypdf", "unstructured-inference (>=0.8.6)", "unstructured.pytesseract (>=0.3.12)"] ppt = ["python-pptx (>=1.0.1)"] pptx = ["python-pptx (>=1.0.1)"] rst = ["pypandoc"] @@ -5021,4 +5021,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "3.11.2" -content-hash = "37c2c20e44336bb3a5c07037884bb7eca9ac4268cd175dfca77be350e9e76da2" +content-hash = "3be3fe281b04ee7a44701778fe8a0ddbe22d31ee146b0e14607a6fbd1c8cda41" diff --git a/pyproject.toml b/pyproject.toml index 1eb25a0bd..dbb44d436 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,7 @@ sentry-sdk = "^2.20.0" bitcash = "^1.0.2" pydantic = "^2.10.5" pydantic_core = "^2.18.4" -unstructured = "^0.16.14" +unstructured = "^0.16.15" Markdown = "^3.6" faiss-cpu = "^1.8.0" psutil = "^5.9.8" From d22670bc559ec484eab7afd279a59ff98cfb5b79 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Jan 2025 01:01:48 +0000 Subject: [PATCH 10/13] chore(deps): Bump selenium from 4.28.0 to 4.28.1 Bumps [selenium](https://github.com/SeleniumHQ/Selenium) from 4.28.0 to 4.28.1. - [Release notes](https://github.com/SeleniumHQ/Selenium/releases) - [Commits](https://github.com/SeleniumHQ/Selenium/commits) --- updated-dependencies: - dependency-name: selenium dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 6258904fd..be1d6b67f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4111,13 +4111,13 @@ wrapt = ">=1.10,<2.0" [[package]] name = "selenium" -version = "4.28.0" +version = "4.28.1" description = "Official Python bindings for Selenium WebDriver" optional = false python-versions = ">=3.9" files = [ - {file = "selenium-4.28.0-py3-none-any.whl", hash = "sha256:3d6a2e8e1b850a1078884ea19f4e011ecdc12263434d87a0b78769836fb82dd8"}, - {file = "selenium-4.28.0.tar.gz", hash = "sha256:a9fae6eef48d470a1b0c6e45185d96f0dafb025e8da4b346cc41e4da3ac54fa0"}, + {file = "selenium-4.28.1-py3-none-any.whl", hash = "sha256:4238847e45e24e4472cfcf3554427512c7aab9443396435b1623ef406fff1cc1"}, + {file = "selenium-4.28.1.tar.gz", hash = "sha256:0072d08670d7ec32db901bd0107695a330cecac9f196e3afb3fa8163026e022a"}, ] [package.dependencies] @@ -5021,4 +5021,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "3.11.2" -content-hash = "3be3fe281b04ee7a44701778fe8a0ddbe22d31ee146b0e14607a6fbd1c8cda41" +content-hash = "9e92c532af81fecec4b0855a320481a779b88c8e456ec7d745c47d4be4790fbb" diff --git a/pyproject.toml b/pyproject.toml index dbb44d436..0b5747c78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ Unidecode = "^1.3.8" user-agents = "^2.2.0" whitenoise = "^6.8.2" django-debug-toolbar = "^4.4.6" -selenium = "^4.28.0" +selenium = "^4.28.1" pylibmc = "^1.6.1" psycopg2-binary = "^2.9.10" boto = "^2.49.0" From c7f62ef18bf159c231dee8bafe486cf0e4dc7ac3 Mon Sep 17 00:00:00 2001 From: Sahil Omkumar Dhillon <118592065+SahilDhillon21@users.noreply.github.com> Date: Fri, 24 Jan 2025 08:43:31 +0530 Subject: [PATCH 11/13] Search fix and enhanced UI (#3252) * frontend changes * Search bar changes * formatting changes --- website/templates/includes/header.html | 73 ++- website/templates/search.html | 753 +++++++++++++++++++++---- website/views/core.py | 93 ++- 3 files changed, 793 insertions(+), 126 deletions(-) diff --git a/website/templates/includes/header.html b/website/templates/includes/header.html index 86e57dcd1..5752c4012 100644 --- a/website/templates/includes/header.html +++ b/website/templates/includes/header.html @@ -149,9 +149,11 @@ action="{% url 'search' %}" method="get"> +
@@ -159,7 +161,7 @@ @@ -177,11 +179,31 @@ class="filter-option flex items-center px-3 py-2 hover:bg-gray-100 text-sm"> {% trans "Projects" %} + + {% trans "Repos" %} + {% trans "Users" %} + + {% trans "Issues" %} + + + {% trans "Domains" %} + + + {% trans "Labels" %} + @@ -345,9 +367,37 @@

Chat with BLT Bot

- {% endif %}
{% if query %} -
+
{% if issues %} -
- {% for activity in issues %} - {% include "_activity.html" %} - {% endfor %} +
+
+
+
+

Issues

+
+ {% for activity in issues %} + {% include "_activity.html" %} + {% endfor %} +
+
+
+
- {% elif domains %} -
-
- {% for domain in domains %} - + {% endif %} + {% if domains %} +
+
+
+
+

Domains

+
+ {% for domain in domains %} +
+
+ {% if domain.logo %} + {{ domain.name }} Logo + {% else %} +
+ N/A +
+ {% endif %} +
+

+ {{ domain.name }} +

+ {% if domain.hostname_domain %}

{{ domain.hostname_domain }}

{% endif %} +
+
+
+ {% if domain.webshot %} + + {{ domain.name }} Webshot + + {% endif %} +
+
+
+ + Clicks: {{ domain.clicks|default:"0" }} +
+
+ + Open Issues: {{ domain.open_issues.count }} +
+
+ + Closed Issues: {{ domain.closed_issues.count }} +
+
+ {% if domain.managers.exists %} +
+ Managers: +
+ {% for manager in domain.managers.all %} + + {{ manager.get_full_name|default:manager.username }} + + {% endfor %} +
+
+ {% endif %} +
+ {% if domain.twitter %} + + + Twitter + + {% endif %} + {% if domain.facebook %} + + + Facebook + + {% endif %} + {% if domain.github %} + + + GitHub + + {% endif %} +
+ {% if domain.tags.exists %} +
+ {% for tag in domain.tags.all %} + + {{ tag.name }} + + {% endfor %} +
+ {% endif %} + {% if domain.top_tester %} +
+ + Top Tester: {{ domain.top_tester.get_full_name|default:domain.top_tester.username }} +
+ {% endif %} +
+
+ Domain Color: +
+
+
+
+ {% endfor %}
- {% endfor %} +
- {% elif users %} -
-
+
+ {% endif %} + {% if users %} +
+
+

Users

+
{% for user in users %} -
-
- {% if user.user.userprofile.avatar %} - {{ user.user.username }} - {% elif user.user.socialaccount_set.all.0.get_avatar_url %} - user image +
+ {% if user.user.userprofile.user_avatar %} + {{ user.user.username }} + {% elif user.user.socialaccount_set.all.0.get_avatar_url %} + user image + {% else %} + user image + {% endif %} +
+

+ {{ user.user.username }} + {{ user.get_title_display }} +

+
+

+ + Role: + {{ user.role|default:"Not specified" }} +

+

+ + Points: + {{ user.total_score }} +

+ {% if user.team %} +

+ + Team: + {{ user.team }} +

+ {% endif %} +
+
+ {% if user.linkedin_url %} + + + LinkedIn + + {% endif %} + {% if user.github_url %} + + + GitHub + + {% endif %} + {% if user.website_url %} + + + Website + + {% endif %} +
+
+ {% for badge in user.badges %} +
+ {{ badge.badge.title }} + {{ badge.badge.title }} +
+ {% endfor %} +
+
+ {% for tag in user.tags.all %} + + {{ tag.name }} + + {% endfor %} +
+
+
+ {% endfor %} +
+
+
+ {% endif %} + {% if organizations %} +
+
+

Organizations

+
+ {% for organization in organizations %} +
+
+ {% if organization.logo %} + {{ organization.name }} Logo + {% else %} +
+ No Logo +
+ {% endif %} +
+

+ {{ organization.name }} +

+ {% if organization.description %} +

{{ organization.description|truncatechars:150 }}

+ {% endif %} +
+ {% if organization.email %} + + + {{ organization.email }} + + {% endif %} + {% if organization.url %} + + + {{ organization.url }} + + {% endif %} + {% if organization.twitter %} + + + + {% endif %} + {% if organization.facebook %} + + + + {% endif %} +
+
+
+
+
+ Type: {{ organization.get_type_display }} +
+
+ Team Points: {{ organization.team_points }} +
+
+

+ {% if organization.is_active %} + Active + {% else %} + Inactive + {% endif %} +

+
+
+ {% if organization.tags.all %} +
+ {% for tag in organization.tags.all %} + + {{ tag.name }} + + {% endfor %} +
+ {% endif %} +
+ {% endfor %} +
+
+
+ {% endif %} + {% if projects %} +
+
+

Projects

+
+ {% for project in projects %} +
+
+ {% if project.logo %} + {{ organization.name }} Logo {% else %} - user image +
+ No Logo +
{% endif %} - {{ user.user.username }} +
+

+ {{ project.name }} +

+ {% if project.description %} +

{{ project.description|truncatechars:150 }}

+ {% endif %} +
+ {% if project.url %} + + + {{ project.url }} + + {% endif %} + {% if project.twitter %} + + + + {% endif %} + {% if project.facebook %} + + + + {% endif %} +
+
-
- - {% if user.total_score is None %} - 0 Point +
+ {% if project.organization %} + {% if project.organization.logo %} + {{ organization.name }} Logo + {% endif %} +

+ Organization: + {{ project.organization.name }} +

+ {% else %} +

+ Organization: Not Assigned +

+ {% endif %} +
+
+
+ Visits: {{ project.project_visit_count }} +
+
+ Created: {{ project.created|date:"F j, Y" }} +
+
+ Modified: {{ project.modified|date:"F j, Y" }} +
+
+ {% if project.tags.all %} +
+ {% for tag in project.tags.all %} + + {{ tag.name }} + + {% endfor %} +
+ {% endif %} +
+ {% endfor %} +
+
+
+ {% endif %} + {% if tags %} +
+
+

Tags

+
+

+ Tags +

+
+ {% for tag in tags %} + + {{ tag.name }} + + {% endfor %} +
+
+ {% if matching_organizations %} +
+

+ Matching Organizations +

+
+ {% for org in matching_organizations %} +
+ {% if org.logo %} + {{ org.name }} logo + {% endif %} + {{ org.name }} +
+ {% endfor %} +
+
+ {% endif %} + {% if matching_domains %} +
+

+ Matching Domains +

+
+ {% for domain in matching_domains %} +
+ {% if domain.logo %} + {{ domain.name }} logo + {% endif %} + {{ domain.name }} +
+ {% endfor %} +
+
+ {% endif %} + {% if matching_issues %} +
+

+ Matching Issues +

+
+ {% for issue in matching_issues %} +
+ {% if issue.logo %} + {{ issue.description }} logo + {% endif %} + {{ issue.description }} +
+ {% endfor %} +
+
+ {% endif %} + {% if matching_user_profiles %} +
+

+ Matching User Profiles +

+
+ {% for profile in matching_user_profiles %} +
+ {% if profile.user_avatar %} + {{ profile.user.username }} + {% else %} + avatar + {% endif %} + {{ profile.user.username }} +
+ {% endfor %} +
+
+ {% endif %} + {% if matching_repos %} +
+

+ Matching Repositories +

+
+ {% for repo in matching_repos %} + + {% endfor %} +
+
+ {% endif %} +
+
+ {% endif %} + {% if repos %} +
+
+

Repositories

+
+ {% for repo in repos %} +
+
+ {% if repo.logo_url %} + {{ repo.name }} Logo {% else %} - {{ user.total_score|floatformat:0 }} Points +
+ No Logo +
{% endif %} +
+

+ {{ repo.name }} +

+ {% if repo.description %}

{{ repo.description|truncatechars:150 }}

{% endif %} +
+ {% if repo.primary_language %} + {{ repo.primary_language }} + {% endif %} + {% if repo.stars %} + + + {{ repo.stars }} + + {% endif %} + {% if repo.forks %} + + + {{ repo.forks }} + + {% endif %} + {% if repo.open_issues %} + + + {{ repo.open_issues }} + + {% endif %} +
+
+
+
+
+ + Last Updated: + {{ repo.last_updated|date:"d M Y, H:i" }} +
+
+ + License: + {{ repo.license }} +
+
+ + Visits: + {{ repo.repo_visit_count }} +
- View Profile + {% if repo.tags.all %} +
+ {% for tag in repo.tags.all %} + + {{ tag.name }} + + {% endfor %} +
+ {% endif %}
{% endfor %}
- {% else %} -
- +
+ {% endif %} + {% if not issues and not domains and not users and not organizations and not projects and not tags and not repos %} +
+
+
- {% endif %} -
+
+ {% endif %} {% endif %} {% endblock content %} diff --git a/website/views/core.py b/website/views/core.py index e89f1aa79..4a6079c3a 100644 --- a/website/views/core.py +++ b/website/views/core.py @@ -37,9 +37,14 @@ Badge, Domain, Issue, + Organization, PRAnalysisReport, + Project, + Repo, Suggestion, SuggestionVotes, + Tag, + UserBadge, UserProfile, Wallet, ) @@ -300,24 +305,13 @@ def find_key(request, token): def search(request, template="search.html"): query = request.GET.get("query") - stype = request.GET.get("type") + stype = request.GET.get("type", "organizations") context = None if query is None: return render(request, template) query = query.strip() - if query[:6] == "issue:": - stype = "issue" - query = query[6:] - elif query[:7] == "domain:": - stype = "domain" - query = query[7:] - elif query[:5] == "user:": - stype = "user" - query = query[5:] - elif query[:6] == "label:": - stype = "label" - query = query[6:] - if stype == "issue" or stype is None: + + if stype == "issues": context = { "query": query, "type": stype, @@ -325,21 +319,26 @@ def search(request, template="search.html"): Q(is_hidden=True) & ~Q(user_id=request.user.id) )[0:20], } - elif stype == "domain": + elif stype == "domains": context = { "query": query, "type": stype, "domains": Domain.objects.filter(Q(url__icontains=query), hunt=None)[0:20], } - elif stype == "user": + elif stype == "users": + users = ( + UserProfile.objects.filter(Q(user__username__icontains=query)) + .annotate(total_score=Sum("user__points__score")) + .order_by("-total_score")[0:20] + ) + for userprofile in users: + userprofile.badges = UserBadge.objects.filter(user=userprofile.user) context = { "query": query, "type": stype, - "users": UserProfile.objects.filter(Q(user__username__icontains=query)) - .annotate(total_score=Sum("user__points__score")) - .order_by("-total_score")[0:20], + "users": users, } - elif stype == "label": + elif stype == "labels": context = { "query": query, "type": stype, @@ -347,7 +346,61 @@ def search(request, template="search.html"): Q(is_hidden=True) & ~Q(user_id=request.user.id) )[0:20], } + elif stype == "organizations": + organizations = Organization.objects.filter(name__icontains=query) + for org in organizations: + d = Domain.objects.filter(organization=org).first() + if d: + org.absolute_url = d.get_absolute_url() + context = { + "query": query, + "type": stype, + "organizations": Organization.objects.filter(name__icontains=query), + } + elif stype == "projects": + context = { + "query": query, + "type": stype, + "projects": Project.objects.filter( + Q(name__icontains=query) | Q(description__icontains=query) + ), + } + elif stype == "repos": + context = { + "query": query, + "type": stype, + "repos": Repo.objects.filter( + Q(name__icontains=query) | Q(description__icontains=query) + ), + } + elif stype == "tags": + tags = Tag.objects.filter(name__icontains=query) + matching_organizations = Organization.objects.filter(tags__in=tags).distinct() + matching_domains = Domain.objects.filter(tags__in=tags).distinct() + matching_issues = Issue.objects.filter(tags__in=tags).distinct() + matching_user_profiles = UserProfile.objects.filter(tags__in=tags).distinct() + matching_repos = Repo.objects.filter(tags__in=tags).distinct() + for org in matching_organizations: + d = Domain.objects.filter(organization=org).first() + if d: + org.absolute_url = d.get_absolute_url() + context = { + "query": query, + "type": stype, + "tags": tags, + "matching_organizations": matching_organizations, + "matching_domains": matching_domains, + "matching_issues": matching_issues, + "matching_user_profiles": matching_user_profiles, + "matching_repos": matching_repos, + } + elif stype == "languages": + context = { + "query": query, + "type": stype, + "repos": Repo.objects.filter(primary_language__icontains=query), + } if request.user.is_authenticated: context["wallet"] = Wallet.objects.get(user=request.user) return render(request, template, context) From 8dc96e604581b4b5b2a970adbe69c920db9e17e4 Mon Sep 17 00:00:00 2001 From: DonnieBLT <128622481+DonnieBLT@users.noreply.github.com> Date: Thu, 23 Jan 2025 22:33:54 -0500 Subject: [PATCH 12/13] Update slack_handlers.py --- website/views/slack_handlers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/views/slack_handlers.py b/website/views/slack_handlers.py index 7d2f8e62c..182768b43 100644 --- a/website/views/slack_handlers.py +++ b/website/views/slack_handlers.py @@ -262,7 +262,7 @@ def slack_commands(request): "type": "section", "text": { "type": "mrkdwn", - "text": " 🛠 *GSOC Projects:* View this year's participating GSOC projects .", + "text": " 🛠 *GSOC Projects:* View this year's participating GSOC projects https://owasp.org/www-community/initiatives/gsoc/gsoc2025ideas", }, }, {"type": "divider"}, From c501ef82f4b5a9323aaaabb00ec45d57cf5a8cfe Mon Sep 17 00:00:00 2001 From: DonnieBLT <128622481+DonnieBLT@users.noreply.github.com> Date: Thu, 23 Jan 2025 22:51:52 -0500 Subject: [PATCH 13/13] feat: Add project status field and Slack stats command - Added a new `status` field to the Project model with predefined status choices - Created a migration for the new Project status field - Implemented a new `/stats` Slack command to display project and platform statistics - Reformatted code to improve readability and follow line length guidelines - Updated pyproject.toml to increase line length limit to 120 characters --- pyproject.toml | 4 +- website/migrations/0182_project_status.py | 27 ++++++ website/models.py | 112 +++++++--------------- website/views/slack_handlers.py | 52 ++++++++-- 4 files changed, 106 insertions(+), 89 deletions(-) create mode 100644 website/migrations/0182_project_status.py diff --git a/pyproject.toml b/pyproject.toml index 0b5747c78..86f87c393 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,11 +92,11 @@ pre-commit = "^3.8.0" [tool.isort] known_first_party = ["blt"] -line_length = 100 +line_length = 120 profile = "black" [tool.ruff] -line-length = 100 +line-length = 120 target-version = "py311" [tool.ruff.lint] diff --git a/website/migrations/0182_project_status.py b/website/migrations/0182_project_status.py new file mode 100644 index 000000000..eb7fbae8b --- /dev/null +++ b/website/migrations/0182_project_status.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.4 on 2025-01-24 03:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("website", "0181_slackintegration_welcome_message"), + ] + + operations = [ + migrations.AddField( + model_name="project", + name="status", + field=models.CharField( + choices=[ + ("flagship", "Flagship"), + ("production", "Production"), + ("incubator", "Incubator"), + ("lab", "Lab"), + ("inactive", "Inactive"), + ], + default="new", + max_length=20, + ), + ), + ] diff --git a/website/models.py b/website/models.py index fe328d9e7..ca319cdd2 100644 --- a/website/models.py +++ b/website/models.py @@ -88,16 +88,10 @@ def __str__(self): class SlackIntegration(models.Model): - integration = models.OneToOneField( - Integration, on_delete=models.CASCADE, related_name="slack_integration" - ) - bot_access_token = models.CharField( - max_length=255, null=True, blank=True - ) # will be different for each workspace + integration = models.OneToOneField(Integration, on_delete=models.CASCADE, related_name="slack_integration") + bot_access_token = models.CharField(max_length=255, null=True, blank=True) # will be different for each workspace workspace_name = models.CharField(max_length=255, null=True, blank=True) - default_channel_name = models.CharField( - max_length=255, null=True, blank=True - ) # Default channel ID + default_channel_name = models.CharField(max_length=255, null=True, blank=True) # Default channel ID default_channel_id = models.CharField(max_length=255, null=True, blank=True) daily_updates = models.BooleanField(default=False) daily_update_time = models.IntegerField( @@ -193,12 +187,7 @@ def closed_issues(self): @property def top_tester(self): - return ( - User.objects.filter(issue__domain=self) - .annotate(total=Count("issue")) - .order_by("-total") - .first() - ) + return User.objects.filter(issue__domain=self).annotate(total=Count("issue")).order_by("-total").first() @property def get_name(self): @@ -315,9 +304,7 @@ class HuntPrize(models.Model): name = models.CharField(max_length=50) value = models.PositiveIntegerField(default=0) no_of_eligible_projects = models.PositiveIntegerField(default=1) # no of winner in this prize - valid_submissions_eligible = models.BooleanField( - default=False - ) # all valid submissions are winners in this prize + valid_submissions_eligible = models.BooleanField(default=False) # all valid submissions are winners in this prize prize_in_crypto = models.BooleanField(default=False) description = models.TextField(null=True, blank=True) created = models.DateTimeField(auto_now_add=True) @@ -352,12 +339,8 @@ class Issue(models.Model): status = models.CharField(max_length=10, default="open", null=True, blank=True) user_agent = models.CharField(max_length=255, default="", null=True, blank=True) ocr = models.TextField(default="", null=True, blank=True) - screenshot = models.ImageField( - upload_to="screenshots", null=True, blank=True, validators=[validate_image] - ) - closed_by = models.ForeignKey( - User, null=True, blank=True, related_name="closed_by", on_delete=models.CASCADE - ) + screenshot = models.ImageField(upload_to="screenshots", null=True, blank=True, validators=[validate_image]) + closed_by = models.ForeignKey(User, null=True, blank=True, related_name="closed_by", on_delete=models.CASCADE) closed_date = models.DateTimeField(default=None, null=True, blank=True) github_url = models.URLField(default="", null=True, blank=True) created = models.DateTimeField(auto_now_add=True) @@ -400,9 +383,7 @@ def get_twitter_message(self): prefix + self.domain_title + spacer - + self.description[ - : 140 - (len(prefix) + len(self.domain_title) + len(spacer) + len(issue_link)) - ] + + self.description[: 140 - (len(prefix) + len(self.domain_title) + len(spacer) + len(issue_link))] + issue_link ) return msg @@ -462,9 +443,7 @@ def delete_image_on_issue_delete(sender, instance, **kwargs): except NotFound: logger.warning(f"File not found in Google Cloud Storage: {blob_name}") except Exception as e: - logger.error( - f"Error deleting image from Google Cloud Storage: {blob_name} - {str(e)}" - ) + logger.error(f"Error deleting image from Google Cloud Storage: {blob_name} - {str(e)}") else: @@ -496,9 +475,7 @@ def delete_image_on_post_delete(sender, instance, **kwargs): except NotFound: logger.warning(f"File not found in Google Cloud Storage: {blob_name}") except Exception as e: - logger.error( - f"Error deleting image from Google Cloud Storage: {blob_name} - {str(e)}" - ) + logger.error(f"Error deleting image from Google Cloud Storage: {blob_name} - {str(e)}") else: @@ -530,12 +507,8 @@ def update_issue_image_access(sender, instance, **kwargs): class Winner(models.Model): hunt = models.ForeignKey(Hunt, null=True, blank=True, on_delete=models.CASCADE) - winner = models.ForeignKey( - User, related_name="winner", null=True, blank=True, on_delete=models.CASCADE - ) - runner = models.ForeignKey( - User, related_name="runner", null=True, blank=True, on_delete=models.CASCADE - ) + winner = models.ForeignKey(User, related_name="winner", null=True, blank=True, on_delete=models.CASCADE) + runner = models.ForeignKey(User, related_name="runner", null=True, blank=True, on_delete=models.CASCADE) second_runner = models.ForeignKey( User, related_name="second_runner", @@ -598,12 +571,8 @@ class UserProfile(models.Model): issue_flaged = models.ManyToManyField(Issue, blank=True, related_name="flaged") issues_hidden = models.BooleanField(default=False) - subscribed_domains = models.ManyToManyField( - Domain, related_name="user_subscribed_domains", blank=True - ) - subscribed_users = models.ManyToManyField( - User, related_name="user_subscribed_users", blank=True - ) + subscribed_domains = models.ManyToManyField(Domain, related_name="user_subscribed_domains", blank=True) + subscribed_users = models.ManyToManyField(User, related_name="user_subscribed_users", blank=True) btc_address = models.CharField(max_length=100, blank=True, null=True) bch_address = models.CharField(max_length=100, blank=True, null=True) eth_address = models.CharField(max_length=100, blank=True, null=True) @@ -655,9 +624,7 @@ def update_streak_and_award_points(self, check_in_date=None): try: with transaction.atomic(): # Streak logic - if not self.last_check_in or check_in_date == self.last_check_in + timedelta( - days=1 - ): + if not self.last_check_in or check_in_date == self.last_check_in + timedelta(days=1): self.current_streak += 1 self.longest_streak = max(self.current_streak, self.longest_streak) # If check-in is not consecutive, reset streak @@ -773,9 +740,7 @@ class Wallet(models.Model): created = models.DateTimeField(auto_now_add=True) def deposit(self, value): - self.transaction_set.create( - value=value, running_balance=self.current_balance + Decimal(value) - ) + self.transaction_set.create(value=value, running_balance=self.current_balance + Decimal(value)) self.current_balance += Decimal(value) self.save() @@ -783,9 +748,7 @@ def withdraw(self, value): if value > self.current_balance: raise Exception("This wallet has insufficient balance.") - self.transaction_set.create( - value=-value, running_balance=self.current_balance - Decimal(value) - ) + self.transaction_set.create(value=-value, running_balance=self.current_balance - Decimal(value)) self.current_balance -= Decimal(value) self.save() @@ -903,6 +866,14 @@ def __str__(self): class Project(models.Model): + STATUS_CHOICES = [ + ("flagship", "Flagship"), + ("production", "Production"), + ("incubator", "Incubator"), + ("lab", "Lab"), + ("inactive", "Inactive"), + ] + organization = models.ForeignKey( Organization, null=True, @@ -913,9 +884,8 @@ class Project(models.Model): name = models.CharField(max_length=255) slug = models.SlugField(unique=True, blank=True) description = models.TextField() - url = models.URLField( - unique=True, null=True, blank=True - ) # Made url nullable in case of no website + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="new") + url = models.URLField(unique=True, null=True, blank=True) # Made url nullable in case of no website project_visit_count = models.IntegerField(default=0) twitter = models.CharField(max_length=30, null=True, blank=True) facebook = models.URLField(null=True, blank=True) @@ -956,9 +926,7 @@ class Contribution(models.Model): title = models.CharField(max_length=255) description = models.TextField() repository = models.ForeignKey(Project, on_delete=models.CASCADE, null=True) - contribution_type = models.CharField( - max_length=20, choices=CONTRIBUTION_TYPES, default="commit" - ) + contribution_type = models.CharField(max_length=20, choices=CONTRIBUTION_TYPES, default="commit") github_username = models.CharField(max_length=255, default="") github_id = models.CharField(max_length=100, null=True, blank=True) github_url = models.URLField(null=True, blank=True) @@ -979,9 +947,7 @@ class BaconToken(models.Model): amount = models.DecimalField(max_digits=10, decimal_places=2) created = models.DateTimeField(auto_now_add=True) contribution = models.OneToOneField(Contribution, on_delete=models.CASCADE) - token_id = models.CharField( - max_length=64, blank=True, null=True - ) # Token ID from the Runes protocol + token_id = models.CharField(max_length=64, blank=True, null=True) # Token ID from the Runes protocol def __str__(self): return f"{self.user.username} - {self.amount} BACON" @@ -1028,9 +994,7 @@ def clear_blocked_cache(sender, instance=None, **kwargs): class TimeLog(models.Model): - user = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="timelogs" - ) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="timelogs") # associate organization with sizzle organization = models.ForeignKey( Organization, @@ -1055,9 +1019,7 @@ def __str__(self): class ActivityLog(models.Model): - user = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="activity_logs" - ) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="activity_logs") window_title = models.CharField(max_length=255) url = models.URLField(null=True, blank=True) # URL field for activity-related URL recorded_at = models.DateTimeField(auto_now_add=True) @@ -1245,9 +1207,7 @@ def verify_file_upload(sender, instance, **kwargs): print(f"Checking if image '{instance.image.name}' exists in the storage backend...") if not default_storage.exists(instance.image.name): print(f"Image '{instance.image.name}' was not uploaded to the storage backend.") - raise ValidationError( - f"Image '{instance.image.name}' was not uploaded to the storage backend." - ) + raise ValidationError(f"Image '{instance.image.name}' was not uploaded to the storage backend.") class Repo(models.Model): @@ -1330,15 +1290,11 @@ class ContributorStats(models.Model): comments = models.PositiveIntegerField(default=0) # "day" for daily entries, "month" for monthly entries - granularity = models.CharField( - max_length=10, choices=[("day", "Day"), ("month", "Month")], default="day" - ) + granularity = models.CharField(max_length=10, choices=[("day", "Day"), ("month", "Month")], default="day") class Meta: # You can't have two different stats for the same date+granularity unique_together = ("contributor", "repo", "date", "granularity") def __str__(self): - return ( - f"{self.contributor.name} in {self.repo.name} " f"on {self.date} [{self.granularity}]" - ) + return f"{self.contributor.name} in {self.repo.name} " f"on {self.date} [{self.granularity}]" diff --git a/website/views/slack_handlers.py b/website/views/slack_handlers.py index 7d2f8e62c..806bc9942 100644 --- a/website/views/slack_handlers.py +++ b/website/views/slack_handlers.py @@ -4,12 +4,13 @@ import os import time +from django.db.models import Count from django.http import HttpResponse, JsonResponse from django.views.decorators.csrf import csrf_exempt from slack import WebClient from slack_sdk.errors import SlackApiError -from website.models import SlackIntegration +from website.models import Domain, Hunt, Issue, Project, SlackIntegration, User if os.getenv("ENV") != "production": from dotenv import load_dotenv @@ -41,10 +42,7 @@ def verify_slack_signature(request): sig_basestring = f"v0:{timestamp}:{request.body.decode()}" # Calculate our signature - my_signature = ( - "v0=" - + hmac.new(SIGNING_SECRET.encode(), sig_basestring.encode(), hashlib.sha256).hexdigest() - ) + my_signature = "v0=" + hmac.new(SIGNING_SECRET.encode(), sig_basestring.encode(), hashlib.sha256).hexdigest() # Compare signatures is_valid = hmac.compare_digest(my_signature, signature) @@ -175,9 +173,7 @@ def _handle_team_join(user_id, request): dm_channel = dm_response["channel"]["id"] - welcome_blocks = [ - {"type": "section", "text": {"type": "mrkdwn", "text": welcome_message}} - ] + welcome_blocks = [{"type": "section", "text": {"type": "mrkdwn", "text": welcome_message}}] # Send message using appropriate client welcome_response = workspace_client.chat_postMessage( @@ -206,7 +202,45 @@ def slack_commands(request): team_id = request.POST.get("team_id") team_domain = request.POST.get("team_domain") # Get the team domain - if command == "/contrib": + if command == "/stats": + # Get project counts by status + project_stats = Project.objects.values("status").annotate(count=Count("id")) + stats_by_status = {stat["status"]: stat["count"] for stat in project_stats} + + # Get other key metrics + total_issues = Issue.objects.count() + total_users = User.objects.count() + total_domains = Domain.objects.count() + total_hunts = Hunt.objects.count() + + # Format stats sections with line breaks for readability + stats_sections = [ + "*Project Statistics:*\n", + "• Flagship Projects: " f"{stats_by_status.get('flagship', 0)}", + "• Production Projects: " f"{stats_by_status.get('production', 0)}", + "• Incubator Projects: " f"{stats_by_status.get('incubator', 0)}", + "• Lab Projects: " f"{stats_by_status.get('lab', 0)}", + "• New Projects: " f"{stats_by_status.get('new', 0)}", + "• Active Projects: " f"{stats_by_status.get('active', 0)}", + "• Inactive Projects: " f"{stats_by_status.get('inactive', 0)}", + "\n*Overall Platform Statistics:*\n", + f"• Total Issues: {total_issues}", + f"• Total Users: {total_users}", + f"• Total Domains: {total_domains}", + f"• Total Hunts: {total_hunts}", + ] + + stats_message = "\n".join(stats_sections) + + try: + # Post message to Slack + client = WebClient(token=workspace_client.bot_token) + client.chat_postMessage(channel=request.POST.get("channel_id"), text=stats_message) + return JsonResponse({"response_type": "in_channel"}) + except SlackApiError: + return JsonResponse({"error": "Error posting message"}, status=500) + + elif command == "/contrib": try: # First try to get custom integration try: