From c7d77666672208265f6d33481ccf37f8964cc6d8 Mon Sep 17 00:00:00 2001 From: Rajgupta36 Date: Sun, 23 Feb 2025 03:17:29 +0530 Subject: [PATCH 1/7] implemented matching leader model --- .../management/commands/matching_users.py | 105 +++++++++++ backend/apps/owasp/admin.py | 34 +++- ...ders_chapter_suggested_leaders_and_more.py | 44 +++++ backend/apps/owasp/models/common.py | 11 ++ backend/poetry.lock | 178 +++++++++++++++--- backend/pyproject.toml | 1 + 6 files changed, 335 insertions(+), 38 deletions(-) create mode 100644 backend/apps/common/management/commands/matching_users.py create mode 100644 backend/apps/owasp/migrations/0015_chapter_leaders_chapter_suggested_leaders_and_more.py 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..1cf40e43a --- /dev/null +++ b/backend/apps/common/management/commands/matching_users.py @@ -0,0 +1,105 @@ +"""A command to perform fuzzy and exact matching of leaders with GitHub users models.""" + +from django.core.management.base import BaseCommand +from django.db import models +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 + +MIN_NO_OF_WORDS = 2 + + +class Command(BaseCommand): + help = "Process raw leaders for multiple models and suggest leaders." + + def add_arguments(self, parser): + parser.add_argument( + "model_name", + type=str, + help="Model name to process leaders for (e.g., Chapter, Committee, Project)", + ) + parser.add_argument( + "--threshold", type=int, default=95, help="Threshold for fuzzy matching" + ) + + def handle(self, *args, **kwargs): + model_name = kwargs["model_name"] + threshold = kwargs["threshold"] + + model_map = { + "chapter": Chapter, + "committee": Committee, + "project": Project, + } + + model_class = model_map.get(model_name.lower()) + + if not model_class: + self.stdout.write( + self.style.ERROR("Invalid model name! Choose from: chapter, committee, project") + ) + return + + all_users = User.objects.all() + filtered_users = [ + u + for u in all_users + if len(u.login) >= MIN_NO_OF_WORDS and (u.name and len(u.name) >= MIN_NO_OF_WORDS) + ] + + instances = model_class.objects.all() + for instance in instances: + self.stdout.write(f"Processing leaders for {model_name.capitalize()} {instance.id}...") + exact_matches, fuzzy_matches, unmatched_leaders = self.process_leaders( + instance.leaders_raw, threshold, filtered_users + ) + instance.suggested_leaders.set(list(set(exact_matches + fuzzy_matches))) + instance.save() + + if unmatched_leaders: + self.stdout.write(f"Unmatched leaders for {instance.name}: {unmatched_leaders}") + + def process_leaders(self, leaders_raw, threshold, filtered_users): + """Process leaders and return the suggested leaders with exact and fuzzy matching.""" + if not leaders_raw: + return [], [], [] + + exact_matches = [] + fuzzy_matches = [] + unmatched_leaders = [] + + for leader in leaders_raw: + try: + leaderdata = User.objects.filter( + models.Q(login__iexact=leader) | models.Q(name__iexact=leader) + ).first() + if leaderdata: + exact_matches.append(leaderdata) + self.stdout.write(f"Exact match found for {leader}: {leaderdata}") + continue + + matches = [ + u + for u in filtered_users + if (fuzz.partial_ratio(leader, u.login) >= threshold) + or (fuzz.partial_ratio(leader, u.name if u.name else "") >= threshold) + ] + + new_fuzzy_matches = [m for m in matches if m not in exact_matches] + fuzzy_matches.extend(new_fuzzy_matches) + + if matches: + for match in new_fuzzy_matches: + self.stdout.write(f"Fuzzy match found for {leader}: {match}") + 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 c25e0a20c..5624c1013 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 @@ -29,12 +29,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 ChapterAdmin(admin.ModelAdmin, GenericEntityAdminMixin): - autocomplete_fields = ("owasp_repository",) +class LeaderEntityAdmin(admin.ModelAdmin, GenericEntityAdminMixin): + """Admin class for entities that have leaders.""" + + actions = ["approve_suggested_leaders"] + filter_horizontal = ("suggested_leaders",) + + +class ChapterAdmin(LeaderEntityAdmin): + autocomplete_fields = ("owasp_repository", "leaders") list_display = ( "name", "region", @@ -49,23 +68,24 @@ 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",) -class EventAdmin(admin.ModelAdmin): +class EventAdmin(admin.ModelAdmin, GenericEntityAdminMixin): autocomplete_fields = ("owasp_repository",) list_display = ("name",) search_fields = ("name",) -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/0015_chapter_leaders_chapter_suggested_leaders_and_more.py b/backend/apps/owasp/migrations/0015_chapter_leaders_chapter_suggested_leaders_and_more.py new file mode 100644 index 000000000..2c97359ee --- /dev/null +++ b/backend/apps/owasp/migrations/0015_chapter_leaders_chapter_suggested_leaders_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 5.1.6 on 2025-02-22 21:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('github', '0015_alter_release_author'), + ('owasp', '0014_project_custom_tags'), + ] + + operations = [ + migrations.AddField( + model_name='chapter', + name='leaders', + field=models.ManyToManyField(blank=True, related_name='normal_%(class)s', to='github.user', verbose_name='Leaders'), + ), + migrations.AddField( + model_name='chapter', + name='suggested_leaders', + field=models.ManyToManyField(blank=True, related_name='exact_matched_%(class)s', to='github.user', verbose_name='Exact Match Users'), + ), + migrations.AddField( + model_name='committee', + name='leaders', + field=models.ManyToManyField(blank=True, related_name='normal_%(class)s', to='github.user', verbose_name='Leaders'), + ), + migrations.AddField( + model_name='committee', + name='suggested_leaders', + field=models.ManyToManyField(blank=True, related_name='exact_matched_%(class)s', to='github.user', verbose_name='Exact Match Users'), + ), + migrations.AddField( + model_name='project', + name='leaders', + field=models.ManyToManyField(blank=True, related_name='normal_%(class)s', to='github.user', verbose_name='Leaders'), + ), + migrations.AddField( + model_name='project', + name='suggested_leaders', + field=models.ManyToManyField(blank=True, related_name='exact_matched_%(class)s', to='github.user', verbose_name='Exact Match Users'), + ), + ] diff --git a/backend/apps/owasp/models/common.py b/backend/apps/owasp/models/common.py index 717725f4f..0e3640b06 100644 --- a/backend/apps/owasp/models/common.py +++ b/backend/apps/owasp/models/common.py @@ -164,6 +164,17 @@ class Meta: verbose_name="Entity invalid related URLs", default=list, blank=True, null=True ) + # M2M + suggested_leaders = models.ManyToManyField( + "github.User", + verbose_name="Exact Match Users", + related_name="exact_matched_%(class)s", + blank=True, + ) + leaders = models.ManyToManyField( + "github.User", verbose_name="Leaders", related_name="normal_%(class)s", blank=True + ) + def get_related_url(self, url, exclude_domains=(), include_domains=()): """Get OWASP entity related URL.""" if ( diff --git a/backend/poetry.lock b/backend/poetry.lock index d4a1746a4..13ab49a8f 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.0.1 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" @@ -113,7 +113,7 @@ propcache = ">=0.2.0" yarl = ">=1.17.0,<2.0" [package.extras] -speedups = ["Brotli", "aiodns (>=3.2.0)", "brotlicffi"] +speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns (>=3.2.0) ; sys_platform == \"linux\" or sys_platform == \"darwin\"", "brotlicffi ; platform_python_implementation != \"CPython\""] [[package]] name = "aiosignal" @@ -195,7 +195,7 @@ sniffio = ">=1.1" [package.extras] doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21)"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] trio = ["trio (>=0.26.1)"] [[package]] @@ -238,12 +238,12 @@ files = [ ] [package.extras] -benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] +tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] [[package]] name = "boto3" @@ -593,7 +593,7 @@ files = [ ] [package.extras] -toml = ["tomli"] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cryptography" @@ -640,10 +640,10 @@ files = [ cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} [package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0)"] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0) ; python_version >= \"3.8\""] docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] -nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2)"] -pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] +nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_version >= \"3.8\""] +pep8test = ["check-sdist ; python_version >= \"3.8\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] sdist = ["build (>=1.0.0)"] ssh = ["bcrypt (>=3.1.5)"] test = ["certifi (>=2024)", "cryptography-vectors (==44.0.1)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] @@ -682,7 +682,7 @@ files = [ wrapt = ">=1.10,<2" [package.extras] -dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "setuptools", "tox"] +dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "setuptools ; python_version >= \"3.12\"", "tox"] [[package]] name = "distlib" @@ -907,7 +907,7 @@ files = [ [package.extras] docs = ["furo (>=2024.8.6)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] testing = ["covdefaults (>=2.3)", "coverage (>=7.6.10)", "diff-cover (>=9.2.1)", "pytest (>=8.3.4)", "pytest-asyncio (>=0.25.2)", "pytest-cov (>=6)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.28.1)"] -typing = ["typing-extensions (>=4.12.2)"] +typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""] [[package]] name = "frozenlist" @@ -1196,7 +1196,7 @@ httpcore = "==1.*" idna = "*" [package.extras] -brotli = ["brotli", "brotlicffi"] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] @@ -1219,14 +1219,14 @@ tests = ["freezegun", "pytest", "pytest-cov"] [[package]] name = "identify" -version = "2.6.7" +version = "2.6.8" description = "File identification library for Python" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "identify-2.6.7-py2.py3-none-any.whl", hash = "sha256:155931cb617a401807b09ecec6635d6c692d180090a1cedca8ef7d58ba5b6aa0"}, - {file = "identify-2.6.7.tar.gz", hash = "sha256:3fa266b42eba321ee0b2bb0936a6a6b9e36a1351cbb69055b3082f4193035684"}, + {file = "identify-2.6.8-py2.py3-none-any.whl", hash = "sha256:83657f0f766a3c8d0eaea16d4ef42494b39b34629a4b3192a9d020d349b3e255"}, + {file = "identify-2.6.8.tar.gz", hash = "sha256:61491417ea2c0c5c670484fd8abbb34de34cdae1e5f39a73ee65e48e4bb663fc"}, ] [package.extras] @@ -1675,14 +1675,14 @@ files = [ [[package]] name = "openai" -version = "1.63.2" +version = "1.64.0" description = "The official Python library for the openai API" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "openai-1.63.2-py3-none-any.whl", hash = "sha256:1f38b27b5a40814c2b7d8759ec78110df58c4a614c25f182809ca52b080ff4d4"}, - {file = "openai-1.63.2.tar.gz", hash = "sha256:aeabeec984a7d2957b4928ceaa339e2ead19c61cfcf35ae62b7c363368d26360"}, + {file = "openai-1.64.0-py3-none-any.whl", hash = "sha256:20f85cde9e95e9fbb416e3cb5a6d3119c0b28308afd6e3cc47bf100623dac623"}, + {file = "openai-1.64.0.tar.gz", hash = "sha256:2861053538704d61340da56e2f176853d19f1dc5704bc306b7597155f850d57a"}, ] [package.dependencies] @@ -1902,23 +1902,23 @@ files = [ [[package]] name = "psycopg" -version = "3.2.4" +version = "3.2.5" description = "PostgreSQL database adapter for Python" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "psycopg-3.2.4-py3-none-any.whl", hash = "sha256:43665368ccd48180744cab26b74332f46b63b7e06e8ce0775547a3533883d381"}, - {file = "psycopg-3.2.4.tar.gz", hash = "sha256:f26f1346d6bf1ef5f5ef1714dd405c67fb365cfd1c6cea07de1792747b167b92"}, + {file = "psycopg-3.2.5-py3-none-any.whl", hash = "sha256:b782130983e5b3de30b4c529623d3687033b4dafa05bb661fc6bf45837ca5879"}, + {file = "psycopg-3.2.5.tar.gz", hash = "sha256:f5f750611c67cb200e85b408882f29265c66d1de7f813add4f8125978bfd70e8"}, ] [package.dependencies] tzdata = {version = "*", markers = "sys_platform == \"win32\""} [package.extras] -binary = ["psycopg-binary (==3.2.4)"] -c = ["psycopg-c (==3.2.4)"] -dev = ["ast-comments (>=1.1.2)", "black (>=24.1.0)", "codespell (>=2.2)", "dnspython (>=2.1)", "flake8 (>=4.0)", "mypy (>=1.14)", "pre-commit (>=4.0.1)", "types-setuptools (>=57.4)", "wheel (>=0.37)"] +binary = ["psycopg-binary (==3.2.5) ; implementation_name != \"pypy\""] +c = ["psycopg-c (==3.2.5) ; implementation_name != \"pypy\""] +dev = ["ast-comments (>=1.1.2)", "black (>=24.1.0)", "codespell (>=2.2)", "dnspython (>=2.1)", "flake8 (>=4.0)", "isort-psycopg", "isort[colors] (>=6.0)", "mypy (>=1.14)", "pre-commit (>=4.0.1)", "types-setuptools (>=57.4)", "wheel (>=0.37)"] docs = ["Sphinx (>=5.0)", "furo (==2022.6.21)", "sphinx-autobuild (>=2021.3.14)", "sphinx-autodoc-typehints (>=1.12)"] pool = ["psycopg-pool"] test = ["anyio (>=4.0)", "mypy (>=1.14)", "pproxy (>=2.7)", "pytest (>=6.2.5)", "pytest-cov (>=3.0)", "pytest-randomly (>=3.5)"] @@ -1954,7 +1954,7 @@ typing-extensions = ">=4.12.2" [package.extras] email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] [[package]] name = "pydantic-core" @@ -2328,6 +2328,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" @@ -2640,6 +2741,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" @@ -2700,7 +2816,7 @@ files = [ ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] @@ -2724,7 +2840,7 @@ platformdirs = ">=3.9.1,<5" [package.extras] docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] [[package]] name = "wrapt" @@ -2915,4 +3031,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "826daea6bad6281b7716210d47a83bcf408a6e0ab48e97acc71207712e596fb8" +content-hash = "28ec7a93b43dffb09b3b7bc7bc144b9dea164c7f202fa89249a22b454dcde655" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 493882762..42833a5fa 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -43,6 +43,7 @@ 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" [tool.poetry.group.dev.dependencies] From 8d35a27a9454f21fb3959774f64d88741e6f6275 Mon Sep 17 00:00:00 2001 From: Rajgupta36 Date: Sun, 23 Feb 2025 03:22:54 +0530 Subject: [PATCH 2/7] pre-commit --- ...ders_chapter_suggested_leaders_and_more.py | 71 +++++++++++++------ 1 file changed, 50 insertions(+), 21 deletions(-) diff --git a/backend/apps/owasp/migrations/0015_chapter_leaders_chapter_suggested_leaders_and_more.py b/backend/apps/owasp/migrations/0015_chapter_leaders_chapter_suggested_leaders_and_more.py index 2c97359ee..acd5c0a35 100644 --- a/backend/apps/owasp/migrations/0015_chapter_leaders_chapter_suggested_leaders_and_more.py +++ b/backend/apps/owasp/migrations/0015_chapter_leaders_chapter_suggested_leaders_and_more.py @@ -4,41 +4,70 @@ class Migration(migrations.Migration): - dependencies = [ - ('github', '0015_alter_release_author'), - ('owasp', '0014_project_custom_tags'), + ("github", "0015_alter_release_author"), + ("owasp", "0014_project_custom_tags"), ] operations = [ migrations.AddField( - model_name='chapter', - name='leaders', - field=models.ManyToManyField(blank=True, related_name='normal_%(class)s', to='github.user', verbose_name='Leaders'), + model_name="chapter", + name="leaders", + field=models.ManyToManyField( + blank=True, + related_name="normal_%(class)s", + to="github.user", + verbose_name="Leaders", + ), ), migrations.AddField( - model_name='chapter', - name='suggested_leaders', - field=models.ManyToManyField(blank=True, related_name='exact_matched_%(class)s', to='github.user', verbose_name='Exact Match Users'), + model_name="chapter", + name="suggested_leaders", + field=models.ManyToManyField( + blank=True, + related_name="exact_matched_%(class)s", + to="github.user", + verbose_name="Exact Match Users", + ), ), migrations.AddField( - model_name='committee', - name='leaders', - field=models.ManyToManyField(blank=True, related_name='normal_%(class)s', to='github.user', verbose_name='Leaders'), + model_name="committee", + name="leaders", + field=models.ManyToManyField( + blank=True, + related_name="normal_%(class)s", + to="github.user", + verbose_name="Leaders", + ), ), migrations.AddField( - model_name='committee', - name='suggested_leaders', - field=models.ManyToManyField(blank=True, related_name='exact_matched_%(class)s', to='github.user', verbose_name='Exact Match Users'), + model_name="committee", + name="suggested_leaders", + field=models.ManyToManyField( + blank=True, + related_name="exact_matched_%(class)s", + to="github.user", + verbose_name="Exact Match Users", + ), ), migrations.AddField( - model_name='project', - name='leaders', - field=models.ManyToManyField(blank=True, related_name='normal_%(class)s', to='github.user', verbose_name='Leaders'), + model_name="project", + name="leaders", + field=models.ManyToManyField( + blank=True, + related_name="normal_%(class)s", + to="github.user", + verbose_name="Leaders", + ), ), migrations.AddField( - model_name='project', - name='suggested_leaders', - field=models.ManyToManyField(blank=True, related_name='exact_matched_%(class)s', to='github.user', verbose_name='Exact Match Users'), + model_name="project", + name="suggested_leaders", + field=models.ManyToManyField( + blank=True, + related_name="exact_matched_%(class)s", + to="github.user", + verbose_name="Exact Match Users", + ), ), ] From 4d1a91b14b4636eb86b0a727dd53a1fdf1a8e6d8 Mon Sep 17 00:00:00 2001 From: Rajgupta36 Date: Sun, 23 Feb 2025 03:33:32 +0530 Subject: [PATCH 3/7] spell-fix --- backend/apps/common/management/commands/matching_users.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/apps/common/management/commands/matching_users.py b/backend/apps/common/management/commands/matching_users.py index 1cf40e43a..c2549bc0f 100644 --- a/backend/apps/common/management/commands/matching_users.py +++ b/backend/apps/common/management/commands/matching_users.py @@ -74,12 +74,12 @@ def process_leaders(self, leaders_raw, threshold, filtered_users): for leader in leaders_raw: try: - leaderdata = User.objects.filter( + leaders_data = User.objects.filter( models.Q(login__iexact=leader) | models.Q(name__iexact=leader) ).first() - if leaderdata: - exact_matches.append(leaderdata) - self.stdout.write(f"Exact match found for {leader}: {leaderdata}") + if leaders_data: + exact_matches.append(leaders_data) + self.stdout.write(f"Exact match found for {leader}: {leaders_data}") continue matches = [ From 15f611275ec9d45297787d5a2f3722e8807b59df Mon Sep 17 00:00:00 2001 From: Rajgupta36 Date: Fri, 21 Mar 2025 02:25:31 +0530 Subject: [PATCH 4/7] fixes --- backend/apps/owasp/admin.py | 1 + .../owasp/migrations/0031_merge_20250320_2001.py | 12 ++++++++++++ backend/poetry.lock | 2 +- 3 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 backend/apps/owasp/migrations/0031_merge_20250320_2001.py diff --git a/backend/apps/owasp/admin.py b/backend/apps/owasp/admin.py index 383e44ea9..23b79da89 100644 --- a/backend/apps/owasp/admin.py +++ b/backend/apps/owasp/admin.py @@ -84,6 +84,7 @@ class CommitteeAdmin(LeaderEntityAdmin): autocomplete_fields = ("owasp_repository", "leaders") search_fields = ("name",) + class EventAdmin(admin.ModelAdmin): list_display = ( "name", diff --git a/backend/apps/owasp/migrations/0031_merge_20250320_2001.py b/backend/apps/owasp/migrations/0031_merge_20250320_2001.py new file mode 100644 index 000000000..bb908d27a --- /dev/null +++ b/backend/apps/owasp/migrations/0031_merge_20250320_2001.py @@ -0,0 +1,12 @@ +# Generated by Django 5.1.7 on 2025-03-20 20:01 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("owasp", "0015_chapter_leaders_chapter_suggested_leaders_and_more"), + ("owasp", "0030_chapter_is_leaders_policy_compliant_and_more"), + ] + + operations = [] diff --git a/backend/poetry.lock b/backend/poetry.lock index c16a0e9d8..45312058e 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -3035,4 +3035,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "defcb17629ba4de68195b11720e61b7a084f226cf1a4e4005d186010dea5012b" +content-hash = "b8629d1dba4d9407fe87d36ee26d9185d384e93939037870dfa0192269bbbbe3" From 635cf27b2f92d3e7d9850e8d3dbad44046378e23 Mon Sep 17 00:00:00 2001 From: Rajgupta36 Date: Sat, 22 Mar 2025 01:16:22 +0530 Subject: [PATCH 5/7] improve speed --- backend/Makefile | 3 + .../management/commands/matching_users.py | 90 ++++++++++++------- backend/apps/owasp/admin.py | 2 +- 3 files changed, 61 insertions(+), 34 deletions(-) diff --git a/backend/Makefile b/backend/Makefile index d92494250..71e4ce687 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -13,6 +13,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 diff --git a/backend/apps/common/management/commands/matching_users.py b/backend/apps/common/management/commands/matching_users.py index c2549bc0f..b2ebfda9c 100644 --- a/backend/apps/common/management/commands/matching_users.py +++ b/backend/apps/common/management/commands/matching_users.py @@ -1,7 +1,6 @@ """A command to perform fuzzy and exact matching of leaders with GitHub users models.""" from django.core.management.base import BaseCommand -from django.db import models from django.db.utils import DatabaseError from thefuzz import fuzz @@ -20,15 +19,19 @@ def add_arguments(self, parser): parser.add_argument( "model_name", type=str, - help="Model name to process leaders for (e.g., Chapter, Committee, Project)", + choices=["chapter", "committee", "project"], + help="Model name to process leaders for (chapter, committee, project)", ) parser.add_argument( - "--threshold", type=int, default=95, help="Threshold for fuzzy matching" + "--threshold", + type=int, + default=85, + help="Threshold for fuzzy matching (0-100)", ) def handle(self, *args, **kwargs): - model_name = kwargs["model_name"] - threshold = kwargs["threshold"] + model_name = kwargs["model_name"].lower() + threshold = max(0, min(kwargs["threshold"], 100)) model_map = { "chapter": Chapter, @@ -36,65 +39,86 @@ def handle(self, *args, **kwargs): "project": Project, } - model_class = model_map.get(model_name.lower()) - + model_class = model_map.get(model_name) if not model_class: self.stdout.write( self.style.ERROR("Invalid model name! Choose from: chapter, committee, project") ) return - all_users = User.objects.all() - filtered_users = [ - u - for u in all_users - if len(u.login) >= MIN_NO_OF_WORDS and (u.name and len(u.name) >= MIN_NO_OF_WORDS) - ] + # Pre-fetch 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.all() + instances = model_class.objects.prefetch_related("suggested_leaders") for instance in instances: self.stdout.write(f"Processing leaders for {model_name.capitalize()} {instance.id}...") - exact_matches, fuzzy_matches, unmatched_leaders = self.process_leaders( + exact_matches, fuzzy_matches, unmatched = self.process_leaders( instance.leaders_raw, threshold, filtered_users ) - instance.suggested_leaders.set(list(set(exact_matches + fuzzy_matches))) - instance.save() - if unmatched_leaders: - self.stdout.write(f"Unmatched leaders for {instance.name}: {unmatched_leaders}") + suggested_leader_ids = {user["id"] for user in exact_matches + fuzzy_matches} + instance.suggested_leaders.set(suggested_leader_ids) + + if unmatched: + self.stdout.write(f"Unmatched leaders for {instance.name}: {unmatched}") + + def _is_valid_user(self, login, name): + """Check if 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 and return the suggested leaders with exact and fuzzy matching.""" + """Process leaders with optimized matching.""" 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: - leaders_data = User.objects.filter( - models.Q(login__iexact=leader) | models.Q(name__iexact=leader) - ).first() - if leaders_data: - exact_matches.append(leaders_data) - self.stdout.write(f"Exact match found for {leader}: {leaders_data}") + exact_match = next( + ( + u + for u in user_list + if u["login"].lower() == leader_lower + or (u["name"] and u["name"].lower() == leader_lower) + ), + None, + ) + + if exact_match: + exact_matches.append(exact_match) + self.stdout.write(f"Exact match found for {leader}: {exact_match['login']}") continue matches = [ u - for u in filtered_users - if (fuzz.partial_ratio(leader, u.login) >= threshold) - or (fuzz.partial_ratio(leader, u.name if u.name else "") >= threshold) + for u in user_list + if (fuzz.partial_ratio(leader_lower, u["login"].lower()) >= threshold) + or ( + u["name"] + and fuzz.partial_ratio(leader_lower, u["name"].lower()) >= threshold + ) ] new_fuzzy_matches = [m for m in matches if m not in exact_matches] - fuzzy_matches.extend(new_fuzzy_matches) - - if 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}") + self.stdout.write(f"Fuzzy match found for {leader}: {match['login']}") else: unmatched_leaders.append(leader) diff --git a/backend/apps/owasp/admin.py b/backend/apps/owasp/admin.py index 23b79da89..3d2a82e0e 100644 --- a/backend/apps/owasp/admin.py +++ b/backend/apps/owasp/admin.py @@ -110,7 +110,7 @@ class PostAdmin(admin.ModelAdmin): ) -class ProjectAdmin(admin.ModelAdmin, GenericEntityAdminMixin): +class ProjectAdmin(LeaderEntityAdmin): autocomplete_fields = ( "organizations", "owasp_repository", From 0234c8319107407b2fc485c879967e97011c4b01 Mon Sep 17 00:00:00 2001 From: Rajgupta36 Date: Sun, 23 Mar 2025 13:08:48 +0530 Subject: [PATCH 6/7] slack models and commands --- .../management/commands/matching_users.py | 48 ++++--- .../0032_alter_chapter_leaders_and_more.py | 73 ++++++++++ backend/apps/owasp/models/common.py | 9 +- backend/apps/slack/admin.py | 55 +++++++- .../commands/populate_slack_data.py | 91 ++++++++++++ .../0004_workspace_member_channel.py | 133 ++++++++++++++++++ backend/apps/slack/models/__init__.py | 3 + backend/apps/slack/models/channel.py | 25 ++++ backend/apps/slack/models/member.py | 40 ++++++ backend/apps/slack/models/workspace.py | 21 +++ backend/poetry.lock | 2 +- backend/pyproject.toml | 1 + 12 files changed, 477 insertions(+), 24 deletions(-) create mode 100644 backend/apps/owasp/migrations/0032_alter_chapter_leaders_and_more.py create mode 100644 backend/apps/slack/management/commands/populate_slack_data.py create mode 100644 backend/apps/slack/migrations/0004_workspace_member_channel.py create mode 100644 backend/apps/slack/models/channel.py create mode 100644 backend/apps/slack/models/member.py create mode 100644 backend/apps/slack/models/workspace.py diff --git a/backend/apps/common/management/commands/matching_users.py b/backend/apps/common/management/commands/matching_users.py index b2ebfda9c..89456ef4d 100644 --- a/backend/apps/common/management/commands/matching_users.py +++ b/backend/apps/common/management/commands/matching_users.py @@ -1,4 +1,4 @@ -"""A command to perform fuzzy and exact matching of leaders with GitHub users models.""" +"""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 @@ -8,19 +8,20 @@ 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 = "Process raw leaders for multiple models and suggest leaders." + help = "Process raw leaders or Slack members and match with GitHub users." def add_arguments(self, parser): parser.add_argument( "model_name", type=str, - choices=["chapter", "committee", "project"], - help="Model name to process leaders for (chapter, committee, project)", + choices=["chapter", "committee", "project", "member"], + help="Model name to process: chapter, committee, project, or member", ) parser.add_argument( "--threshold", @@ -34,39 +35,48 @@ def handle(self, *args, **kwargs): threshold = max(0, min(kwargs["threshold"], 100)) model_map = { - "chapter": Chapter, - "committee": Committee, - "project": Project, + "chapter": (Chapter, "suggested_leaders"), + "committee": (Committee, "suggested_leaders"), + "project": (Project, "suggested_leaders"), + "member": (Member, "suggested_users"), } - model_class = model_map.get(model_name) - if not model_class: + if model_name not in model_map: self.stdout.write( - self.style.ERROR("Invalid model name! Choose from: chapter, committee, project") + self.style.ERROR( + "Invalid model name! Choose from: chapter, committee, project, member" + ) ) return - # Pre-fetch users + 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("suggested_leaders") + instances = model_class.objects.prefetch_related(relation_field) for instance in instances: - self.stdout.write(f"Processing leaders for {model_name.capitalize()} {instance.id}...") + 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( - instance.leaders_raw, threshold, filtered_users + leaders_raw, threshold, filtered_users ) - suggested_leader_ids = {user["id"] for user in exact_matches + fuzzy_matches} - instance.suggested_leaders.set(suggested_leader_ids) + 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 leaders for {instance.name}: {unmatched}") + self.stdout.write(f"Unmatched for {instance}: {unmatched}") def _is_valid_user(self, login, name): - """Check if user meets minimum requirements.""" + """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): @@ -94,7 +104,7 @@ def process_leaders(self, leaders_raw, threshold, filtered_users): u for u in user_list if u["login"].lower() == leader_lower - or (u["name"] and u["name"].lower() == leader_lower) + or (u["name"].lower() == leader_lower) ), None, ) diff --git a/backend/apps/owasp/migrations/0032_alter_chapter_leaders_and_more.py b/backend/apps/owasp/migrations/0032_alter_chapter_leaders_and_more.py new file mode 100644 index 000000000..81e7d15a0 --- /dev/null +++ b/backend/apps/owasp/migrations/0032_alter_chapter_leaders_and_more.py @@ -0,0 +1,73 @@ +# Generated by Django 5.1.7 on 2025-03-23 07:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("github", "0018_alter_issue_managers_alter_pullrequest_managers"), + ("owasp", "0031_merge_20250320_2001"), + ] + + operations = [ + migrations.AlterField( + model_name="chapter", + name="leaders", + field=models.ManyToManyField( + blank=True, + related_name="assigned_%(class)s", + to="github.user", + verbose_name="Assigned leaders", + ), + ), + migrations.AlterField( + model_name="chapter", + name="suggested_leaders", + field=models.ManyToManyField( + blank=True, + related_name="matched_%(class)s", + to="github.user", + verbose_name="Matched Users", + ), + ), + migrations.AlterField( + model_name="committee", + name="leaders", + field=models.ManyToManyField( + blank=True, + related_name="assigned_%(class)s", + to="github.user", + verbose_name="Assigned leaders", + ), + ), + migrations.AlterField( + model_name="committee", + name="suggested_leaders", + field=models.ManyToManyField( + blank=True, + related_name="matched_%(class)s", + to="github.user", + verbose_name="Matched Users", + ), + ), + migrations.AlterField( + model_name="project", + name="leaders", + field=models.ManyToManyField( + blank=True, + related_name="assigned_%(class)s", + to="github.user", + verbose_name="Assigned leaders", + ), + ), + migrations.AlterField( + 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 41fc99e0d..066949091 100644 --- a/backend/apps/owasp/models/common.py +++ b/backend/apps/owasp/models/common.py @@ -176,12 +176,15 @@ def get_metadata(self): # M2M suggested_leaders = models.ManyToManyField( "github.User", - verbose_name="Exact Match Users", - related_name="exact_matched_%(class)s", + verbose_name="Matched Users", + related_name="matched_%(class)s", blank=True, ) leaders = models.ManyToManyField( - "github.User", verbose_name="Leaders", related_name="normal_%(class)s", blank=True + "github.User", + verbose_name="Assigned leaders", + related_name="assigned_%(class)s", + blank=True, ) def get_related_url(self, url, exclude_domains=(), include_domains=()): 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/populate_slack_data.py b/backend/apps/slack/management/commands/populate_slack_data.py new file mode 100644 index 000000000..93008d900 --- /dev/null +++ b/backend/apps/slack/management/commands/populate_slack_data.py @@ -0,0 +1,91 @@ +"""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}...") + try: + response = client.conversations_list( + types="public_channel,private_channel", limit=1000 + ) + 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), + }, + ) + self.stdout.write( + self.style.SUCCESS(f"Populated {len(response['channels'])} channels") + ) + except SlackApiError as e: + self.stdout.write( + self.style.ERROR(f"Failed to fetch channels: {e.response['error']}") + ) + + # populate members + self.stdout.write(f"Fetching members for {workspace_id}...") + try: + response = client.users_list(limit=1000) + 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 + self.stdout.write(self.style.SUCCESS(f"Populated {member_count} 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..9e44f3819 --- /dev/null +++ b/backend/apps/slack/migrations/0004_workspace_member_channel.py @@ -0,0 +1,133 @@ +# Generated by Django 5.1.7 on 2025-03-23 07:04 + +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, 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, 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, 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..c9d34bdec --- /dev/null +++ b/backend/apps/slack/models/channel.py @@ -0,0 +1,25 @@ +"""Slack app channel model.""" + +from django.db import models + +from apps.common.models import TimestampedModel + +from .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) + 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..5f5144f6d --- /dev/null +++ b/backend/apps/slack/models/member.py @@ -0,0 +1,40 @@ +"""Slack app channel 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) + 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..3577b6e68 --- /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) + 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 8cc694cba..8c0a98dee 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -3036,4 +3036,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "b8629d1dba4d9407fe87d36ee26d9185d384e93939037870dfa0192269bbbbe3" +content-hash = "6861ad43e28fb126bdb524481edcd661a975587520c54ac3cf95e3157ed486ee" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 620ce2d3d..34cfa8a45 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -45,6 +45,7 @@ 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" From 3b1d42274271071df1ebf8c6f24da804df11603d Mon Sep 17 00:00:00 2001 From: Rajgupta36 Date: Sun, 23 Mar 2025 20:04:49 +0530 Subject: [PATCH 7/7] bug fixes --- backend/Makefile | 4 + .../management/commands/matching_users.py | 34 ++++---- ...ders_chapter_suggested_leaders_and_more.py | 73 ---------------- ...ers_chapter_suggested_leaders_and_more.py} | 16 ++-- .../migrations/0031_merge_20250320_2001.py | 12 --- ...ulate_slack_data.py => load_slack_data.py} | 87 +++++++++++-------- .../0004_workspace_member_channel.py | 14 ++- backend/apps/slack/models/channel.py | 5 +- backend/apps/slack/models/member.py | 4 +- backend/apps/slack/models/workspace.py | 2 +- cspell/custom-dict.txt | 1 + 11 files changed, 96 insertions(+), 156 deletions(-) delete mode 100644 backend/apps/owasp/migrations/0015_chapter_leaders_chapter_suggested_leaders_and_more.py rename backend/apps/owasp/migrations/{0032_alter_chapter_leaders_and_more.py => 0031_chapter_leaders_chapter_suggested_leaders_and_more.py} (87%) delete mode 100644 backend/apps/owasp/migrations/0031_merge_20250320_2001.py rename backend/apps/slack/management/commands/{populate_slack_data.py => load_slack_data.py} (50%) diff --git a/backend/Makefile b/backend/Makefile index 71e4ce687..fa0317fd5 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -53,6 +53,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 index 89456ef4d..31e026ebc 100644 --- a/backend/apps/common/management/commands/matching_users.py +++ b/backend/apps/common/management/commands/matching_users.py @@ -14,7 +14,7 @@ class Command(BaseCommand): - help = "Process raw leaders or Slack members and match with GitHub users." + help = "Match leaders or Slack members with GitHub users using exact and fuzzy matching." def add_arguments(self, parser): parser.add_argument( @@ -26,7 +26,7 @@ def add_arguments(self, parser): parser.add_argument( "--threshold", type=int, - default=85, + default=75, help="Threshold for fuzzy matching (0-100)", ) @@ -80,7 +80,7 @@ def _is_valid_user(self, login, name): 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.""" + """Process leaders with optimized matching, capturing all exact matches.""" if not leaders_raw: return [], [], [] @@ -99,28 +99,28 @@ def process_leaders(self, leaders_raw, threshold, filtered_users): leader_lower = leader.lower() try: - exact_match = next( - ( - u - for u in user_list - if u["login"].lower() == leader_lower - or (u["name"].lower() == leader_lower) - ), - None, - ) + # 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_match: - exact_matches.append(exact_match) - self.stdout.write(f"Exact match found for {leader}: {exact_match['login']}") + 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.partial_ratio(leader_lower, u["login"].lower()) >= threshold) + if (fuzz.token_sort_ratio(leader_lower, u["login"].lower()) >= threshold) or ( u["name"] - and fuzz.partial_ratio(leader_lower, u["name"].lower()) >= threshold + and fuzz.token_sort_ratio(leader_lower, u["name"].lower()) >= threshold ) ] diff --git a/backend/apps/owasp/migrations/0015_chapter_leaders_chapter_suggested_leaders_and_more.py b/backend/apps/owasp/migrations/0015_chapter_leaders_chapter_suggested_leaders_and_more.py deleted file mode 100644 index acd5c0a35..000000000 --- a/backend/apps/owasp/migrations/0015_chapter_leaders_chapter_suggested_leaders_and_more.py +++ /dev/null @@ -1,73 +0,0 @@ -# Generated by Django 5.1.6 on 2025-02-22 21:43 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("github", "0015_alter_release_author"), - ("owasp", "0014_project_custom_tags"), - ] - - operations = [ - migrations.AddField( - model_name="chapter", - name="leaders", - field=models.ManyToManyField( - blank=True, - related_name="normal_%(class)s", - to="github.user", - verbose_name="Leaders", - ), - ), - migrations.AddField( - model_name="chapter", - name="suggested_leaders", - field=models.ManyToManyField( - blank=True, - related_name="exact_matched_%(class)s", - to="github.user", - verbose_name="Exact Match Users", - ), - ), - migrations.AddField( - model_name="committee", - name="leaders", - field=models.ManyToManyField( - blank=True, - related_name="normal_%(class)s", - to="github.user", - verbose_name="Leaders", - ), - ), - migrations.AddField( - model_name="committee", - name="suggested_leaders", - field=models.ManyToManyField( - blank=True, - related_name="exact_matched_%(class)s", - to="github.user", - verbose_name="Exact Match Users", - ), - ), - migrations.AddField( - model_name="project", - name="leaders", - field=models.ManyToManyField( - blank=True, - related_name="normal_%(class)s", - to="github.user", - verbose_name="Leaders", - ), - ), - migrations.AddField( - model_name="project", - name="suggested_leaders", - field=models.ManyToManyField( - blank=True, - related_name="exact_matched_%(class)s", - to="github.user", - verbose_name="Exact Match Users", - ), - ), - ] diff --git a/backend/apps/owasp/migrations/0032_alter_chapter_leaders_and_more.py b/backend/apps/owasp/migrations/0031_chapter_leaders_chapter_suggested_leaders_and_more.py similarity index 87% rename from backend/apps/owasp/migrations/0032_alter_chapter_leaders_and_more.py rename to backend/apps/owasp/migrations/0031_chapter_leaders_chapter_suggested_leaders_and_more.py index 81e7d15a0..669b4f272 100644 --- a/backend/apps/owasp/migrations/0032_alter_chapter_leaders_and_more.py +++ b/backend/apps/owasp/migrations/0031_chapter_leaders_chapter_suggested_leaders_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.7 on 2025-03-23 07:04 +# Generated by Django 5.1.7 on 2025-03-23 13:51 from django.db import migrations, models @@ -6,11 +6,11 @@ class Migration(migrations.Migration): dependencies = [ ("github", "0018_alter_issue_managers_alter_pullrequest_managers"), - ("owasp", "0031_merge_20250320_2001"), + ("owasp", "0030_chapter_is_leaders_policy_compliant_and_more"), ] operations = [ - migrations.AlterField( + migrations.AddField( model_name="chapter", name="leaders", field=models.ManyToManyField( @@ -20,7 +20,7 @@ class Migration(migrations.Migration): verbose_name="Assigned leaders", ), ), - migrations.AlterField( + migrations.AddField( model_name="chapter", name="suggested_leaders", field=models.ManyToManyField( @@ -30,7 +30,7 @@ class Migration(migrations.Migration): verbose_name="Matched Users", ), ), - migrations.AlterField( + migrations.AddField( model_name="committee", name="leaders", field=models.ManyToManyField( @@ -40,7 +40,7 @@ class Migration(migrations.Migration): verbose_name="Assigned leaders", ), ), - migrations.AlterField( + migrations.AddField( model_name="committee", name="suggested_leaders", field=models.ManyToManyField( @@ -50,7 +50,7 @@ class Migration(migrations.Migration): verbose_name="Matched Users", ), ), - migrations.AlterField( + migrations.AddField( model_name="project", name="leaders", field=models.ManyToManyField( @@ -60,7 +60,7 @@ class Migration(migrations.Migration): verbose_name="Assigned leaders", ), ), - migrations.AlterField( + migrations.AddField( model_name="project", name="suggested_leaders", field=models.ManyToManyField( diff --git a/backend/apps/owasp/migrations/0031_merge_20250320_2001.py b/backend/apps/owasp/migrations/0031_merge_20250320_2001.py deleted file mode 100644 index bb908d27a..000000000 --- a/backend/apps/owasp/migrations/0031_merge_20250320_2001.py +++ /dev/null @@ -1,12 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-20 20:01 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("owasp", "0015_chapter_leaders_chapter_suggested_leaders_and_more"), - ("owasp", "0030_chapter_is_leaders_policy_compliant_and_more"), - ] - - operations = [] diff --git a/backend/apps/slack/management/commands/populate_slack_data.py b/backend/apps/slack/management/commands/load_slack_data.py similarity index 50% rename from backend/apps/slack/management/commands/populate_slack_data.py rename to backend/apps/slack/management/commands/load_slack_data.py index 93008d900..6a811f6da 100644 --- a/backend/apps/slack/management/commands/populate_slack_data.py +++ b/backend/apps/slack/management/commands/load_slack_data.py @@ -31,52 +31,67 @@ def handle(self, *args, **options): # Slack client client = WebClient(token=bot_token) - # populate channels + # Populate channels self.stdout.write(f"Fetching channels for {workspace_id}...") + total_channels = 0 try: - response = client.conversations_list( - types="public_channel,private_channel", limit=1000 - ) - 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), - }, + cursor = None + while True: + response = client.conversations_list( + types="public_channel,private_channel", limit=1000, cursor=cursor ) - self.stdout.write( - self.style.SUCCESS(f"Populated {len(response['channels'])} channels") - ) + 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']}") ) - # populate members self.stdout.write(f"Fetching members for {workspace_id}...") + total_members = 0 try: - response = client.users_list(limit=1000) - 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 - self.stdout.write(self.style.SUCCESS(f"Populated {member_count} members")) + 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']}") diff --git a/backend/apps/slack/migrations/0004_workspace_member_channel.py b/backend/apps/slack/migrations/0004_workspace_member_channel.py index 9e44f3819..0e7eed2c4 100644 --- a/backend/apps/slack/migrations/0004_workspace_member_channel.py +++ b/backend/apps/slack/migrations/0004_workspace_member_channel.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.7 on 2025-03-23 07:04 +# Generated by Django 5.1.7 on 2025-03-23 13:51 import django.db.models.deletion from django.db import migrations, models @@ -24,7 +24,7 @@ class Migration(migrations.Migration): ("nest_updated_at", models.DateTimeField(auto_now=True)), ( "slack_workspace_id", - models.CharField(max_length=50, verbose_name="Workspace ID"), + models.CharField(max_length=50, unique=True, verbose_name="Workspace ID"), ), ( "name", @@ -56,7 +56,10 @@ class Migration(migrations.Migration): "real_name", models.CharField(default="", max_length=100, verbose_name="Real Name"), ), - ("slack_user_id", models.CharField(max_length=50, verbose_name="User ID")), + ( + "slack_user_id", + models.CharField(max_length=50, unique=True, verbose_name="User ID"), + ), ( "username", models.CharField(default="", max_length=100, verbose_name="Username"), @@ -115,7 +118,10 @@ class Migration(migrations.Migration): "name", models.CharField(default="", max_length=100, verbose_name="Channel Name"), ), - ("slack_channel_id", models.CharField(max_length=50, verbose_name="Channel ID")), + ( + "slack_channel_id", + models.CharField(max_length=50, unique=True, verbose_name="Channel ID"), + ), ( "workspace", models.ForeignKey( diff --git a/backend/apps/slack/models/channel.py b/backend/apps/slack/models/channel.py index c9d34bdec..69b4c4a50 100644 --- a/backend/apps/slack/models/channel.py +++ b/backend/apps/slack/models/channel.py @@ -3,8 +3,7 @@ from django.db import models from apps.common.models import TimestampedModel - -from .workspace import Workspace +from apps.slack.models.workspace import Workspace class Channel(TimestampedModel): @@ -17,7 +16,7 @@ class Meta: 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) + 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): diff --git a/backend/apps/slack/models/member.py b/backend/apps/slack/models/member.py index 5f5144f6d..d86095a2c 100644 --- a/backend/apps/slack/models/member.py +++ b/backend/apps/slack/models/member.py @@ -1,4 +1,4 @@ -"""Slack app channel model.""" +"""Slack app member model.""" from django.db import models @@ -16,7 +16,7 @@ class Meta: 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) + 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( diff --git a/backend/apps/slack/models/workspace.py b/backend/apps/slack/models/workspace.py index 3577b6e68..6a73a9a03 100644 --- a/backend/apps/slack/models/workspace.py +++ b/backend/apps/slack/models/workspace.py @@ -12,7 +12,7 @@ class Meta: db_table = "slack_workspaces" verbose_name_plural = "Workspaces" - slack_workspace_id = models.CharField(verbose_name="Workspace ID", max_length=50) + 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="") diff --git a/cspell/custom-dict.txt b/cspell/custom-dict.txt index 055fedc3a..d0fe7c1ac 100644 --- a/cspell/custom-dict.txt +++ b/cspell/custom-dict.txt @@ -87,6 +87,7 @@ superfences tsc Twitterbot usefixtures +USLACKBOT Whistleblower winsrdf wsgi