diff --git a/backend/Makefile b/backend/Makefile index bdba5d67cf..5a7e65933c 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -40,6 +40,9 @@ github-enrich-issues: @echo "Enriching GitHub issues" @CMD="python manage.py github_enrich_issues" $(MAKE) exec-backend-command +github-match-users: + @CMD="python manage.py github_match_users $(MATCH_MODEL)" $(MAKE) exec-backend-command + github-update-owasp-organization: @echo "Updating OWASP GitHub organization" @CMD="python manage.py github_update_owasp_organization" $(MAKE) exec-backend-command @@ -141,6 +144,10 @@ setup: shell-backend: @CMD="/bin/sh" $(MAKE) exec-backend-command-it +slack-sync-data: + @echo "Syncing Slack data" + @CMD="python manage.py slack_sync_data" $(MAKE) exec-backend-command + sync-data: \ update-data \ enrich-data \ diff --git a/backend/apps/github/admin.py b/backend/apps/github/admin.py index 03734aae23..cb5560e851 100644 --- a/backend/apps/github/admin.py +++ b/backend/apps/github/admin.py @@ -172,12 +172,22 @@ class ReleaseAdmin(admin.ModelAdmin): "author", "repository", ) - search_fields = ("node_id", "repository__name") + search_fields = ( + "node_id", + "repository__name", + ) class UserAdmin(admin.ModelAdmin): - list_display = ("title", "created_at", "updated_at") - search_fields = ("login", "name") + list_display = ( + "title", + "created_at", + "updated_at", + ) + search_fields = ( + "login", + "name", + ) admin.site.register(Issue, IssueAdmin) diff --git a/backend/apps/github/management/commands/github_match_users.py b/backend/apps/github/management/commands/github_match_users.py new file mode 100644 index 0000000000..808cc05931 --- /dev/null +++ b/backend/apps/github/management/commands/github_match_users.py @@ -0,0 +1,131 @@ +"""A command to perform fuzzy and exact matching of leaders/slack members with User model.""" + +from django.core.management.base import BaseCommand +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 + +ID_MIN_LENGTH = 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", "member", "project"), + 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"), + "member": (Member, "suggested_users"), + "project": (Project, "suggested_leaders"), + } + + 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] + users = { + u["id"]: u + for u in User.objects.values("id", "login", "name") + if self._is_valid_user(u["login"], u["name"]) + } + + for instance in model_class.objects.prefetch_related(relation_field): + self.stdout.write(f"Processing {model_name} {instance.id}...") + + leaders_raw = ( + [field for field in (instance.username, instance.real_name) if field] + if model_name == "member" + else instance.leaders_raw + ) + exact_matches, fuzzy_matches, unmatched = self.process_leaders( + leaders_raw, threshold, 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) >= ID_MIN_LENGTH and len(name or "") >= ID_MIN_LENGTH + + 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() + + # 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) + + return exact_matches, fuzzy_matches, unmatched_leaders diff --git a/backend/apps/github/models/common.py b/backend/apps/github/models/common.py index 753e8f643c..3f5c82ca43 100644 --- a/backend/apps/github/models/common.py +++ b/backend/apps/github/models/common.py @@ -33,7 +33,9 @@ class Meta: @property def title(self) -> str: """Entity title.""" - return f"{self.name or self.login}" + return ( + f"{self.name} ({self.login})" if self.name and self.name != self.login else self.login + ) @property def url(self) -> str: diff --git a/backend/apps/github/models/user.py b/backend/apps/github/models/user.py index c3c8afe75d..91d0eaa92f 100644 --- a/backend/apps/github/models/user.py +++ b/backend/apps/github/models/user.py @@ -37,7 +37,7 @@ def __str__(self) -> str: str: The name or login of the user. """ - return f"{self.name or self.login}" + return self.title @property def issues(self): diff --git a/backend/apps/owasp/admin.py b/backend/apps/owasp/admin.py index adcadd6632..9f5c3f5b82 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 @@ -45,8 +45,31 @@ def custom_field_owasp_url(self, obj): custom_field_owasp_url.short_description = "OWASP 🔗" -class ChapterAdmin(admin.ModelAdmin, GenericEntityAdminMixin): - autocomplete_fields = ("owasp_repository",) +class LeaderAdminMixin: + """Admin mixin for entities that can have leaders.""" + + actions = ("approve_suggested_leaders",) + filter_horizontal = ("suggested_leaders",) + + def approve_suggested_leaders(self, request, queryset): + """Approve 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, + ) + + approve_suggested_leaders.short_description = "Approve suggested leaders" + + +class ChapterAdmin(admin.ModelAdmin, GenericEntityAdminMixin, LeaderAdminMixin): + autocomplete_fields = ( + "leaders", + "owasp_repository", + ) list_display = ( "name", "created_at", @@ -64,8 +87,11 @@ class ChapterAdmin(admin.ModelAdmin, GenericEntityAdminMixin): search_fields = ("name", "key") -class CommitteeAdmin(admin.ModelAdmin): - autocomplete_fields = ("owasp_repository",) +class CommitteeAdmin(admin.ModelAdmin, GenericEntityAdminMixin, LeaderAdminMixin): + autocomplete_fields = ( + "leaders", + "owasp_repository", + ) search_fields = ("name",) @@ -94,8 +120,9 @@ class PostAdmin(admin.ModelAdmin): ) -class ProjectAdmin(admin.ModelAdmin, GenericEntityAdminMixin): +class ProjectAdmin(admin.ModelAdmin, GenericEntityAdminMixin, LeaderAdminMixin): autocomplete_fields = ( + "leaders", "organizations", "owasp_repository", "owners", 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 0000000000..669b4f2725 --- /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/migrations/0033_merge_20250510_0037.py b/backend/apps/owasp/migrations/0033_merge_20250510_0037.py new file mode 100644 index 0000000000..01d6c1a4e6 --- /dev/null +++ b/backend/apps/owasp/migrations/0033_merge_20250510_0037.py @@ -0,0 +1,12 @@ +# Generated by Django 5.2 on 2025-05-10 00:37 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("owasp", "0031_chapter_leaders_chapter_suggested_leaders_and_more"), + ("owasp", "0032_alter_chapter_description_and_more"), + ] + + operations = [] diff --git a/backend/apps/owasp/models/common.py b/backend/apps/owasp/models/common.py index 456b16cd35..2bfbadca7d 100644 --- a/backend/apps/owasp/models/common.py +++ b/backend/apps/owasp/models/common.py @@ -75,6 +75,20 @@ class Meta: related_name="+", ) + # M2Ms. + leaders = models.ManyToManyField( + "github.User", + verbose_name="Leaders", + related_name="assigned_%(class)s", + blank=True, + ) + suggested_leaders = models.ManyToManyField( + "github.User", + verbose_name="Suggested leaders", + related_name="matched_%(class)s", + blank=True, + ) + @property def github_url(self) -> str: """Get GitHub URL.""" diff --git a/backend/apps/slack/admin.py b/backend/apps/slack/admin.py index 56fe710f28..bdc8361c34 100644 --- a/backend/apps/slack/admin.py +++ b/backend/apps/slack/admin.py @@ -1,8 +1,19 @@ """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 ChannelAdmin(admin.ModelAdmin): + list_filter = ("is_private",) + search_fields = ( + "name", + "slack_channel_id", + ) class EventAdmin(admin.ModelAdmin): @@ -16,4 +27,55 @@ class EventAdmin(admin.ModelAdmin): list_filter = ("trigger",) +class MemberAdmin(admin.ModelAdmin): + actions = ("approve_suggested_users",) + filter_horizontal = ("suggested_users",) + search_fields = ( + "slack_user_id", + "username", + "real_name", + "email", + "user", + ) + + 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 = ( + "name", + "slack_workspace_id", + ) + + +admin.site.register(Channel, ChannelAdmin) admin.site.register(Event, EventAdmin) +admin.site.register(Member, MemberAdmin) +admin.site.register(Workspace, WorkspaceAdmin) diff --git a/backend/apps/slack/management/commands/slack_sync_data.py b/backend/apps/slack/management/commands/slack_sync_data.py new file mode 100644 index 0000000000..69828e4c03 --- /dev/null +++ b/backend/apps/slack/management/commands/slack_sync_data.py @@ -0,0 +1,85 @@ +"""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.exists(): + self.stdout.write(self.style.WARNING("No workspaces found in the database")) + return + + for workspace in workspaces: + self.stdout.write(f"\nProcessing workspace: {workspace}") + + if not (bot_token := workspace.bot_token): + self.stdout.write(self.style.ERROR(f"No bot token found for {workspace}")) + continue + + client = WebClient(token=bot_token) + total_channels = 0 + total_members = 0 + + self.stdout.write(f"Fetching channels for {workspace}...") + 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"]: + # TODO(arkid15r): use bulk save. + Channel.update_data(workspace, channel) + 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}...") + try: + cursor = None + while True: + response = client.users_list(limit=1000, cursor=cursor) + self._handle_slack_response(response, "users_list") + + member_count = 0 + for member in response["members"]: + # TODO(arkid15r): use bulk save. + Member.update_data(workspace, member) + 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 0000000000..0e7eed2c44 --- /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/migrations/0005_remove_workspace_bot_token.py b/backend/apps/slack/migrations/0005_remove_workspace_bot_token.py new file mode 100644 index 0000000000..14e80ae1d1 --- /dev/null +++ b/backend/apps/slack/migrations/0005_remove_workspace_bot_token.py @@ -0,0 +1,16 @@ +# Generated by Django 5.2 on 2025-05-10 17:10 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("slack", "0004_workspace_member_channel"), + ] + + operations = [ + migrations.RemoveField( + model_name="workspace", + name="bot_token", + ), + ] diff --git a/backend/apps/slack/migrations/0006_member_is_bot_alter_member_user.py b/backend/apps/slack/migrations/0006_member_is_bot_alter_member_user.py new file mode 100644 index 0000000000..71e6dc1d15 --- /dev/null +++ b/backend/apps/slack/migrations/0006_member_is_bot_alter_member_user.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2 on 2025-05-10 17:56 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("github", "0026_alter_organization_company_and_more"), + ("slack", "0005_remove_workspace_bot_token"), + ] + + operations = [ + migrations.AddField( + model_name="member", + name="is_bot", + field=models.BooleanField(default=False, verbose_name="Is bot"), + ), + migrations.AlterField( + model_name="member", + name="user", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="slack_users", + to="github.user", + verbose_name="User", + ), + ), + ] diff --git a/backend/apps/slack/models/__init__.py b/backend/apps/slack/models/__init__.py index 1387ac9663..dfc517a754 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 0000000000..aeec8477e8 --- /dev/null +++ b/backend/apps/slack/models/channel.py @@ -0,0 +1,37 @@ +"""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.workspace}" + + @staticmethod + def update_data(workspace, channel_data) -> None: + """Update instance based on Slack data.""" + Channel.objects.update_or_create( + slack_channel_id=channel_data["id"], + workspace=workspace, + defaults={ + "is_private": channel_data["is_private"], + "member_count": channel_data.get("num_members", 0), + "name": channel_data["name"], + }, + ) diff --git a/backend/apps/slack/models/member.py b/backend/apps/slack/models/member.py new file mode 100644 index 0000000000..b2cd0c6e3e --- /dev/null +++ b/backend/apps/slack/models/member.py @@ -0,0 +1,62 @@ +"""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="") + is_bot = models.BooleanField(verbose_name="Is bot", default=False) + 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="") + + # FKs. + user = models.ForeignKey( + "github.User", + verbose_name="User", + related_name="slack_users", + blank=True, + null=True, + on_delete=models.SET_NULL, + ) + workspace = models.ForeignKey( + Workspace, + on_delete=models.CASCADE, + related_name="members", + ) + + # M2Ms. + suggested_users = models.ManyToManyField( + "github.User", + verbose_name="Github user suggestions", + related_name="suggested_slack_users", + blank=True, + ) + + def __str__(self): + """Member human readable representation.""" + return f"{self.username or 'Unnamed'} ({self.slack_user_id})" + + @staticmethod + def update_data(workspace, member_data) -> None: + """Update instance based on Slack data.""" + Member.objects.update_or_create( + slack_user_id=member_data["id"], + workspace=workspace, + defaults={ + "email": member_data["profile"].get("email", ""), + "is_bot": member_data["is_bot"], + "real_name": member_data.get("real_name", ""), + "username": member_data["name"], + }, + ) diff --git a/backend/apps/slack/models/workspace.py b/backend/apps/slack/models/workspace.py new file mode 100644 index 0000000000..c973006492 --- /dev/null +++ b/backend/apps/slack/models/workspace.py @@ -0,0 +1,32 @@ +"""Slack app workspace model.""" + +import os + +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" + + name = models.CharField(verbose_name="Workspace Name", max_length=100, default="") + slack_workspace_id = models.CharField(verbose_name="Workspace ID", max_length=50, unique=True) + + def __str__(self): + """Workspace human readable representation.""" + return f"{self.name or self.slack_workspace_id}" + + @property + def bot_token(self) -> str: + """Get bot token for the workspace. + + Returns: + str: The bot token for the workspace. + + """ + return os.getenv(f"SLACK_BOT_TOKEN_{self.slack_workspace_id.upper()}", "") diff --git a/backend/poetry.lock b/backend/poetry.lock index 2fee2e2896..5ffa648290 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -2516,6 +2516,113 @@ files = [ {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, ] +[[package]] +name = "rapidfuzz" +version = "3.13.0" +description = "rapid fuzzy string matching" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "rapidfuzz-3.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:aafc42a1dc5e1beeba52cd83baa41372228d6d8266f6d803c16dbabbcc156255"}, + {file = "rapidfuzz-3.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:85c9a131a44a95f9cac2eb6e65531db014e09d89c4f18c7b1fa54979cb9ff1f3"}, + {file = "rapidfuzz-3.13.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d7cec4242d30dd521ef91c0df872e14449d1dffc2a6990ede33943b0dae56c3"}, + {file = "rapidfuzz-3.13.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e297c09972698c95649e89121e3550cee761ca3640cd005e24aaa2619175464e"}, + {file = "rapidfuzz-3.13.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ef0f5f03f61b0e5a57b1df7beafd83df993fd5811a09871bad6038d08e526d0d"}, + {file = "rapidfuzz-3.13.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d8cf5f7cd6e4d5eb272baf6a54e182b2c237548d048e2882258336533f3f02b7"}, + {file = "rapidfuzz-3.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9256218ac8f1a957806ec2fb9a6ddfc6c32ea937c0429e88cf16362a20ed8602"}, + {file = "rapidfuzz-3.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e1bdd2e6d0c5f9706ef7595773a81ca2b40f3b33fd7f9840b726fb00c6c4eb2e"}, + {file = "rapidfuzz-3.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5280be8fd7e2bee5822e254fe0a5763aa0ad57054b85a32a3d9970e9b09bbcbf"}, + {file = "rapidfuzz-3.13.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd742c03885db1fce798a1cd87a20f47f144ccf26d75d52feb6f2bae3d57af05"}, + {file = "rapidfuzz-3.13.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5435fcac94c9ecf0504bf88a8a60c55482c32e18e108d6079a0089c47f3f8cf6"}, + {file = "rapidfuzz-3.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:93a755266856599be4ab6346273f192acde3102d7aa0735e2f48b456397a041f"}, + {file = "rapidfuzz-3.13.0-cp310-cp310-win32.whl", hash = "sha256:3abe6a4e8eb4cfc4cda04dd650a2dc6d2934cbdeda5def7e6fd1c20f6e7d2a0b"}, + {file = "rapidfuzz-3.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:e8ddb58961401da7d6f55f185512c0d6bd24f529a637078d41dd8ffa5a49c107"}, + {file = "rapidfuzz-3.13.0-cp310-cp310-win_arm64.whl", hash = "sha256:c523620d14ebd03a8d473c89e05fa1ae152821920c3ff78b839218ff69e19ca3"}, + {file = "rapidfuzz-3.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d395a5cad0c09c7f096433e5fd4224d83b53298d53499945a9b0e5a971a84f3a"}, + {file = "rapidfuzz-3.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7b3eda607a019169f7187328a8d1648fb9a90265087f6903d7ee3a8eee01805"}, + {file = "rapidfuzz-3.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98e0bfa602e1942d542de077baf15d658bd9d5dcfe9b762aff791724c1c38b70"}, + {file = "rapidfuzz-3.13.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bef86df6d59667d9655905b02770a0c776d2853971c0773767d5ef8077acd624"}, + {file = "rapidfuzz-3.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fedd316c165beed6307bf754dee54d3faca2c47e1f3bcbd67595001dfa11e969"}, + {file = "rapidfuzz-3.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5158da7f2ec02a930be13bac53bb5903527c073c90ee37804090614cab83c29e"}, + {file = "rapidfuzz-3.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b6f913ee4618ddb6d6f3e387b76e8ec2fc5efee313a128809fbd44e65c2bbb2"}, + {file = "rapidfuzz-3.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d25fdbce6459ccbbbf23b4b044f56fbd1158b97ac50994eaae2a1c0baae78301"}, + {file = "rapidfuzz-3.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:25343ccc589a4579fbde832e6a1e27258bfdd7f2eb0f28cb836d6694ab8591fc"}, + {file = "rapidfuzz-3.13.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a9ad1f37894e3ffb76bbab76256e8a8b789657183870be11aa64e306bb5228fd"}, + {file = "rapidfuzz-3.13.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5dc71ef23845bb6b62d194c39a97bb30ff171389c9812d83030c1199f319098c"}, + {file = "rapidfuzz-3.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b7f4c65facdb94f44be759bbd9b6dda1fa54d0d6169cdf1a209a5ab97d311a75"}, + {file = "rapidfuzz-3.13.0-cp311-cp311-win32.whl", hash = "sha256:b5104b62711565e0ff6deab2a8f5dbf1fbe333c5155abe26d2cfd6f1849b6c87"}, + {file = "rapidfuzz-3.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:9093cdeb926deb32a4887ebe6910f57fbcdbc9fbfa52252c10b56ef2efb0289f"}, + {file = "rapidfuzz-3.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:f70f646751b6aa9d05be1fb40372f006cc89d6aad54e9d79ae97bd1f5fce5203"}, + {file = "rapidfuzz-3.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a1a6a906ba62f2556372282b1ef37b26bca67e3d2ea957277cfcefc6275cca7"}, + {file = "rapidfuzz-3.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2fd0975e015b05c79a97f38883a11236f5a24cca83aa992bd2558ceaa5652b26"}, + {file = "rapidfuzz-3.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d4e13593d298c50c4f94ce453f757b4b398af3fa0fd2fde693c3e51195b7f69"}, + {file = "rapidfuzz-3.13.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed6f416bda1c9133000009d84d9409823eb2358df0950231cc936e4bf784eb97"}, + {file = "rapidfuzz-3.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1dc82b6ed01acb536b94a43996a94471a218f4d89f3fdd9185ab496de4b2a981"}, + {file = "rapidfuzz-3.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9d824de871daa6e443b39ff495a884931970d567eb0dfa213d234337343835f"}, + {file = "rapidfuzz-3.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d18228a2390375cf45726ce1af9d36ff3dc1f11dce9775eae1f1b13ac6ec50f"}, + {file = "rapidfuzz-3.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5fe634c9482ec5d4a6692afb8c45d370ae86755e5f57aa6c50bfe4ca2bdd87"}, + {file = "rapidfuzz-3.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:694eb531889f71022b2be86f625a4209c4049e74be9ca836919b9e395d5e33b3"}, + {file = "rapidfuzz-3.13.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11b47b40650e06147dee5e51a9c9ad73bb7b86968b6f7d30e503b9f8dd1292db"}, + {file = "rapidfuzz-3.13.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:98b8107ff14f5af0243f27d236bcc6e1ef8e7e3b3c25df114e91e3a99572da73"}, + {file = "rapidfuzz-3.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b836f486dba0aceb2551e838ff3f514a38ee72b015364f739e526d720fdb823a"}, + {file = "rapidfuzz-3.13.0-cp312-cp312-win32.whl", hash = "sha256:4671ee300d1818d7bdfd8fa0608580d7778ba701817216f0c17fb29e6b972514"}, + {file = "rapidfuzz-3.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e2065f68fb1d0bf65adc289c1bdc45ba7e464e406b319d67bb54441a1b9da9e"}, + {file = "rapidfuzz-3.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:65cc97c2fc2c2fe23586599686f3b1ceeedeca8e598cfcc1b7e56dc8ca7e2aa7"}, + {file = "rapidfuzz-3.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:09e908064d3684c541d312bd4c7b05acb99a2c764f6231bd507d4b4b65226c23"}, + {file = "rapidfuzz-3.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:57c390336cb50d5d3bfb0cfe1467478a15733703af61f6dffb14b1cd312a6fae"}, + {file = "rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0da54aa8547b3c2c188db3d1c7eb4d1bb6dd80baa8cdaeaec3d1da3346ec9caa"}, + {file = "rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df8e8c21e67afb9d7fbe18f42c6111fe155e801ab103c81109a61312927cc611"}, + {file = "rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:461fd13250a2adf8e90ca9a0e1e166515cbcaa5e9c3b1f37545cbbeff9e77f6b"}, + {file = "rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2b3dd5d206a12deca16870acc0d6e5036abeb70e3cad6549c294eff15591527"}, + {file = "rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1343d745fbf4688e412d8f398c6e6d6f269db99a54456873f232ba2e7aeb4939"}, + {file = "rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b1b065f370d54551dcc785c6f9eeb5bd517ae14c983d2784c064b3aa525896df"}, + {file = "rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:11b125d8edd67e767b2295eac6eb9afe0b1cdc82ea3d4b9257da4b8e06077798"}, + {file = "rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c33f9c841630b2bb7e69a3fb5c84a854075bb812c47620978bddc591f764da3d"}, + {file = "rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ae4574cb66cf1e85d32bb7e9ec45af5409c5b3970b7ceb8dea90168024127566"}, + {file = "rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e05752418b24bbd411841b256344c26f57da1148c5509e34ea39c7eb5099ab72"}, + {file = "rapidfuzz-3.13.0-cp313-cp313-win32.whl", hash = "sha256:0e1d08cb884805a543f2de1f6744069495ef527e279e05370dd7c83416af83f8"}, + {file = "rapidfuzz-3.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9a7c6232be5f809cd39da30ee5d24e6cadd919831e6020ec6c2391f4c3bc9264"}, + {file = "rapidfuzz-3.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:3f32f15bacd1838c929b35c84b43618481e1b3d7a61b5ed2db0291b70ae88b53"}, + {file = "rapidfuzz-3.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cc64da907114d7a18b5e589057e3acaf2fec723d31c49e13fedf043592a3f6a7"}, + {file = "rapidfuzz-3.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4d9d7f84c8e992a8dbe5a3fdbea73d733da39bf464e62c912ac3ceba9c0cff93"}, + {file = "rapidfuzz-3.13.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a79a2f07786a2070669b4b8e45bd96a01c788e7a3c218f531f3947878e0f956"}, + {file = "rapidfuzz-3.13.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9f338e71c45b69a482de8b11bf4a029993230760120c8c6e7c9b71760b6825a1"}, + {file = "rapidfuzz-3.13.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adb40ca8ddfcd4edd07b0713a860be32bdf632687f656963bcbce84cea04b8d8"}, + {file = "rapidfuzz-3.13.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48719f7dcf62dfb181063b60ee2d0a39d327fa8ad81b05e3e510680c44e1c078"}, + {file = "rapidfuzz-3.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9327a4577f65fc3fb712e79f78233815b8a1c94433d0c2c9f6bc5953018b3565"}, + {file = "rapidfuzz-3.13.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:200030dfc0a1d5d6ac18e993c5097c870c97c41574e67f227300a1fb74457b1d"}, + {file = "rapidfuzz-3.13.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cc269e74cad6043cb8a46d0ce580031ab642b5930562c2bb79aa7fbf9c858d26"}, + {file = "rapidfuzz-3.13.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:e62779c6371bd2b21dbd1fdce89eaec2d93fd98179d36f61130b489f62294a92"}, + {file = "rapidfuzz-3.13.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f4797f821dc5d7c2b6fc818b89f8a3f37bcc900dd9e4369e6ebf1e525efce5db"}, + {file = "rapidfuzz-3.13.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:d21f188f6fe4fbf422e647ae9d5a68671d00218e187f91859c963d0738ccd88c"}, + {file = "rapidfuzz-3.13.0-cp39-cp39-win32.whl", hash = "sha256:45dd4628dd9c21acc5c97627dad0bb791764feea81436fb6e0a06eef4c6dceaa"}, + {file = "rapidfuzz-3.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:624a108122039af89ddda1a2b7ab2a11abe60c1521956f142f5d11bcd42ef138"}, + {file = "rapidfuzz-3.13.0-cp39-cp39-win_arm64.whl", hash = "sha256:435071fd07a085ecbf4d28702a66fd2e676a03369ee497cc38bcb69a46bc77e2"}, + {file = "rapidfuzz-3.13.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fe5790a36d33a5d0a6a1f802aa42ecae282bf29ac6f7506d8e12510847b82a45"}, + {file = "rapidfuzz-3.13.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:cdb33ee9f8a8e4742c6b268fa6bd739024f34651a06b26913381b1413ebe7590"}, + {file = "rapidfuzz-3.13.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c99b76b93f7b495eee7dcb0d6a38fb3ce91e72e99d9f78faa5664a881cb2b7d"}, + {file = "rapidfuzz-3.13.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6af42f2ede8b596a6aaf6d49fdee3066ca578f4856b85ab5c1e2145de367a12d"}, + {file = "rapidfuzz-3.13.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c0efa73afbc5b265aca0d8a467ae2a3f40d6854cbe1481cb442a62b7bf23c99"}, + {file = "rapidfuzz-3.13.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7ac21489de962a4e2fc1e8f0b0da4aa1adc6ab9512fd845563fecb4b4c52093a"}, + {file = "rapidfuzz-3.13.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1ba007f4d35a45ee68656b2eb83b8715e11d0f90e5b9f02d615a8a321ff00c27"}, + {file = "rapidfuzz-3.13.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d7a217310429b43be95b3b8ad7f8fc41aba341109dc91e978cd7c703f928c58f"}, + {file = "rapidfuzz-3.13.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:558bf526bcd777de32b7885790a95a9548ffdcce68f704a81207be4a286c1095"}, + {file = "rapidfuzz-3.13.0-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:202a87760f5145140d56153b193a797ae9338f7939eb16652dd7ff96f8faf64c"}, + {file = "rapidfuzz-3.13.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cfcccc08f671646ccb1e413c773bb92e7bba789e3a1796fd49d23c12539fe2e4"}, + {file = "rapidfuzz-3.13.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:1f219f1e3c3194d7a7de222f54450ce12bc907862ff9a8962d83061c1f923c86"}, + {file = "rapidfuzz-3.13.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ccbd0e7ea1a216315f63ffdc7cd09c55f57851afc8fe59a74184cb7316c0598b"}, + {file = "rapidfuzz-3.13.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:a50856f49a4016ef56edd10caabdaf3608993f9faf1e05c3c7f4beeac46bd12a"}, + {file = "rapidfuzz-3.13.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fd05336db4d0b8348d7eaaf6fa3c517b11a56abaa5e89470ce1714e73e4aca7"}, + {file = "rapidfuzz-3.13.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:573ad267eb9b3f6e9b04febce5de55d8538a87c56c64bf8fd2599a48dc9d8b77"}, + {file = "rapidfuzz-3.13.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30fd1451f87ccb6c2f9d18f6caa483116bbb57b5a55d04d3ddbd7b86f5b14998"}, + {file = "rapidfuzz-3.13.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6dd36d4916cf57ddb05286ed40b09d034ca5d4bca85c17be0cb6a21290597d9"}, + {file = "rapidfuzz-3.13.0.tar.gz", hash = "sha256:d2eaf3839e52cbcc0accbe9817a67b4b0fcf70aaeb229cfddc1c28061f9ce5d8"}, +] + +[package.extras] +all = ["numpy"] + [[package]] name = "redis" version = "6.0.0" @@ -2845,6 +2952,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" @@ -3157,4 +3279,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "8dc9e85a835bc82849fe37e2adf4fa037d1be36fcbbc2208cc105386aa4b249e" +content-hash = "9a54d98d8e2f8566c86399925a59b16e8dfa1953e4f7799e05aeaae6e3b6ccc7" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index cd0e91f1e6..ab3343c1a5 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -48,6 +48,8 @@ pyyaml = "^6.0.2" requests = "^2.32.3" sentry-sdk = { extras = ["django"], version = "^2.20.0" } slack-bolt = "^1.22.0" +slack-sdk = "^3.35.0" +thefuzz = "^0.22.1" [tool.poetry.group.dev.dependencies] djlint = "^1.36.4" diff --git a/backend/tests/apps/github/models/issue_test.py b/backend/tests/apps/github/models/issue_test.py index 918741b011..fa082e1983 100644 --- a/backend/tests/apps/github/models/issue_test.py +++ b/backend/tests/apps/github/models/issue_test.py @@ -11,7 +11,7 @@ class TestIssueModel: def test_str(self): author = User(name="Author", login="author") issue = Issue(title="Test Issue", author=author) - assert str(issue) == "Test Issue by Author" + assert str(issue) == "Test Issue by Author (author)" def test_open_issues_count(self): with patch("apps.github.models.issue.IndexBase.get_total_count") as mock_get_total_count: diff --git a/backend/tests/apps/github/models/user_test.py b/backend/tests/apps/github/models/user_test.py index d31f51bffa..d13a84dcc3 100644 --- a/backend/tests/apps/github/models/user_test.py +++ b/backend/tests/apps/github/models/user_test.py @@ -9,7 +9,7 @@ class TestUserModel: @pytest.mark.parametrize( ("name", "login", "expected_str"), [ - ("John Doe", "john-doe", "John Doe"), + ("John Doe", "john-doe", "John Doe (john-doe)"), ("", "jane-doe", "jane-doe"), (None, "ghost", "ghost"), ],