diff --git a/backend/Makefile b/backend/Makefile index cab4cee5a..9c19088d6 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -18,6 +18,9 @@ collect-static: django-shell: @CMD="python manage.py shell" $(MAKE) exec-backend-command-it +match-user: + @CMD="python manage.py matching_users $(model)" $(MAKE) exec-backend-command-it + dump-data: @echo "Dumping Nest data" @CMD="python manage.py dumpdata github owasp --indent=2" $(MAKE) exec-backend-command > backend/data/nest.json @@ -59,6 +62,10 @@ load-data: @echo "Loading Nest data" @CMD="python manage.py load_data" $(MAKE) exec-backend-command +load-slack-data: + @echo "Loading Slack data" + @CMD="python manage.py load_slack_data" $(MAKE) exec-backend-command + merge-migrations: @CMD="python manage.py makemigrations --merge" $(MAKE) exec-backend-command diff --git a/backend/apps/common/management/commands/matching_users.py b/backend/apps/common/management/commands/matching_users.py new file mode 100644 index 000000000..31e026ebc --- /dev/null +++ b/backend/apps/common/management/commands/matching_users.py @@ -0,0 +1,139 @@ +"""A command to perform fuzzy and exact matching of leaders/slack members with User model.""" + +from django.core.management.base import BaseCommand +from django.db.utils import DatabaseError +from thefuzz import fuzz + +from apps.github.models.user import User +from apps.owasp.models.chapter import Chapter +from apps.owasp.models.committee import Committee +from apps.owasp.models.project import Project +from apps.slack.models import Member + +MIN_NO_OF_WORDS = 2 + + +class Command(BaseCommand): + help = "Match leaders or Slack members with GitHub users using exact and fuzzy matching." + + def add_arguments(self, parser): + parser.add_argument( + "model_name", + type=str, + choices=["chapter", "committee", "project", "member"], + help="Model name to process: chapter, committee, project, or member", + ) + parser.add_argument( + "--threshold", + type=int, + default=75, + help="Threshold for fuzzy matching (0-100)", + ) + + def handle(self, *args, **kwargs): + model_name = kwargs["model_name"].lower() + threshold = max(0, min(kwargs["threshold"], 100)) + + model_map = { + "chapter": (Chapter, "suggested_leaders"), + "committee": (Committee, "suggested_leaders"), + "project": (Project, "suggested_leaders"), + "member": (Member, "suggested_users"), + } + + if model_name not in model_map: + self.stdout.write( + self.style.ERROR( + "Invalid model name! Choose from: chapter, committee, project, member" + ) + ) + return + + model_class, relation_field = model_map[model_name] + + # Pre-fetch GitHub users + all_users = User.objects.values("id", "login", "name") + filtered_users = { + u["id"]: u for u in all_users if self._is_valid_user(u["login"], u["name"]) + } + + instances = model_class.objects.prefetch_related(relation_field) + for instance in instances: + self.stdout.write(f"Processing {model_name} {instance.id}...") + if model_name == "member": + leaders_raw = [field for field in [instance.username, instance.real_name] if field] + else: + leaders_raw = instance.leaders_raw + + exact_matches, fuzzy_matches, unmatched = self.process_leaders( + leaders_raw, threshold, filtered_users + ) + + matched_user_ids = {user["id"] for user in exact_matches + fuzzy_matches} + getattr(instance, relation_field).set(matched_user_ids) + + if unmatched: + self.stdout.write(f"Unmatched for {instance}: {unmatched}") + + def _is_valid_user(self, login, name): + """Check if GitHub user meets minimum requirements.""" + return len(login) >= MIN_NO_OF_WORDS and name and len(name) >= MIN_NO_OF_WORDS + + def process_leaders(self, leaders_raw, threshold, filtered_users): + """Process leaders with optimized matching, capturing all exact matches.""" + if not leaders_raw: + return [], [], [] + + exact_matches = [] + fuzzy_matches = [] + unmatched_leaders = [] + processed_leaders = set() + + user_list = list(filtered_users.values()) + + for leader in leaders_raw: + if not leader or leader in processed_leaders: + continue + + processed_leaders.add(leader) + leader_lower = leader.lower() + + try: + # Find all exact matches + exact_matches_for_leader = [ + u + for u in user_list + if u["login"].lower() == leader_lower + or (u["name"] and u["name"].lower() == leader_lower) + ] + + if exact_matches_for_leader: + exact_matches.extend(exact_matches_for_leader) + for match in exact_matches_for_leader: + self.stdout.write(f"Exact match found for {leader}: {match['login']}") + continue + + # Fuzzy matching with token_sort_ratio + matches = [ + u + for u in user_list + if (fuzz.token_sort_ratio(leader_lower, u["login"].lower()) >= threshold) + or ( + u["name"] + and fuzz.token_sort_ratio(leader_lower, u["name"].lower()) >= threshold + ) + ] + + new_fuzzy_matches = [m for m in matches if m not in exact_matches] + if new_fuzzy_matches: + fuzzy_matches.extend(new_fuzzy_matches) + for match in new_fuzzy_matches: + self.stdout.write(f"Fuzzy match found for {leader}: {match['login']}") + else: + unmatched_leaders.append(leader) + + except DatabaseError as e: + unmatched_leaders.append(leader) + self.stdout.write(self.style.ERROR(f"Error processing leader {leader}: {e}")) + + return exact_matches, fuzzy_matches, unmatched_leaders diff --git a/backend/apps/owasp/admin.py b/backend/apps/owasp/admin.py index 2357af3f3..687a15d35 100644 --- a/backend/apps/owasp/admin.py +++ b/backend/apps/owasp/admin.py @@ -1,6 +1,6 @@ """OWASP app admin.""" -from django.contrib import admin +from django.contrib import admin, messages from django.utils.safestring import mark_safe from apps.owasp.models.chapter import Chapter @@ -41,12 +41,31 @@ def custom_field_owasp_url(self, obj): f"↗️" ) + def approve_suggested_leaders(self, request, queryset): + """Approve all suggested leaders for selected entities.""" + for entity in queryset: + suggestions = entity.suggested_leaders.all() + entity.leaders.add(*suggestions) + self.message_user( + request, + f"Approved {suggestions.count()} leader suggestions for {entity.name}", + messages.SUCCESS, + ) + custom_field_github_urls.short_description = "GitHub 🔗" custom_field_owasp_url.short_description = "OWASP 🔗" + approve_suggested_leaders.short_description = "Approve all suggested leaders" + + +class LeaderEntityAdmin(admin.ModelAdmin, GenericEntityAdminMixin): + """Admin class for entities that have leaders.""" + + actions = ["approve_suggested_leaders"] + filter_horizontal = ("suggested_leaders",) -class ChapterAdmin(admin.ModelAdmin, GenericEntityAdminMixin): - autocomplete_fields = ("owasp_repository",) +class ChapterAdmin(LeaderEntityAdmin): + autocomplete_fields = ("owasp_repository", "leaders") list_display = ( "name", "region", @@ -62,8 +81,8 @@ class ChapterAdmin(admin.ModelAdmin, GenericEntityAdminMixin): search_fields = ("name", "key") -class CommitteeAdmin(admin.ModelAdmin): - autocomplete_fields = ("owasp_repository",) +class CommitteeAdmin(LeaderEntityAdmin): + autocomplete_fields = ("owasp_repository", "leaders") search_fields = ("name",) @@ -92,12 +111,13 @@ class PostAdmin(admin.ModelAdmin): ) -class ProjectAdmin(admin.ModelAdmin, GenericEntityAdminMixin): +class ProjectAdmin(LeaderEntityAdmin): autocomplete_fields = ( "organizations", "owasp_repository", "owners", "repositories", + "leaders", ) list_display = ( "custom_field_name", diff --git a/backend/apps/owasp/migrations/0031_chapter_leaders_chapter_suggested_leaders_and_more.py b/backend/apps/owasp/migrations/0031_chapter_leaders_chapter_suggested_leaders_and_more.py new file mode 100644 index 000000000..669b4f272 --- /dev/null +++ b/backend/apps/owasp/migrations/0031_chapter_leaders_chapter_suggested_leaders_and_more.py @@ -0,0 +1,73 @@ +# Generated by Django 5.1.7 on 2025-03-23 13:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("github", "0018_alter_issue_managers_alter_pullrequest_managers"), + ("owasp", "0030_chapter_is_leaders_policy_compliant_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="chapter", + name="leaders", + field=models.ManyToManyField( + blank=True, + related_name="assigned_%(class)s", + to="github.user", + verbose_name="Assigned leaders", + ), + ), + migrations.AddField( + model_name="chapter", + name="suggested_leaders", + field=models.ManyToManyField( + blank=True, + related_name="matched_%(class)s", + to="github.user", + verbose_name="Matched Users", + ), + ), + migrations.AddField( + model_name="committee", + name="leaders", + field=models.ManyToManyField( + blank=True, + related_name="assigned_%(class)s", + to="github.user", + verbose_name="Assigned leaders", + ), + ), + migrations.AddField( + model_name="committee", + name="suggested_leaders", + field=models.ManyToManyField( + blank=True, + related_name="matched_%(class)s", + to="github.user", + verbose_name="Matched Users", + ), + ), + migrations.AddField( + model_name="project", + name="leaders", + field=models.ManyToManyField( + blank=True, + related_name="assigned_%(class)s", + to="github.user", + verbose_name="Assigned leaders", + ), + ), + migrations.AddField( + model_name="project", + name="suggested_leaders", + field=models.ManyToManyField( + blank=True, + related_name="matched_%(class)s", + to="github.user", + verbose_name="Matched Users", + ), + ), + ] diff --git a/backend/apps/owasp/models/common.py b/backend/apps/owasp/models/common.py index 2260bf247..066949091 100644 --- a/backend/apps/owasp/models/common.py +++ b/backend/apps/owasp/models/common.py @@ -173,6 +173,20 @@ def get_metadata(self): extra={"repository": getattr(self.owasp_repository, "name", None)}, ) + # M2M + suggested_leaders = models.ManyToManyField( + "github.User", + verbose_name="Matched Users", + related_name="matched_%(class)s", + blank=True, + ) + leaders = models.ManyToManyField( + "github.User", + verbose_name="Assigned leaders", + related_name="assigned_%(class)s", + blank=True, + ) + def get_related_url(self, url, exclude_domains=(), include_domains=()): """Get OWASP entity related URL.""" if ( diff --git a/backend/apps/slack/admin.py b/backend/apps/slack/admin.py index 56fe710f2..c6f704e6f 100644 --- a/backend/apps/slack/admin.py +++ b/backend/apps/slack/admin.py @@ -1,8 +1,11 @@ """Slack app admin.""" -from django.contrib import admin +from django.contrib import admin, messages +from apps.slack.models.channel import Channel from apps.slack.models.event import Event +from apps.slack.models.member import Member +from apps.slack.models.workspace import Workspace class EventAdmin(admin.ModelAdmin): @@ -16,4 +19,54 @@ class EventAdmin(admin.ModelAdmin): list_filter = ("trigger",) +class ChannelAdmin(admin.ModelAdmin): + search_fields = ( + "slack_channel_id", + "name", + ) + list_filter = ("is_private",) + + +class MemberAdmin(admin.ModelAdmin): + search_fields = ("slack_user_id", "username", "real_name", "email", "user") + filter_horizontal = ("suggested_users",) + actions = ["approve_suggested_users"] + + def approve_suggested_users(self, request, queryset): + """Approve all suggested users for selected members, enforcing one-to-one constraints.""" + for entity in queryset: + suggestions = entity.suggested_users.all() + + if suggestions.count() == 1: + entity.user = suggestions.first() # only one suggested user + entity.save() + self.message_user( + request, + f" assigned user for {entity}.", + messages.SUCCESS, + ) + elif suggestions.count() > 1: + self.message_user( + request, + f"Error: Multiple suggested users found for {entity}. " + f"Only one user can be assigned due to the one-to-one constraint.", + messages.ERROR, + ) + else: + self.message_user( + request, + f"No suggested users found for {entity}.", + messages.WARNING, + ) + + approve_suggested_users.short_description = "Approve the suggested user (if only one exists)" + + +class WorkspaceAdmin(admin.ModelAdmin): + search_fields = ("slack_workspace_id", "name") + + admin.site.register(Event, EventAdmin) +admin.site.register(Workspace, WorkspaceAdmin) +admin.site.register(Channel, ChannelAdmin) +admin.site.register(Member, MemberAdmin) diff --git a/backend/apps/slack/management/commands/load_slack_data.py b/backend/apps/slack/management/commands/load_slack_data.py new file mode 100644 index 000000000..6a811f6da --- /dev/null +++ b/backend/apps/slack/management/commands/load_slack_data.py @@ -0,0 +1,106 @@ +"""A command to populate Slack channels and members data based on workspaces's bot tokens.""" + +from django.core.management.base import BaseCommand +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +from apps.slack.models import Channel, Member, Workspace + + +class Command(BaseCommand): + help = "Populate channels and members for all Slack workspaces using their bot tokens" + + def handle(self, *args, **options): + workspaces = Workspace.objects.all() + + if not workspaces: + self.stdout.write(self.style.WARNING("No workspaces found in the database")) + return + + for workspace in workspaces: + workspace_id = workspace.slack_workspace_id + workspace_name = workspace.name or "Unnamed" + bot_token = workspace.bot_token + + self.stdout.write(f"\nProcessing workspace: {workspace_name} ({workspace_id})") + + if not bot_token: + self.stdout.write(self.style.ERROR(f"No bot token found for {workspace_id}")) + continue + + # Slack client + client = WebClient(token=bot_token) + + # Populate channels + self.stdout.write(f"Fetching channels for {workspace_id}...") + total_channels = 0 + try: + cursor = None + while True: + response = client.conversations_list( + types="public_channel,private_channel", limit=1000, cursor=cursor + ) + self._handle_slack_response(response, "conversations_list") + + for channel in response["channels"]: + Channel.objects.update_or_create( + workspace=workspace, + slack_channel_id=channel["id"], + defaults={ + "name": channel["name"], + "is_private": channel["is_private"], + "member_count": channel.get("num_members", 0), + }, + ) + total_channels += len(response["channels"]) + + cursor = response.get("response_metadata", {}).get("next_cursor") + if not cursor: + break + + self.stdout.write(self.style.SUCCESS(f"Populated {total_channels} channels")) + except SlackApiError as e: + self.stdout.write( + self.style.ERROR(f"Failed to fetch channels: {e.response['error']}") + ) + + self.stdout.write(f"Fetching members for {workspace_id}...") + total_members = 0 + try: + cursor = None + while True: + response = client.users_list(limit=1000, cursor=cursor) + self._handle_slack_response(response, "users_list") + + member_count = 0 + for user in response["members"]: + if not user["is_bot"] and user["id"] != "USLACKBOT": + Member.objects.update_or_create( + workspace=workspace, + slack_user_id=user["id"], + defaults={ + "username": user["name"], + "real_name": user.get("real_name", ""), + "email": user["profile"].get("email", "Not available"), + }, + ) + member_count += 1 + total_members += member_count + + cursor = response.get("response_metadata", {}).get("next_cursor") + if not cursor: + break + + self.stdout.write(self.style.SUCCESS(f"Populated {total_members} members")) + except SlackApiError as e: + self.stdout.write( + self.style.ERROR(f"Failed to fetch members: {e.response['error']}") + ) + + self.stdout.write(self.style.SUCCESS("\nFinished processing all workspaces")) + + def _handle_slack_response(self, response, api_method): + """Handle Slack API response and raise exception if needed.""" + if not response["ok"]: + error_message = f"{api_method} API call failed" + raise SlackApiError(error_message, response) diff --git a/backend/apps/slack/migrations/0004_workspace_member_channel.py b/backend/apps/slack/migrations/0004_workspace_member_channel.py new file mode 100644 index 000000000..0e7eed2c4 --- /dev/null +++ b/backend/apps/slack/migrations/0004_workspace_member_channel.py @@ -0,0 +1,139 @@ +# Generated by Django 5.1.7 on 2025-03-23 13:51 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("github", "0018_alter_issue_managers_alter_pullrequest_managers"), + ("slack", "0003_remove_event_command"), + ] + + operations = [ + migrations.CreateModel( + name="Workspace", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("nest_created_at", models.DateTimeField(auto_now_add=True)), + ("nest_updated_at", models.DateTimeField(auto_now=True)), + ( + "slack_workspace_id", + models.CharField(max_length=50, unique=True, verbose_name="Workspace ID"), + ), + ( + "name", + models.CharField(default="", max_length=100, verbose_name="Workspace Name"), + ), + ( + "bot_token", + models.CharField(default="", max_length=200, verbose_name="Bot Token"), + ), + ], + options={ + "verbose_name_plural": "Workspaces", + "db_table": "slack_workspaces", + }, + ), + migrations.CreateModel( + name="Member", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("nest_created_at", models.DateTimeField(auto_now_add=True)), + ("nest_updated_at", models.DateTimeField(auto_now=True)), + ("email", models.CharField(default="", max_length=100, verbose_name="Email")), + ( + "real_name", + models.CharField(default="", max_length=100, verbose_name="Real Name"), + ), + ( + "slack_user_id", + models.CharField(max_length=50, unique=True, verbose_name="User ID"), + ), + ( + "username", + models.CharField(default="", max_length=100, verbose_name="Username"), + ), + ( + "suggested_users", + models.ManyToManyField( + blank=True, + related_name="matched_slack_users", + to="github.user", + verbose_name="github_user_suggestions", + ), + ), + ( + "user", + models.OneToOneField( + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="slack_user", + to="github.user", + verbose_name="User", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="members", + to="slack.workspace", + ), + ), + ], + options={ + "verbose_name_plural": "Members", + "db_table": "slack_members", + }, + ), + migrations.CreateModel( + name="Channel", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("nest_created_at", models.DateTimeField(auto_now_add=True)), + ("nest_updated_at", models.DateTimeField(auto_now=True)), + ("is_private", models.BooleanField(default=False, verbose_name="Is Private")), + ( + "member_count", + models.PositiveIntegerField(default=0, verbose_name="Member Count"), + ), + ( + "name", + models.CharField(default="", max_length=100, verbose_name="Channel Name"), + ), + ( + "slack_channel_id", + models.CharField(max_length=50, unique=True, verbose_name="Channel ID"), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="channels", + to="slack.workspace", + ), + ), + ], + options={ + "verbose_name_plural": "Channels", + "db_table": "slack_channels", + }, + ), + ] diff --git a/backend/apps/slack/models/__init__.py b/backend/apps/slack/models/__init__.py index 1387ac966..dfc517a75 100644 --- a/backend/apps/slack/models/__init__.py +++ b/backend/apps/slack/models/__init__.py @@ -1 +1,4 @@ +from .channel import Channel from .event import Event +from .member import Member +from .workspace import Workspace diff --git a/backend/apps/slack/models/channel.py b/backend/apps/slack/models/channel.py new file mode 100644 index 000000000..69b4c4a50 --- /dev/null +++ b/backend/apps/slack/models/channel.py @@ -0,0 +1,24 @@ +"""Slack app channel model.""" + +from django.db import models + +from apps.common.models import TimestampedModel +from apps.slack.models.workspace import Workspace + + +class Channel(TimestampedModel): + """Slack Channel model.""" + + class Meta: + db_table = "slack_channels" + verbose_name_plural = "Channels" + + is_private = models.BooleanField(verbose_name="Is Private", default=False) + member_count = models.PositiveIntegerField(verbose_name="Member Count", default=0) + name = models.CharField(verbose_name="Channel Name", max_length=100, default="") + slack_channel_id = models.CharField(verbose_name="Channel ID", max_length=50, unique=True) + workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name="channels") + + def __str__(self): + """Channel human readable representation.""" + return f"#{self.name} ({self.slack_channel_id})" diff --git a/backend/apps/slack/models/member.py b/backend/apps/slack/models/member.py new file mode 100644 index 000000000..d86095a2c --- /dev/null +++ b/backend/apps/slack/models/member.py @@ -0,0 +1,40 @@ +"""Slack app member model.""" + +from django.db import models + +from apps.common.models import TimestampedModel + +from .workspace import Workspace + + +class Member(TimestampedModel): + """Slack Member model.""" + + class Meta: + db_table = "slack_members" + verbose_name_plural = "Members" + + email = models.CharField(verbose_name="Email", max_length=100, default="") + real_name = models.CharField(verbose_name="Real Name", max_length=100, default="") + slack_user_id = models.CharField(verbose_name="User ID", max_length=50, unique=True) + username = models.CharField(verbose_name="Username", max_length=100, default="") + workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name="members") + user = models.OneToOneField( + "github.User", + verbose_name="User", + related_name="slack_user", + null=True, + default=None, + on_delete=models.SET_NULL, + ) + # M2M + suggested_users = models.ManyToManyField( + "github.User", + verbose_name="github_user_suggestions", + related_name="matched_slack_users", + blank=True, + ) + + def __str__(self): + """Member human readable representation.""" + return f"{self.username or 'Unnamed'} ({self.slack_user_id})" diff --git a/backend/apps/slack/models/workspace.py b/backend/apps/slack/models/workspace.py new file mode 100644 index 000000000..6a73a9a03 --- /dev/null +++ b/backend/apps/slack/models/workspace.py @@ -0,0 +1,21 @@ +"""Slack app workspace model.""" + +from django.db import models + +from apps.common.models import TimestampedModel + + +class Workspace(TimestampedModel): + """Slack Workspace model.""" + + class Meta: + db_table = "slack_workspaces" + verbose_name_plural = "Workspaces" + + slack_workspace_id = models.CharField(verbose_name="Workspace ID", max_length=50, unique=True) + name = models.CharField(verbose_name="Workspace Name", max_length=100, default="") + bot_token = models.CharField(verbose_name="Bot Token", max_length=200, default="") + + def __str__(self): + """Workspace human readable representation.""" + return f"{self.name or 'Unnamed'}" diff --git a/backend/poetry.lock b/backend/poetry.lock index 82b93262c..40d7f93d8 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -2501,6 +2501,107 @@ files = [ {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, ] +[[package]] +name = "rapidfuzz" +version = "3.12.1" +description = "rapid fuzzy string matching" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "rapidfuzz-3.12.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dbb7ea2fd786e6d66f225ef6eef1728832314f47e82fee877cb2a793ebda9579"}, + {file = "rapidfuzz-3.12.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1ae41361de05762c1eaa3955e5355de7c4c6f30d1ef1ea23d29bf738a35809ab"}, + {file = "rapidfuzz-3.12.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc3c39e0317e7f68ba01bac056e210dd13c7a0abf823e7b6a5fe7e451ddfc496"}, + {file = "rapidfuzz-3.12.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69f2520296f1ae1165b724a3aad28c56fd0ac7dd2e4cff101a5d986e840f02d4"}, + {file = "rapidfuzz-3.12.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34dcbf5a7daecebc242f72e2500665f0bde9dd11b779246c6d64d106a7d57c99"}, + {file = "rapidfuzz-3.12.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:773ab37fccf6e0513891f8eb4393961ddd1053c6eb7e62eaa876e94668fc6d31"}, + {file = "rapidfuzz-3.12.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ecf0e6de84c0bc2c0f48bc03ba23cef2c5f1245db7b26bc860c11c6fd7a097c"}, + {file = "rapidfuzz-3.12.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4dc2ebad4adb29d84a661f6a42494df48ad2b72993ff43fad2b9794804f91e45"}, + {file = "rapidfuzz-3.12.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:8389d98b9f54cb4f8a95f1fa34bf0ceee639e919807bb931ca479c7a5f2930bf"}, + {file = "rapidfuzz-3.12.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:165bcdecbfed9978962da1d3ec9c191b2ff9f1ccc2668fbaf0613a975b9aa326"}, + {file = "rapidfuzz-3.12.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:129d536740ab0048c1a06ccff73c683f282a2347c68069affae8dbc423a37c50"}, + {file = "rapidfuzz-3.12.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1b67e390261ffe98ec86c771b89425a78b60ccb610c3b5874660216fcdbded4b"}, + {file = "rapidfuzz-3.12.1-cp310-cp310-win32.whl", hash = "sha256:a66520180d3426b9dc2f8d312f38e19bc1fc5601f374bae5c916f53fa3534a7d"}, + {file = "rapidfuzz-3.12.1-cp310-cp310-win_amd64.whl", hash = "sha256:82260b20bc7a76556cecb0c063c87dad19246a570425d38f8107b8404ca3ac97"}, + {file = "rapidfuzz-3.12.1-cp310-cp310-win_arm64.whl", hash = "sha256:3a860d103bbb25c69c2e995fdf4fac8cb9f77fb69ec0a00469d7fd87ff148f46"}, + {file = "rapidfuzz-3.12.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6d9afad7b16d01c9e8929b6a205a18163c7e61b6cd9bcf9c81be77d5afc1067a"}, + {file = "rapidfuzz-3.12.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bb424ae7240f2d2f7d8dda66a61ebf603f74d92f109452c63b0dbf400204a437"}, + {file = "rapidfuzz-3.12.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42149e6d13bd6d06437d2a954dae2184dadbbdec0fdb82dafe92860d99f80519"}, + {file = "rapidfuzz-3.12.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:760ac95d788f2964b73da01e0bdffbe1bf2ad8273d0437565ce9092ae6ad1fbc"}, + {file = "rapidfuzz-3.12.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2cf27e8e4bf7bf9d92ef04f3d2b769e91c3f30ba99208c29f5b41e77271a2614"}, + {file = "rapidfuzz-3.12.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:00ceb8ff3c44ab0d6014106c71709c85dee9feedd6890eff77c814aa3798952b"}, + {file = "rapidfuzz-3.12.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8b61c558574fbc093d85940c3264c08c2b857b8916f8e8f222e7b86b0bb7d12"}, + {file = "rapidfuzz-3.12.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:346a2d8f17224e99f9ef988606c83d809d5917d17ad00207237e0965e54f9730"}, + {file = "rapidfuzz-3.12.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d60d1db1b7e470e71ae096b6456e20ec56b52bde6198e2dbbc5e6769fa6797dc"}, + {file = "rapidfuzz-3.12.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:2477da227e266f9c712f11393182c69a99d3c8007ea27f68c5afc3faf401cc43"}, + {file = "rapidfuzz-3.12.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:8499c7d963ddea8adb6cffac2861ee39a1053e22ca8a5ee9de1197f8dc0275a5"}, + {file = "rapidfuzz-3.12.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:12802e5c4d8ae104fb6efeeb436098325ce0dca33b461c46e8df015c84fbef26"}, + {file = "rapidfuzz-3.12.1-cp311-cp311-win32.whl", hash = "sha256:e1061311d07e7cdcffa92c9b50c2ab4192907e70ca01b2e8e1c0b6b4495faa37"}, + {file = "rapidfuzz-3.12.1-cp311-cp311-win_amd64.whl", hash = "sha256:c6e4ed63e204daa863a802eec09feea5448617981ba5d150f843ad8e3ae071a4"}, + {file = "rapidfuzz-3.12.1-cp311-cp311-win_arm64.whl", hash = "sha256:920733a28c3af47870835d59ca9879579f66238f10de91d2b4b3f809d1ebfc5b"}, + {file = "rapidfuzz-3.12.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f6235b57ae3faa3f85cb3f90c9fee49b21bd671b76e90fc99e8ca2bdf0b5e4a3"}, + {file = "rapidfuzz-3.12.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af4585e5812632c357fee5ab781c29f00cd06bea58f8882ff244cc4906ba6c9e"}, + {file = "rapidfuzz-3.12.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5942dc4460e5030c5f9e1d4c9383de2f3564a2503fe25e13e89021bcbfea2f44"}, + {file = "rapidfuzz-3.12.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b31ab59e1a0df5afc21f3109b6cfd77b34040dbf54f1bad3989f885cfae1e60"}, + {file = "rapidfuzz-3.12.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97c885a7a480b21164f57a706418c9bbc9a496ec6da087e554424358cadde445"}, + {file = "rapidfuzz-3.12.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d844c0587d969ce36fbf4b7cbf0860380ffeafc9ac5e17a7cbe8abf528d07bb"}, + {file = "rapidfuzz-3.12.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a93c95dce8917bf428064c64024de43ffd34ec5949dd4425780c72bd41f9d969"}, + {file = "rapidfuzz-3.12.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:834f6113d538af358f39296604a1953e55f8eeffc20cb4caf82250edbb8bf679"}, + {file = "rapidfuzz-3.12.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a940aa71a7f37d7f0daac186066bf6668d4d3b7e7ef464cb50bc7ba89eae1f51"}, + {file = "rapidfuzz-3.12.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ec9eaf73501c9a7de2c6938cb3050392e2ee0c5ca3921482acf01476b85a7226"}, + {file = "rapidfuzz-3.12.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3c5ec360694ac14bfaeb6aea95737cf1a6cf805b5fe8ea7fd28814706c7fa838"}, + {file = "rapidfuzz-3.12.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b5e176524653ac46f1802bdd273a4b44a5f8d0054ed5013a8e8a4b72f254599"}, + {file = "rapidfuzz-3.12.1-cp312-cp312-win32.whl", hash = "sha256:6f463c6f1c42ec90e45d12a6379e18eddd5cdf74138804d8215619b6f4d31cea"}, + {file = "rapidfuzz-3.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:b894fa2b30cd6498a29e5c470cb01c6ea898540b7e048a0342775a5000531334"}, + {file = "rapidfuzz-3.12.1-cp312-cp312-win_arm64.whl", hash = "sha256:43bb17056c5d1332f517b888c4e57846c4b5f936ed304917eeb5c9ac85d940d4"}, + {file = "rapidfuzz-3.12.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:97f824c15bc6933a31d6e3cbfa90188ba0e5043cf2b6dd342c2b90ee8b3fd47c"}, + {file = "rapidfuzz-3.12.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a973b3f5cabf931029a3ae4a0f72e3222e53d412ea85fc37ddc49e1774f00fbf"}, + {file = "rapidfuzz-3.12.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df7880e012228722dec1be02b9ef3898ed023388b8a24d6fa8213d7581932510"}, + {file = "rapidfuzz-3.12.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9c78582f50e75e6c2bc38c791ed291cb89cf26a3148c47860c1a04d6e5379c8e"}, + {file = "rapidfuzz-3.12.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2d7d9e6a04d8344b0198c96394c28874086888d0a2b2f605f30d1b27b9377b7d"}, + {file = "rapidfuzz-3.12.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5620001fd4d6644a2f56880388179cc8f3767670f0670160fcb97c3b46c828af"}, + {file = "rapidfuzz-3.12.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0666ab4c52e500af7ba5cc17389f5d15c0cdad06412c80312088519fdc25686d"}, + {file = "rapidfuzz-3.12.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:27b4d440fa50b50c515a91a01ee17e8ede719dca06eef4c0cccf1a111a4cfad3"}, + {file = "rapidfuzz-3.12.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83dccfd5a754f2a0e8555b23dde31f0f7920601bfa807aa76829391ea81e7c67"}, + {file = "rapidfuzz-3.12.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b572b634740e047c53743ed27a1bb3b4f93cf4abbac258cd7af377b2c4a9ba5b"}, + {file = "rapidfuzz-3.12.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7fa7b81fb52902d5f78dac42b3d6c835a6633b01ddf9b202a3ca8443be4b2d6a"}, + {file = "rapidfuzz-3.12.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1d4fbff980cb6baef4ee675963c081f7b5d6580a105d6a4962b20f1f880e1fb"}, + {file = "rapidfuzz-3.12.1-cp313-cp313-win32.whl", hash = "sha256:3fe8da12ea77271097b303fa7624cfaf5afd90261002314e3b0047d36f4afd8d"}, + {file = "rapidfuzz-3.12.1-cp313-cp313-win_amd64.whl", hash = "sha256:6f7e92fc7d2a7f02e1e01fe4f539324dfab80f27cb70a30dd63a95445566946b"}, + {file = "rapidfuzz-3.12.1-cp313-cp313-win_arm64.whl", hash = "sha256:e31be53d7f4905a6a038296d8b773a79da9ee9f0cd19af9490c5c5a22e37d2e5"}, + {file = "rapidfuzz-3.12.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bef5c91d5db776523530073cda5b2a276283258d2f86764be4a008c83caf7acd"}, + {file = "rapidfuzz-3.12.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:841e0c2a5fbe8fc8b9b1a56e924c871899932c0ece7fbd970aa1c32bfd12d4bf"}, + {file = "rapidfuzz-3.12.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:046fc67f3885d94693a2151dd913aaf08b10931639cbb953dfeef3151cb1027c"}, + {file = "rapidfuzz-3.12.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4d2d39b2e76c17f92edd6d384dc21fa020871c73251cdfa017149358937a41d"}, + {file = "rapidfuzz-3.12.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c5857dda85165b986c26a474b22907db6b93932c99397c818bcdec96340a76d5"}, + {file = "rapidfuzz-3.12.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c26cd1b9969ea70dbf0dbda3d2b54ab4b2e683d0fd0f17282169a19563efeb1"}, + {file = "rapidfuzz-3.12.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf56ea4edd69005786e6c80a9049d95003aeb5798803e7a2906194e7a3cb6472"}, + {file = "rapidfuzz-3.12.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fbe7580b5fb2db8ebd53819171ff671124237a55ada3f64d20fc9a149d133960"}, + {file = "rapidfuzz-3.12.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:018506a53c3b20dcbda8c93d4484b9eb1764c93d5ea16be103cf6b0d8b11d860"}, + {file = "rapidfuzz-3.12.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:325c9c71b737fcd32e2a4e634c430c07dd3d374cfe134eded3fe46e4c6f9bf5d"}, + {file = "rapidfuzz-3.12.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:930756639643e3aa02d3136b6fec74e5b9370a24f8796e1065cd8a857a6a6c50"}, + {file = "rapidfuzz-3.12.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0acbd27543b158cb915fde03877383816a9e83257832818f1e803bac9b394900"}, + {file = "rapidfuzz-3.12.1-cp39-cp39-win32.whl", hash = "sha256:80ff9283c54d7d29b2d954181e137deee89bec62f4a54675d8b6dbb6b15d3e03"}, + {file = "rapidfuzz-3.12.1-cp39-cp39-win_amd64.whl", hash = "sha256:fd37e53f0ed239d0cec27b250cec958982a8ba252ce64aa5e6052de3a82fa8db"}, + {file = "rapidfuzz-3.12.1-cp39-cp39-win_arm64.whl", hash = "sha256:4a4422e4f73a579755ab60abccb3ff148b5c224b3c7454a13ca217dfbad54da6"}, + {file = "rapidfuzz-3.12.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b7cba636c32a6fc3a402d1cb2c70c6c9f8e6319380aaf15559db09d868a23e56"}, + {file = "rapidfuzz-3.12.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b79286738a43e8df8420c4b30a92712dec6247430b130f8e015c3a78b6d61ac2"}, + {file = "rapidfuzz-3.12.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8dc1937198e7ff67e217e60bfa339f05da268d91bb15fec710452d11fe2fdf60"}, + {file = "rapidfuzz-3.12.1-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b85817a57cf8db32dd5d2d66ccfba656d299b09eaf86234295f89f91be1a0db2"}, + {file = "rapidfuzz-3.12.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:04283c6f3e79f13a784f844cd5b1df4f518ad0f70c789aea733d106c26e1b4fb"}, + {file = "rapidfuzz-3.12.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a718f740553aad5f4daef790191511da9c6eae893ee1fc2677627e4b624ae2db"}, + {file = "rapidfuzz-3.12.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cbdf145c7e4ebf2e81c794ed7a582c4acad19e886d5ad6676086369bd6760753"}, + {file = "rapidfuzz-3.12.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:0d03ad14a26a477be221fddc002954ae68a9e2402b9d85433f2d0a6af01aa2bb"}, + {file = "rapidfuzz-3.12.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1187aeae9c89e838d2a0a2b954b4052e4897e5f62e5794ef42527bf039d469e"}, + {file = "rapidfuzz-3.12.1-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd47dfb1bca9673a48b923b3d988b7668ee8efd0562027f58b0f2b7abf27144c"}, + {file = "rapidfuzz-3.12.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:187cdb402e223264eebed2fe671e367e636a499a7a9c82090b8d4b75aa416c2a"}, + {file = "rapidfuzz-3.12.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:d6899b41bf6c30282179f77096c1939f1454836440a8ab05b48ebf7026a3b590"}, + {file = "rapidfuzz-3.12.1.tar.gz", hash = "sha256:6a98bbca18b4a37adddf2d8201856441c26e9c981d8895491b5bc857b5f780eb"}, +] + +[package.extras] +all = ["numpy"] + [[package]] name = "regex" version = "2024.11.6" @@ -2813,6 +2914,21 @@ files = [ {file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"}, ] +[[package]] +name = "thefuzz" +version = "0.22.1" +description = "Fuzzy string matching in python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "thefuzz-0.22.1-py3-none-any.whl", hash = "sha256:59729b33556850b90e1093c4cf9e618af6f2e4c985df193fdf3c5b5cf02ca481"}, + {file = "thefuzz-0.22.1.tar.gz", hash = "sha256:7138039a7ecf540da323792d8592ef9902b1d79eb78c147d4f20664de79f3680"}, +] + +[package.dependencies] +rapidfuzz = ">=3.0.0,<4.0.0" + [[package]] name = "tqdm" version = "4.67.1" @@ -3125,4 +3241,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "83bfbf685725f1bf168770d9a4f146f6fc2d0c2010d3c200670be35745ade04a" +content-hash = "6861ad43e28fb126bdb524481edcd661a975587520c54ac3cf95e3157ed486ee" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index acab6f412..de1eca1d1 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -47,6 +47,8 @@ pyyaml = "^6.0.2" requests = "^2.32.3" sentry-sdk = { extras = ["django"], version = "^2.20.0" } slack-bolt = "^1.22.0" +thefuzz = "^0.22.1" +slack-sdk = "^3.35.0" [tool.poetry.group.dev.dependencies] djlint = "^1.36.4" diff --git a/cspell/custom-dict.txt b/cspell/custom-dict.txt index 1c4e462d8..ab297d56f 100644 --- a/cspell/custom-dict.txt +++ b/cspell/custom-dict.txt @@ -96,6 +96,8 @@ superfences tiktok tsc usefixtures +USLACKBOT +Whistleblower winsrdf wsgi xapp