From 5486fe3d8dd643599369a7d81efaaddd672837aa Mon Sep 17 00:00:00 2001 From: Saleem Latif Date: Fri, 13 Oct 2023 13:24:03 +0500 Subject: [PATCH] feat: Added the ability to blacklist job-skill relationship. --- requirements/base.in | 2 +- requirements/ci.txt | 14 +- requirements/constraints.txt | 7 + requirements/dev.txt | 137 +++++++------- requirements/doc.txt | 168 ++++++++--------- requirements/pip-tools.txt | 17 +- requirements/pip.txt | 8 +- requirements/test.txt | 105 +++++------ taxonomy/admin.py | 60 +++++- taxonomy/constants.py | 1 + taxonomy/forms.py | 31 +++ taxonomy/models.py | 47 ++++- taxonomy/static/admin/css/skills-tags.css | 63 +++++++ taxonomy/static/admin/js/job-skills-admin.js | 35 ++++ .../templates/admin/taxonomy/job-skills.html | 92 +++++++++ taxonomy/views.py | 154 +++++++++++++++ test_settings.py | 23 +++ test_utils/factories.py | 2 + tests/test_models.py | 178 ++++++++++++++++++ tests/test_views.py | 174 ++++++++++++++++- 20 files changed, 1073 insertions(+), 245 deletions(-) create mode 100644 taxonomy/forms.py create mode 100644 taxonomy/static/admin/css/skills-tags.css create mode 100644 taxonomy/static/admin/js/job-skills-admin.js create mode 100644 taxonomy/templates/admin/taxonomy/job-skills.html create mode 100644 taxonomy/views.py diff --git a/requirements/base.in b/requirements/base.in index e3cd27c5..fb63bbe3 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -14,4 +14,4 @@ django-choices django-filter openedx-events openai -pandas +django-object-actions diff --git a/requirements/ci.txt b/requirements/ci.txt index 2dda5f21..a23e0087 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -1,20 +1,20 @@ # -# This file is autogenerated by pip-compile with Python 3.8 +# This file is autogenerated by pip-compile with Python 3.9 # by the following command: # # make upgrade # -distlib==0.3.6 +distlib==0.3.7 # via virtualenv -filelock==3.12.2 +filelock==3.12.4 # via # tox # virtualenv -packaging==23.1 +packaging==23.2 # via tox -platformdirs==3.8.0 +platformdirs==3.11.0 # via virtualenv -pluggy==1.2.0 +pluggy==1.3.0 # via tox py==1.11.0 # via tox @@ -29,5 +29,5 @@ tox==3.28.0 # tox-battery tox-battery==0.6.1 # via -r requirements/ci.in -virtualenv==20.23.1 +virtualenv==20.24.5 # via tox diff --git a/requirements/constraints.txt b/requirements/constraints.txt index bc17c9e5..1e01265f 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -24,3 +24,10 @@ algoliasearch<2.0.0 # tox version greater than 4 is causing problems. tox<4.0.0 + +# latest version require python>=3.9 +sphinxcontrib-applehelp<=1.0.4 +sphinxcontrib-devhelp<=1.0.2 +sphinxcontrib-htmlhelp<=2.0.1 +sphinxcontrib-qthelp<=1.0.3 +sphinxcontrib-serializinghtml<=1.1.5 diff --git a/requirements/dev.txt b/requirements/dev.txt index 4b196cea..d8d28f77 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,10 +1,10 @@ # -# This file is autogenerated by pip-compile with Python 3.8 +# This file is autogenerated by pip-compile with Python 3.9 # by the following command: # # make upgrade # -aiohttp==3.8.4 +aiohttp==3.8.6 # via # -r requirements/test.txt # openai @@ -28,7 +28,7 @@ astroid==2.3.3 # via # pylint # pylint-celery -async-timeout==4.0.2 +async-timeout==4.0.3 # via # -r requirements/test.txt # aiohttp @@ -43,16 +43,16 @@ billiard==3.6.4.0 # via # -r requirements/test.txt # celery -boto3==1.27.1 +boto3==1.28.66 # via # -r requirements/test.txt # django-ses -botocore==1.30.1 +botocore==1.31.66 # via # -r requirements/test.txt # boto3 # s3transfer -build==0.10.0 +build==1.0.3 # via # -r requirements/pip-tools.txt # pip-tools @@ -60,22 +60,22 @@ celery==4.4.7 # via # -c requirements/constraints.txt # -r requirements/test.txt -certifi==2023.5.7 +certifi==2023.7.22 # via # -r requirements/test.txt # requests -cffi==1.15.1 +cffi==1.16.0 # via # -r requirements/test.txt # pynacl -chardet==5.1.0 +chardet==5.2.0 # via diff-cover -charset-normalizer==3.1.0 +charset-normalizer==3.3.0 # via # -r requirements/test.txt # aiohttp # requests -click==8.1.4 +click==8.1.7 # via # -r requirements/pip-tools.txt # -r requirements/test.txt @@ -86,21 +86,22 @@ click==8.1.4 # pip-tools click-log==0.3.2 # via edx-lint -code-annotations==1.3.0 +code-annotations==1.5.0 # via -r requirements/test.txt -coverage[toml]==7.2.7 +coverage[toml]==7.3.2 # via # -r requirements/test.txt + # coverage # pytest-cov ddt==1.6.0 # via -r requirements/test.txt -diff-cover==7.6.0 +diff-cover==8.0.0 # via -r requirements/dev.in -distlib==0.3.6 +distlib==0.3.7 # via # -r requirements/ci.txt # virtualenv -django==3.2.20 +django==3.2.22 # via # -c requirements/constraints.txt # -r requirements/test.txt @@ -110,66 +111,70 @@ django==3.2.20 # django-model-utils # django-ses # django-solo + # django-waffle # djangorestframework # edx-django-utils # edx-i18n-tools # openedx-events -django-choices==1.7.2 +django-choices==2.0.0 # via -r requirements/test.txt django-crum==0.7.9 # via # -r requirements/test.txt # edx-django-utils -django-filter==23.2 +django-filter==23.3 # via -r requirements/test.txt django-model-utils==4.3.1 # via -r requirements/test.txt +django-object-actions==4.2.0 + # via -r requirements/test.txt django-ses==3.5.0 # via -r requirements/test.txt django-solo==2.1.0 # via -r requirements/test.txt -django-waffle==3.0.0 +django-waffle==4.0.0 # via # -r requirements/test.txt # edx-django-utils djangorestframework==3.14.0 # via -r requirements/test.txt -edx-django-utils==5.5.0 +edx-django-utils==5.7.0 # via # -r requirements/test.txt # edx-rest-api-client -edx-i18n-tools==0.9.2 +edx-i18n-tools==1.3.0 # via -r requirements/dev.in edx-lint==1.5.2 # via # -c requirements/constraints.txt # -r requirements/dev.in -edx-opaque-keys[django]==2.3.0 +edx-opaque-keys[django]==2.5.1 # via # -r requirements/test.txt + # edx-opaque-keys # openedx-events -edx-rest-api-client==5.5.2 +edx-rest-api-client==5.6.1 # via -r requirements/test.txt -exceptiongroup==1.1.2 +exceptiongroup==1.1.3 # via # -r requirements/test.txt # pytest -factory-boy==3.2.1 +factory-boy==3.3.0 # via -r requirements/test.txt -faker==18.11.2 +faker==19.11.0 # via # -r requirements/test.txt # factory-boy -fastavro==1.8.0 +fastavro==1.8.4 # via # -r requirements/test.txt # openedx-events -filelock==3.12.2 +filelock==3.12.4 # via # -r requirements/ci.txt # tox # virtualenv -frozenlist==1.3.3 +frozenlist==1.4.0 # via # -r requirements/test.txt # aiohttp @@ -179,6 +184,10 @@ idna==3.4 # -r requirements/test.txt # requests # yarl +importlib-metadata==6.8.0 + # via + # -r requirements/pip-tools.txt + # build iniconfig==2.0.0 # via # -r requirements/test.txt @@ -203,32 +212,30 @@ kombu==4.6.11 # celery lazy-object-proxy==1.4.3 # via astroid +lxml==4.9.3 + # via edx-i18n-tools markupsafe==2.1.3 # via # -r requirements/test.txt # jinja2 mccabe==0.6.1 # via pylint -mock==5.0.2 +mock==5.1.0 # via -r requirements/test.txt multidict==6.0.4 # via # -r requirements/test.txt # aiohttp # yarl -newrelic==8.8.1 +newrelic==9.1.0 # via # -r requirements/test.txt # edx-django-utils -numpy==1.24.4 - # via - # -r requirements/test.txt - # pandas -openai==0.27.8 +openai==0.28.1 # via -r requirements/test.txt -openedx-events==8.2.0 +openedx-events==9.0.0 # via -r requirements/test.txt -packaging==23.1 +packaging==23.2 # via # -r requirements/ci.txt # -r requirements/pip-tools.txt @@ -236,8 +243,6 @@ packaging==23.1 # build # pytest # tox -pandas==2.0.3 - # via -r requirements/test.txt path==13.1.0 # via # -c requirements/constraints.txt @@ -246,13 +251,13 @@ pbr==5.11.1 # via # -r requirements/test.txt # stevedore -pip-tools==6.14.0 +pip-tools==7.3.0 # via -r requirements/pip-tools.txt -platformdirs==3.8.0 +platformdirs==3.11.0 # via # -r requirements/ci.txt # virtualenv -pluggy==1.2.0 +pluggy==1.3.0 # via # -r requirements/ci.txt # -r requirements/test.txt @@ -261,7 +266,7 @@ pluggy==1.2.0 # tox polib==1.2.0 # via edx-i18n-tools -psutil==5.9.5 +psutil==5.9.6 # via # -r requirements/test.txt # edx-django-utils @@ -269,7 +274,7 @@ py==1.11.0 # via # -r requirements/ci.txt # tox -pycodestyle==2.10.0 +pycodestyle==2.11.1 # via -r requirements/dev.in pycparser==2.21 # via @@ -277,9 +282,9 @@ pycparser==2.21 # cffi pydocstyle==6.3.0 # via -r requirements/dev.in -pygments==2.15.1 +pygments==2.16.1 # via diff-cover -pyjwt==2.7.0 +pyjwt==2.8.0 # via # -r requirements/test.txt # edx-rest-api-client @@ -309,7 +314,7 @@ pyproject-hooks==1.0.0 # via # -r requirements/pip-tools.txt # build -pytest==7.4.0 +pytest==7.4.2 # via # -r requirements/test.txt # pytest-cov @@ -323,20 +328,18 @@ python-dateutil==2.8.2 # -r requirements/test.txt # botocore # faker - # pandas python-slugify==8.0.1 # via # -r requirements/test.txt # code-annotations -pytz==2023.3 +pytz==2023.3.post1 # via # -r requirements/test.txt # celery # django # django-ses # djangorestframework - # pandas -pyyaml==6.0 +pyyaml==6.0.1 # via # -r requirements/test.txt # code-annotations @@ -350,9 +353,9 @@ requests==2.31.0 # openai # responses # slumber -responses==0.23.1 +responses==0.23.3 # via -r requirements/test.txt -s3transfer==0.6.1 +s3transfer==0.7.0 # via # -r requirements/test.txt # boto3 @@ -361,7 +364,6 @@ six==1.16.0 # -r requirements/ci.txt # -r requirements/test.txt # astroid - # django-choices # edx-lint # python-dateutil # tox @@ -371,7 +373,7 @@ slumber==0.7.1 # edx-rest-api-client snowballstemmer==2.2.0 # via pydocstyle -soupsieve==2.4.1 +soupsieve==2.5 # via # -r requirements/test.txt # beautifulsoup4 @@ -385,7 +387,7 @@ stevedore==5.1.0 # code-annotations # edx-django-utils # edx-opaque-keys -testfixtures==7.1.0 +testfixtures==7.2.0 # via -r requirements/test.txt text-unidecode==1.3 # via @@ -411,23 +413,20 @@ tox-battery==0.6.1 # via # -r requirements/ci.txt # -r requirements/dev.in -tqdm==4.65.0 +tqdm==4.66.1 # via # -r requirements/test.txt # openai -types-pyyaml==6.0.12.10 +types-pyyaml==6.0.12.12 # via # -r requirements/test.txt # responses -typing-extensions==4.7.1 +typing-extensions==4.8.0 # via # -r requirements/test.txt # asgiref -tzdata==2023.3 - # via - # -r requirements/test.txt - # pandas -urllib3==1.26.16 + # edx-opaque-keys +urllib3==1.26.18 # via # -r requirements/test.txt # botocore @@ -438,11 +437,11 @@ vine==1.3.0 # -r requirements/test.txt # amqp # celery -virtualenv==20.23.1 +virtualenv==20.24.5 # via # -r requirements/ci.txt # tox -wheel==0.40.0 +wheel==0.41.2 # via # -r requirements/pip-tools.txt # pip-tools @@ -452,6 +451,10 @@ yarl==1.9.2 # via # -r requirements/test.txt # aiohttp +zipp==3.17.0 + # via + # -r requirements/pip-tools.txt + # importlib-metadata # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/requirements/doc.txt b/requirements/doc.txt index 77f7298e..898432b7 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -1,12 +1,12 @@ # -# This file is autogenerated by pip-compile with Python 3.8 +# This file is autogenerated by pip-compile with Python 3.9 # by the following command: # # make upgrade # accessible-pygments==0.0.4 # via pydata-sphinx-theme -aiohttp==3.8.4 +aiohttp==3.8.6 # via # -r requirements/test.txt # openai @@ -28,7 +28,7 @@ asgiref==3.7.2 # via # -r requirements/test.txt # django -async-timeout==4.0.2 +async-timeout==4.0.3 # via # -r requirements/test.txt # aiohttp @@ -37,7 +37,7 @@ attrs==23.1.0 # -r requirements/test.txt # aiohttp # openedx-events -babel==2.12.1 +babel==2.13.0 # via # pydata-sphinx-theme # sphinx @@ -49,53 +49,49 @@ billiard==3.6.4.0 # via # -r requirements/test.txt # celery -bleach==6.0.0 - # via readme-renderer -boto3==1.27.1 +boto3==1.28.66 # via # -r requirements/test.txt # django-ses -botocore==1.30.1 +botocore==1.31.66 # via # -r requirements/test.txt # boto3 # s3transfer -build==0.10.0 +build==1.0.3 # via -r requirements/doc.in celery==4.4.7 # via # -c requirements/constraints.txt # -r requirements/test.txt -certifi==2023.5.7 +certifi==2023.7.22 # via # -r requirements/test.txt # requests -cffi==1.15.1 +cffi==1.16.0 # via # -r requirements/test.txt - # cryptography # pynacl -charset-normalizer==3.1.0 +charset-normalizer==3.3.0 # via # -r requirements/test.txt # aiohttp # requests -click==8.1.4 +click==8.1.7 # via # -r requirements/test.txt # code-annotations # edx-django-utils -code-annotations==1.3.0 +code-annotations==1.5.0 # via -r requirements/test.txt -coverage[toml]==7.2.7 +coverage[toml]==7.3.2 # via # -r requirements/test.txt + # coverage # pytest-cov -cryptography==41.0.1 - # via secretstorage ddt==1.6.0 # via -r requirements/test.txt -django==3.2.20 +django==3.2.22 # via # -c requirements/constraints.txt # -r requirements/test.txt @@ -105,24 +101,27 @@ django==3.2.20 # django-model-utils # django-ses # django-solo + # django-waffle # djangorestframework # edx-django-utils # openedx-events -django-choices==1.7.2 +django-choices==2.0.0 # via -r requirements/test.txt django-crum==0.7.9 # via # -r requirements/test.txt # edx-django-utils -django-filter==23.2 +django-filter==23.3 # via -r requirements/test.txt django-model-utils==4.3.1 # via -r requirements/test.txt +django-object-actions==4.2.0 + # via -r requirements/test.txt django-ses==3.5.0 # via -r requirements/test.txt django-solo==2.1.0 # via -r requirements/test.txt -django-waffle==3.0.0 +django-waffle==4.0.0 # via # -r requirements/test.txt # edx-django-utils @@ -137,31 +136,32 @@ docutils==0.19 # readme-renderer # restructuredtext-lint # sphinx -edx-django-utils==5.5.0 +edx-django-utils==5.7.0 # via # -r requirements/test.txt # edx-rest-api-client -edx-opaque-keys[django]==2.3.0 +edx-opaque-keys[django]==2.5.1 # via # -r requirements/test.txt + # edx-opaque-keys # openedx-events -edx-rest-api-client==5.5.2 +edx-rest-api-client==5.6.1 # via -r requirements/test.txt -exceptiongroup==1.1.2 +exceptiongroup==1.1.3 # via # -r requirements/test.txt # pytest -factory-boy==3.2.1 +factory-boy==3.3.0 # via -r requirements/test.txt -faker==18.11.2 +faker==19.11.0 # via # -r requirements/test.txt # factory-boy -fastavro==1.8.0 +fastavro==1.8.4 # via # -r requirements/test.txt # openedx-events -frozenlist==1.3.3 +frozenlist==1.4.0 # via # -r requirements/test.txt # aiohttp @@ -173,23 +173,18 @@ idna==3.4 # yarl imagesize==1.4.1 # via sphinx -importlib-metadata==6.7.0 +importlib-metadata==6.8.0 # via + # build # keyring # sphinx # twine -importlib-resources==5.12.0 - # via keyring iniconfig==2.0.0 # via # -r requirements/test.txt # pytest -jaraco-classes==3.2.3 +jaraco-classes==3.3.0 # via keyring -jeepney==0.8.0 - # via - # keyring - # secretstorage jinja2==3.1.2 # via # -r requirements/test.txt @@ -214,47 +209,43 @@ markupsafe==2.1.3 # jinja2 mdurl==0.1.2 # via markdown-it-py -mock==5.0.2 +mock==5.1.0 # via -r requirements/test.txt -more-itertools==9.1.0 +more-itertools==10.1.0 # via jaraco-classes multidict==6.0.4 # via # -r requirements/test.txt # aiohttp # yarl -newrelic==8.8.1 +newrelic==9.1.0 # via # -r requirements/test.txt # edx-django-utils -numpy==1.24.4 - # via - # -r requirements/test.txt - # pandas -openai==0.27.8 +nh3==0.2.14 + # via readme-renderer +openai==0.28.1 # via -r requirements/test.txt -openedx-events==8.2.0 +openedx-events==9.0.0 # via -r requirements/test.txt -packaging==23.1 +packaging==23.2 # via # -r requirements/test.txt # build # pydata-sphinx-theme # pytest # sphinx -pandas==2.0.3 - # via -r requirements/test.txt pbr==5.11.1 # via # -r requirements/test.txt # stevedore pkginfo==1.9.6 # via twine -pluggy==1.2.0 +pluggy==1.3.0 # via # -r requirements/test.txt # pytest -psutil==5.9.5 +psutil==5.9.6 # via # -r requirements/test.txt # edx-django-utils @@ -262,9 +253,9 @@ pycparser==2.21 # via # -r requirements/test.txt # cffi -pydata-sphinx-theme==0.13.3 +pydata-sphinx-theme==0.14.1 # via sphinx-book-theme -pygments==2.15.1 +pygments==2.16.1 # via # accessible-pygments # doc8 @@ -272,7 +263,7 @@ pygments==2.15.1 # readme-renderer # rich # sphinx -pyjwt==2.7.0 +pyjwt==2.8.0 # via # -r requirements/test.txt # edx-rest-api-client @@ -286,7 +277,7 @@ pynacl==1.5.0 # edx-django-utils pyproject-hooks==1.0.0 # via build -pytest==7.4.0 +pytest==7.4.2 # via # -r requirements/test.txt # pytest-cov @@ -300,26 +291,23 @@ python-dateutil==2.8.2 # -r requirements/test.txt # botocore # faker - # pandas python-slugify==8.0.1 # via # -r requirements/test.txt # code-annotations -pytz==2023.3 +pytz==2023.3.post1 # via # -r requirements/test.txt - # babel # celery # django # django-ses # djangorestframework - # pandas -pyyaml==6.0 +pyyaml==6.0.1 # via # -r requirements/test.txt # code-annotations # responses -readme-renderer==40.0 +readme-renderer==42.0 # via twine requests==2.31.0 # via @@ -334,25 +322,21 @@ requests==2.31.0 # twine requests-toolbelt==1.0.0 # via twine -responses==0.23.1 +responses==0.23.3 # via -r requirements/test.txt restructuredtext-lint==1.4.0 # via doc8 rfc3986==2.0.0 # via twine -rich==13.4.2 +rich==13.6.0 # via twine -s3transfer==0.6.1 +s3transfer==0.7.0 # via # -r requirements/test.txt # boto3 -secretstorage==3.3.3 - # via keyring six==1.16.0 # via # -r requirements/test.txt - # bleach - # django-choices # python-dateutil slumber==0.7.1 # via @@ -360,7 +344,7 @@ slumber==0.7.1 # edx-rest-api-client snowballstemmer==2.2.0 # via sphinx -soupsieve==2.4.1 +soupsieve==2.5 # via # -r requirements/test.txt # beautifulsoup4 @@ -372,17 +356,27 @@ sphinx==6.2.1 sphinx-book-theme==1.0.1 # via -r requirements/doc.in sphinxcontrib-applehelp==1.0.4 - # via sphinx + # via + # -c requirements/constraints.txt + # sphinx sphinxcontrib-devhelp==1.0.2 - # via sphinx + # via + # -c requirements/constraints.txt + # sphinx sphinxcontrib-htmlhelp==2.0.1 - # via sphinx + # via + # -c requirements/constraints.txt + # sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx sphinxcontrib-qthelp==1.0.3 - # via sphinx + # via + # -c requirements/constraints.txt + # sphinx sphinxcontrib-serializinghtml==1.1.5 - # via sphinx + # via + # -c requirements/constraints.txt + # sphinx sqlparse==0.4.4 # via # -r requirements/test.txt @@ -394,7 +388,7 @@ stevedore==5.1.0 # doc8 # edx-django-utils # edx-opaque-keys -testfixtures==7.1.0 +testfixtures==7.2.0 # via -r requirements/test.txt text-unidecode==1.3 # via @@ -408,27 +402,23 @@ tomli==2.0.1 # doc8 # pyproject-hooks # pytest -tqdm==4.65.0 +tqdm==4.66.1 # via # -r requirements/test.txt # openai twine==4.0.2 # via -r requirements/doc.in -types-pyyaml==6.0.12.10 +types-pyyaml==6.0.12.12 # via # -r requirements/test.txt # responses -typing-extensions==4.7.1 +typing-extensions==4.8.0 # via # -r requirements/test.txt # asgiref + # edx-opaque-keys # pydata-sphinx-theme - # rich -tzdata==2023.3 - # via - # -r requirements/test.txt - # pandas -urllib3==1.26.16 +urllib3==1.26.18 # via # -r requirements/test.txt # botocore @@ -440,13 +430,9 @@ vine==1.3.0 # -r requirements/test.txt # amqp # celery -webencodings==0.5.1 - # via bleach yarl==1.9.2 # via # -r requirements/test.txt # aiohttp -zipp==3.15.0 - # via - # importlib-metadata - # importlib-resources +zipp==3.17.0 + # via importlib-metadata diff --git a/requirements/pip-tools.txt b/requirements/pip-tools.txt index 69a70b01..df834b0b 100644 --- a/requirements/pip-tools.txt +++ b/requirements/pip-tools.txt @@ -1,16 +1,18 @@ # -# This file is autogenerated by pip-compile with Python 3.8 +# This file is autogenerated by pip-compile with Python 3.9 # by the following command: # # make upgrade # -build==0.10.0 +build==1.0.3 # via pip-tools -click==8.1.4 +click==8.1.7 # via pip-tools -packaging==23.1 +importlib-metadata==6.8.0 # via build -pip-tools==6.14.0 +packaging==23.2 + # via build +pip-tools==7.3.0 # via -r requirements/pip-tools.in pyproject-hooks==1.0.0 # via build @@ -18,8 +20,11 @@ tomli==2.0.1 # via # build # pip-tools -wheel==0.40.0 + # pyproject-hooks +wheel==0.41.2 # via pip-tools +zipp==3.17.0 + # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/requirements/pip.txt b/requirements/pip.txt index fa19e6f0..946bd373 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -1,14 +1,14 @@ # -# This file is autogenerated by pip-compile with Python 3.8 +# This file is autogenerated by pip-compile with Python 3.9 # by the following command: # # make upgrade # -wheel==0.40.0 +wheel==0.41.2 # via -r requirements/pip.in # The following packages are considered to be unsafe in a requirements file: -pip==23.1.2 +pip==23.3 # via -r requirements/pip.in -setuptools==68.0.0 +setuptools==68.2.2 # via -r requirements/pip.in diff --git a/requirements/test.txt b/requirements/test.txt index 40ad2641..c464b603 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,10 +1,10 @@ # -# This file is autogenerated by pip-compile with Python 3.8 +# This file is autogenerated by pip-compile with Python 3.9 # by the following command: # # make upgrade # -aiohttp==3.8.4 +aiohttp==3.8.6 # via openai aiosignal==1.3.1 # via aiohttp @@ -16,7 +16,7 @@ amqp==2.6.1 # via kombu asgiref==3.7.2 # via django -async-timeout==4.0.2 +async-timeout==4.0.3 # via aiohttp attrs==23.1.0 # via @@ -26,9 +26,9 @@ beautifulsoup4==4.12.2 # via -r requirements/base.in billiard==3.6.4.0 # via celery -boto3==1.27.1 +boto3==1.28.66 # via django-ses -botocore==1.30.1 +botocore==1.31.66 # via # boto3 # s3transfer @@ -36,22 +36,24 @@ celery==4.4.7 # via # -c requirements/constraints.txt # -r requirements/base.in -certifi==2023.5.7 +certifi==2023.7.22 # via requests -cffi==1.15.1 +cffi==1.16.0 # via pynacl -charset-normalizer==3.1.0 +charset-normalizer==3.3.0 # via # aiohttp # requests -click==8.1.4 +click==8.1.7 # via # code-annotations # edx-django-utils -code-annotations==1.3.0 +code-annotations==1.5.0 # via -r requirements/test.in -coverage[toml]==7.2.7 - # via pytest-cov +coverage[toml]==7.3.2 + # via + # coverage + # pytest-cov ddt==1.6.0 # via -r requirements/test.in # via @@ -63,46 +65,49 @@ ddt==1.6.0 # django-model-utils # django-ses # django-solo + # django-waffle # djangorestframework # edx-django-utils # openedx-events -django-choices==1.7.2 +django-choices==2.0.0 # via -r requirements/base.in django-crum==0.7.9 # via edx-django-utils -django-filter==23.2 +django-filter==23.3 # via -r requirements/base.in django-model-utils==4.3.1 # via -r requirements/base.in +django-object-actions==4.2.0 + # via -r requirements/base.in django-ses==3.5.0 # via -r requirements/base.in django-solo==2.1.0 # via -r requirements/base.in -django-waffle==3.0.0 +django-waffle==4.0.0 # via edx-django-utils djangorestframework==3.14.0 # via -r requirements/base.in -edx-django-utils==5.5.0 +edx-django-utils==5.7.0 # via # -r requirements/base.in # edx-rest-api-client -edx-opaque-keys[django]==2.3.0 +edx-opaque-keys[django]==2.5.1 # via # -r requirements/base.in # openedx-events -edx-rest-api-client==5.5.2 +edx-rest-api-client==5.6.1 # via -r requirements/base.in -exceptiongroup==1.1.2 +exceptiongroup==1.1.3 # via pytest -factory-boy==3.2.1 +factory-boy==3.3.0 # via -r requirements/test.in -faker==18.11.2 +faker==19.11.0 # via # -r requirements/test.in # factory-boy -fastavro==1.8.0 +fastavro==1.8.4 # via openedx-events -frozenlist==1.3.3 +frozenlist==1.4.0 # via # aiohttp # aiosignal @@ -122,39 +127,35 @@ kombu==4.6.11 # via celery markupsafe==2.1.3 # via jinja2 -mock==5.0.2 +mock==5.1.0 # via -r requirements/test.in multidict==6.0.4 # via # aiohttp # yarl -newrelic==8.8.1 +newrelic==9.1.0 # via edx-django-utils -numpy==1.24.4 - # via pandas -openai==0.27.8 +openai==0.28.1 # via -r requirements/base.in -openedx-events==8.2.0 +openedx-events==9.0.0 # via -r requirements/base.in -packaging==23.1 +packaging==23.2 # via pytest -pandas==2.0.3 - # via -r requirements/base.in pbr==5.11.1 # via stevedore -pluggy==1.2.0 +pluggy==1.3.0 # via pytest -psutil==5.9.5 +psutil==5.9.6 # via edx-django-utils pycparser==2.21 # via cffi -pyjwt==2.7.0 +pyjwt==2.8.0 # via edx-rest-api-client pymongo==3.13.0 # via edx-opaque-keys pynacl==1.5.0 # via edx-django-utils -pytest==7.4.0 +pytest==7.4.2 # via # pytest-cov # pytest-django @@ -166,18 +167,16 @@ python-dateutil==2.8.2 # via # botocore # faker - # pandas python-slugify==8.0.1 # via code-annotations -pytz==2023.3 +pytz==2023.3.post1 # via # -r requirements/base.in # celery # django # django-ses # djangorestframework - # pandas -pyyaml==6.0 +pyyaml==6.0.1 # via # code-annotations # responses @@ -188,17 +187,15 @@ requests==2.31.0 # openai # responses # slumber -responses==0.23.1 +responses==0.23.3 # via -r requirements/test.in -s3transfer==0.6.1 +s3transfer==0.7.0 # via boto3 six==1.16.0 - # via - # django-choices - # python-dateutil + # via python-dateutil slumber==0.7.1 # via edx-rest-api-client -soupsieve==2.4.1 +soupsieve==2.5 # via beautifulsoup4 sqlparse==0.4.4 # via django @@ -207,7 +204,7 @@ stevedore==5.1.0 # code-annotations # edx-django-utils # edx-opaque-keys -testfixtures==7.1.0 +testfixtures==7.2.0 # via -r requirements/test.in text-unidecode==1.3 # via python-slugify @@ -215,15 +212,15 @@ tomli==2.0.1 # via # coverage # pytest -tqdm==4.65.0 +tqdm==4.66.1 # via openai -types-pyyaml==6.0.12.10 +types-pyyaml==6.0.12.12 # via responses -typing-extensions==4.7.1 - # via asgiref -tzdata==2023.3 - # via pandas -urllib3==1.26.16 +typing-extensions==4.8.0 + # via + # asgiref + # edx-opaque-keys +urllib3==1.26.18 # via # botocore # requests diff --git a/taxonomy/admin.py b/taxonomy/admin.py index 0080bda6..3135bad4 100644 --- a/taxonomy/admin.py +++ b/taxonomy/admin.py @@ -6,14 +6,34 @@ """ from __future__ import unicode_literals -from django.contrib import admin -from django.contrib import messages +from django_object_actions import DjangoObjectActions +from django.contrib import admin, messages +from django.http import HttpResponseRedirect +from django.urls import re_path, reverse + +from taxonomy.constants import JOB_SKILLS_URL_NAME from taxonomy.models import ( - CourseRunXBlockSkillsTracker, CourseSkills, Job, JobPath, JobPostings, JobSkills, ProgramSkill, Skill, - Translation, SkillCategory, SkillSubCategory, SkillsQuiz, RefreshProgramSkillsConfig, Industry, IndustryJobSkill, - XBlockSkills, XBlockSkillData, B2CJobAllowList + B2CJobAllowList, + CourseRunXBlockSkillsTracker, + CourseSkills, + Industry, + IndustryJobSkill, + Job, + JobPath, + JobPostings, + JobSkills, + ProgramSkill, + RefreshProgramSkillsConfig, + Skill, + SkillCategory, + SkillsQuiz, + SkillSubCategory, + Translation, + XBlockSkillData, + XBlockSkills, ) +from taxonomy.views import JobSkillsView @admin.register(Skill) @@ -76,14 +96,40 @@ class CourseSkillsTitleAdmin(admin.ModelAdmin): @admin.register(Job) -class JobAdmin(admin.ModelAdmin): +class JobAdmin(DjangoObjectActions, admin.ModelAdmin): """ Administrative view for Jobs. """ list_display = ('id', 'name', 'created', 'modified') search_fields = ('name',) - actions = ['remove_unused_jobs'] + actions = ('remove_unused_jobs', ) + change_actions = ('job_skills', ) + + def job_skills(self, request, obj): + """ + Object tool handler method - redirects to "Course Skills" view. + """ + # url names coming from get_urls are prefixed with 'admin' namespace + return HttpResponseRedirect( + redirect_to=reverse(f"admin:{JOB_SKILLS_URL_NAME}", args=(obj.pk,)), + ) + + def get_urls(self): + """ + Return the additional urls used by the custom object tools. + """ + additional_urls = [ + re_path( + r"^([^/]+)/skills$", + self.admin_site.admin_view(JobSkillsView.as_view()), + name=JOB_SKILLS_URL_NAME, + ), + ] + return additional_urls + super().get_urls() + + job_skills.label = "view job skills" + job_skills.short_description = "view job skills" def remove_unused_jobs(self, request, queryset): # pylint: disable=unused-argument """ diff --git a/taxonomy/constants.py b/taxonomy/constants.py index 2431638a..5aead790 100644 --- a/taxonomy/constants.py +++ b/taxonomy/constants.py @@ -144,3 +144,4 @@ def get_job_posting_query_filter(jobs=None): JOB_SOURCE_COURSE_SKILL = 'course_skill' JOB_SOURCE_INDUSTRY = 'industry' +JOB_SKILLS_URL_NAME = 'job-skills' diff --git a/taxonomy/forms.py b/taxonomy/forms.py new file mode 100644 index 00000000..0f7fed49 --- /dev/null +++ b/taxonomy/forms.py @@ -0,0 +1,31 @@ +""" +Forms for taxonomy-connector app. +""" + +from django import forms + + +class ExcludeSkillsForm(forms.Form): + """ + Form to handle excluding skills from course. + """ + + exclude_skills = forms.MultipleChoiceField() + include_skills = forms.MultipleChoiceField() + + def __init__( + self, job_skills, excluded_job_skills, *args, **kwargs + ): + """ + Initialize multi choice fields. + """ + super().__init__(*args, **kwargs) + + self.fields['include_skills'] = forms.MultipleChoiceField( + choices=((skill.id, skill.name) for skill in excluded_job_skills), + required=False, + ) + self.fields['exclude_skills'] = forms.MultipleChoiceField( + choices=((skill.id, skill.name) for skill in job_skills), + required=False, + ) diff --git a/taxonomy/models.py b/taxonomy/models.py index 61a5b9e2..03e01482 100644 --- a/taxonomy/models.py +++ b/taxonomy/models.py @@ -524,6 +524,49 @@ def __repr__(self): self.description ) + def get_whitelisted_job_skills(self, prefetch_skills=True): + """ + Get a QuerySet of all the whitelisted skills associated with the job. + """ + job_skill_qs = JobSkills.get_whitelisted_job_skill_qs().filter(job=self) + industry_job_skill_qs = IndustryJobSkill.get_whitelisted_job_skill_qs().filter(job=self) + if prefetch_skills: + job_skill_qs = job_skill_qs.select_related('skill') + industry_job_skill_qs = industry_job_skill_qs.select_related('skill') + return job_skill_qs, industry_job_skill_qs + + def get_blacklisted_job_skills(self, prefetch_skills=True): + """ + Get a QuerySet of all the whitelisted skills associated with the job. + """ + job_skill_qs = JobSkills.get_blacklist_job_skill_qs().filter(job=self) + industry_job_skill_qs = IndustryJobSkill.get_blacklist_job_skill_qs().filter(job=self) + + if prefetch_skills: + job_skill_qs = job_skill_qs.select_related('skill') + industry_job_skill_qs = industry_job_skill_qs.select_related('skill') + return job_skill_qs, industry_job_skill_qs + + def blacklist_job_skills(self, skill_ids): + """ + Black list all job skills with the given skill ids. + + Arguments: + skill_ids (list): A list of Skill ids that should be black list for the current job. + """ + self.jobskills_set.filter(skill__id__in=skill_ids).update(is_blacklisted=True) + self.industryjobskill_set.filter(skill__id__in=skill_ids).update(is_blacklisted=True) + + def whitelist_job_skills(self, skill_ids): + """ + Remove all job skills with the given skill ids from the blacklist. + + Arguments: + skill_ids (list): A list of Skill ids that should be removed from the blacklist. + """ + self.jobskills_set.filter(skill__id__in=skill_ids).update(is_blacklisted=False) + self.industryjobskill_set.filter(skill__id__in=skill_ids).update(is_blacklisted=False) + class JobPath(TimeStampedModel): """ @@ -642,7 +685,7 @@ class Meta: abstract = True @classmethod - def get_whitelisted_job_skills(cls): + def get_whitelisted_job_skill_qs(cls): """ Get a QuerySet of whitelisted job skills. @@ -651,7 +694,7 @@ def get_whitelisted_job_skills(cls): return cls.objects.filter(is_blacklisted=False) @classmethod - def get_blacklist_job_skill(cls): + def get_blacklist_job_skill_qs(cls): """ Get a QuerySet of whitelisted job skills. diff --git a/taxonomy/static/admin/css/skills-tags.css b/taxonomy/static/admin/css/skills-tags.css new file mode 100644 index 00000000..0cf41500 --- /dev/null +++ b/taxonomy/static/admin/css/skills-tags.css @@ -0,0 +1,63 @@ +.list-item { + background-color: silver; + color: #447E9B;; + padding: 8px 0 8px 8px;; + border-radius: 4px; + margin: 5px; + width: max-content; + list-style: none; + display: inline-block; + cursor: pointer; +} + +.list-item:hover * { + color: #003366; + font-weight: bold; +} + +.item{ + padding-left: 0px !important; +} + +.paragraph{ + color: red; +} + +.grid { + display: grid; + grid-template-columns: 49% 2% 49%; +} + +hr.vertical { + color: #b2b2b2; + background-color: #b2b2b2; +} + +hr.vertical { + width: 1px; + height: 100%; +} + +.remove { + margin-left: 1em; + cursor: pointer; + padding: 10px 14px; + transform: translate(0%, -50%); + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; +} + +.remove:hover { + background: #003366; + color: silver; +} + +.submit-row { + padding: 12px 14px; + margin: 0 0 20px; + background: #f8f8f8; + border: 1px solid #eee; + border-radius: 4px; + text-align: right; + overflow: hidden; +} diff --git a/taxonomy/static/admin/js/job-skills-admin.js b/taxonomy/static/admin/js/job-skills-admin.js new file mode 100644 index 00000000..cc77d974 --- /dev/null +++ b/taxonomy/static/admin/js/job-skills-admin.js @@ -0,0 +1,35 @@ + +function excludeCourseSkill(event) { + $('.excluded-skills .item').append( + $(event.target.parentElement.outerHTML) + ); + + let skillId = event.target.parentElement.dataset.skillId; + $('#id_exclude_skills option[value="' + skillId + '"]').attr('selected', true) + $('#id_include_skills option[value="' + skillId + '"]').attr('selected', false) + + event.target.parentElement.remove(); + +} + +function includeCourseSkill(event) { + $('.job-skill .item').append( + $(event.target.parentElement.outerHTML) + ); + + let skillId = event.target.parentElement.dataset.skillId; + $('#id_include_skills option[value="' + skillId + '"]').attr('selected', true) + $('#id_exclude_skills option[value="' + skillId + '"]').attr('selected', false) + + event.target.parentElement.remove(); +} + + +$(function(){ + // Exclude course skill. + $('.job-skill').on('click', '.remove', excludeCourseSkill); + + // Include course skills. + $('.excluded-skills').on('click', '.remove', includeCourseSkill); + +}); diff --git a/taxonomy/templates/admin/taxonomy/job-skills.html b/taxonomy/templates/admin/taxonomy/job-skills.html new file mode 100644 index 00000000..bd48f10c --- /dev/null +++ b/taxonomy/templates/admin/taxonomy/job-skills.html @@ -0,0 +1,92 @@ +{% extends 'admin/base_site.html' %} +{% load i18n static admin_urls %} + +{% block extrastyle %} + +{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+
+
+

{{ "Skills for "|add:job.name }}

+ {% if job_skills %} + + {% else %} +
    +
+ {% endif %} +
+
+
+

{{ "Excluded Skills for "|add:job.name }}

+ {% if excluded_job_skills %} + + {% else %} +
    +
+ {% endif %} + +
+
+
+
+
+ {% csrf_token %} +
+ {{ exclude_skills_form.as_p }} +
+
+ +
+
+
+ +
+ +{% endblock %} + +{% block footer %} + {{ block.super }} + + +{% endblock %} diff --git a/taxonomy/views.py b/taxonomy/views.py new file mode 100644 index 00000000..ca2a21b1 --- /dev/null +++ b/taxonomy/views.py @@ -0,0 +1,154 @@ +""" +Views for taxonomy-connector app. +""" +from collections import namedtuple + +from django.contrib import admin, messages +from django.contrib.auth import get_permission_codename +from django.http import HttpResponseRedirect +from django.shortcuts import get_object_or_404, render +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from django.views.generic import View + +from taxonomy.constants import JOB_SKILLS_URL_NAME +from taxonomy.forms import ExcludeSkillsForm +from taxonomy.models import Job + +Option = namedtuple('Option', 'id name') + + +class JobSkillsView(View): + """ + Job Skills view. + + For displaying job skills of a particular job. + """ + + template = 'admin/taxonomy/job-skills.html' + form = ExcludeSkillsForm + + class ContextParameters: + """ + Namespace-style class for custom context parameters. + """ + + JOB_SKILLS = 'job_skills' + EXCLUDED_JOB_SKILLS = 'excluded_job_skills' + + JOB = 'job' + + @staticmethod + def _get_admin_context(request, job): + """ + Build admin context. + """ + options = job._meta + codename = get_permission_codename('change', options) + return { + 'has_change_permission': request.user.has_perm('%s.%s' % (options.app_label, codename)), + 'opts': options + } + + @staticmethod + def _get_skill_options(*query_sets): + """ + Get a list of skill option tuples containing skills id and skill name. + + Arguments: + query_sets (list): A list of queryset objects of any class inheriting from `BaseJobSkill` model. + + Returns: + (list>): A named tuple with skills `id` as id and skill `nam`e as name attribute. + """ + options = set() + for qs in query_sets: + for job_skill in qs: + options.add(Option(job_skill.skill.id, job_skill.skill.name)) + return options + + def _get_view_context(self, job_pk): + """ + Return the default context parameters. + """ + job = get_object_or_404(Job, id=job_pk) + job_skills, industry_job_skills = job.get_whitelisted_job_skills() + excluded_job_skills, excluded_industry_job_skills = job.get_blacklisted_job_skills() + return { + self.ContextParameters.JOB: job, + self.ContextParameters.JOB_SKILLS: self._get_skill_options(job_skills, industry_job_skills), + self.ContextParameters.EXCLUDED_JOB_SKILLS: self._get_skill_options( + excluded_job_skills, excluded_industry_job_skills + ), + 'title': job.name, + } + + def _build_context(self, request, job_pk): + """ + Build admin and view context used by the template. + """ + context = self._get_view_context(job_pk) + context.update(admin.site.each_context(request)) + context.update(self._get_admin_context(request, context['job'])) + return context + + def get(self, request, job_pk): + """ + Handle GET request - renders the template. + + Arguments: + request (django.http.request.HttpRequest): Request instance + job_pk (str): Primary key of the job + + Returns: + django.http.response.HttpResponse: HttpResponse + """ + context = self._build_context(request, job_pk) + context['exclude_skills_form'] = self.form( + context[self.ContextParameters.JOB_SKILLS], + context[self.ContextParameters.EXCLUDED_JOB_SKILLS], + ) + + return render(request, self.template, context) + + def post(self, request, job_pk): + """ + Handle POST request - saves excluded/included skills. + + Arguments: + request (django.http.request.HttpRequest): Request instance + job_pk (str): Primary key of the job + + Returns: + django.http.response.HttpResponse: HttpResponse + """ + job = Job.objects.get(id=job_pk) + job_skills, industry_job_skills = job.get_whitelisted_job_skills() + excluded_job_skills, excluded_industry_job_skills = job.get_blacklisted_job_skills() + + form = self.form( + job_skills=self._get_skill_options(job_skills, industry_job_skills), + excluded_job_skills=self._get_skill_options(excluded_job_skills, excluded_industry_job_skills), + data=request.POST + ) + if form.is_valid(): + if form.cleaned_data['exclude_skills']: + job.blacklist_job_skills(form.cleaned_data['exclude_skills']) + if form.cleaned_data['include_skills']: + job.whitelist_job_skills(form.cleaned_data['include_skills']) + + messages.add_message( + request=self.request, + level=messages.SUCCESS, + message=_('Job skills were updated successfully.'), + ) + else: + messages.add_message( + request=self.request, + level=messages.ERROR, + message=_('Job skills could not be updated, please try again or contact support.'), + ) + + return HttpResponseRedirect( + reverse(f'admin:{JOB_SKILLS_URL_NAME}', args=(job_pk,)) + ) diff --git a/test_settings.py b/test_settings.py index ab925e2f..2e7b88eb 100644 --- a/test_settings.py +++ b/test_settings.py @@ -31,6 +31,7 @@ def root(*args): } INSTALLED_APPS = ( + 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', @@ -53,6 +54,28 @@ def root(*args): 'django.contrib.sites.middleware.CurrentSiteMiddleware', ) +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'APP_DIRS': True, + 'DIRS': ( + root('templates'), + ), + 'OPTIONS': { + 'context_processors': ( + 'django.contrib.auth.context_processors.auth', + 'django.template.context_processors.debug', + 'django.template.context_processors.i18n', + 'django.template.context_processors.media', + 'django.template.context_processors.static', + 'django.template.context_processors.tz', + 'django.template.context_processors.request', + 'django.contrib.messages.context_processors.messages', + ), + 'debug': True, # Django will only display debug pages if the global DEBUG setting is set to True. + } + }, +] # Settings related to LightCast (EMSI) client # API URLs are altered to avoid accidentally calling the API in tests # Original URL: https://auth.emsicloud.com/connect/token diff --git a/test_utils/factories.py b/test_utils/factories.py index e5ff634b..c2e3a711 100644 --- a/test_utils/factories.py +++ b/test_utils/factories.py @@ -303,6 +303,7 @@ class Meta: job = factory.SubFactory(JobFactory) significance = factory.LazyAttribute(lambda x: FAKER.pyfloat(right_digits=2, min_value=0, max_value=100)) unique_postings = factory.LazyAttribute(lambda x: FAKER.pyint(min_value=0, max_value=100000000)) + is_blacklisted = False class IndustryJobSkillFactory(factory.django.DjangoModelFactory): @@ -318,6 +319,7 @@ class Meta: job = factory.SubFactory(JobFactory) significance = factory.LazyAttribute(lambda x: FAKER.pyfloat(right_digits=2, min_value=0, max_value=100)) unique_postings = factory.LazyAttribute(lambda x: FAKER.pyint(min_value=0, max_value=100000000)) + is_blacklisted = False class JobPostingsFactory(factory.django.DjangoModelFactory): diff --git a/tests/test_models.py b/tests/test_models.py index 5b1823ea..fb7af407 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -426,6 +426,184 @@ def test_task_does_not_triggered_if_job_has_description(self, mocked_generate_jo Job(external_id='11111', name='job name', description='I am description').save() mocked_generate_job_description_task.assert_not_called() + def test_get_whitelisted_job_skills(self): + """ + Validate get_whitelisted_job_skills returns only job skills that have not been blacklisted. + """ + job = factories.JobFactory.create() + factories.JobSkillFactory.create_batch(10, job=job, is_blacklisted=True) + factories.JobSkillFactory.create_batch(5, job=job, is_blacklisted=False) + factories.IndustryJobSkillFactory.create_batch(10, job=job, is_blacklisted=True) + factories.IndustryJobSkillFactory.create_batch(5, job=job, is_blacklisted=False) + + # Make sure there were only 2 queries made to fetch the data, one for job skills + # and another for industry job skills. + with self.assertNumQueries(2): + job_skills, industry_job_skills = job.get_whitelisted_job_skills() + assert len(job_skills) == 5 + assert len(industry_job_skills) == 5 + + def test_get_blacklisted_job_skills(self): + """ + Validate get_blacklisted_job_skills returns only job skills that have been blacklisted. + """ + job = factories.JobFactory.create() + factories.JobSkillFactory.create_batch(10, job=job, is_blacklisted=True) + factories.JobSkillFactory.create_batch(5, job=job, is_blacklisted=False) + factories.IndustryJobSkillFactory.create_batch(10, job=job, is_blacklisted=True) + factories.IndustryJobSkillFactory.create_batch(5, job=job, is_blacklisted=False) + + # Make sure there were only 2 queries made to fetch the data, one for job skills + # and another for industry job skills. + with self.assertNumQueries(2): + job_skills, industry_job_skills = job.get_blacklisted_job_skills() + assert len(job_skills) == 10 + assert len(industry_job_skills) == 10 + + def test_get_skills_disabled_prefetch(self): + """ + Validate get_blacklisted_job_skills makes multiple queries if user wants to disable prefetch. + """ + job = factories.JobFactory.create() + factories.JobSkillFactory.create_batch(10, job=job, is_blacklisted=True) + factories.JobSkillFactory.create_batch(5, job=job, is_blacklisted=False) + factories.IndustryJobSkillFactory.create_batch(10, job=job, is_blacklisted=True) + factories.IndustryJobSkillFactory.create_batch(5, job=job, is_blacklisted=False) + + # Make sure there were only 2 queries made to fetch the data, one for job skills + # and another for industry job skills. + with self.assertNumQueries(22): + job_skills, industry_job_skills = job.get_blacklisted_job_skills(prefetch_skills=False) + + assert len([job_skill.skill.id for job_skill in job_skills]) == 10 + assert len([job_skill.skill.id for job_skill in industry_job_skills]) == 10 + + with self.assertNumQueries(12): + job_skills, industry_job_skills = job.get_whitelisted_job_skills(prefetch_skills=False) + + assert len([job_skill.skill.id for job_skill in job_skills]) == 5 + assert len([job_skill.skill.id for job_skill in industry_job_skills]) == 5 + + def test_whitelist_job_skills(self): + """ + Validate whitelist_job_skills removes skills from the blacklist. + """ + job = factories.JobFactory.create() + blacklisted_job_skills = factories.JobSkillFactory.create_batch(5, job=job, is_blacklisted=True) + factories.JobSkillFactory.create_batch(5, job=job, is_blacklisted=False) + blacklisted_industry_job_skills = factories.IndustryJobSkillFactory.create_batch( + 5, job=job, is_blacklisted=True + ) + factories.IndustryJobSkillFactory.create_batch(5, job=job, is_blacklisted=False) + + # Assert initial data. + job_skills, industry_job_skills = job.get_whitelisted_job_skills() + assert len(job_skills) == 5 + assert len(industry_job_skills) == 5 + + # whitelist some of the job skills from JobSkill model. + skill_ids = [job_skill.skill.id for job_skill in blacklisted_job_skills][:3] + job.whitelist_job_skills(skill_ids) + + # Make sure there were only 2 queries made to fetch the data, one for job skills + # and another for industry job skills. + with self.assertNumQueries(2): + # Assert database records. + job_skills, industry_job_skills = job.get_whitelisted_job_skills() + assert len(job_skills) == 8 + assert len(industry_job_skills) == 5 + + # Now whitelist some if the industry job skills. + skill_ids = [job_skill.skill.id for job_skill in blacklisted_industry_job_skills][:2] + job.whitelist_job_skills(skill_ids) + + # Make sure there were only 2 queries made to fetch the data, one for job skills + # and another for industry job skills. + with self.assertNumQueries(2): + # Assert database records. + job_skills, industry_job_skills = job.get_whitelisted_job_skills() + assert len(job_skills) == 8 + assert len(industry_job_skills) == 7 + + # Now whitelist everything. + skill_ids = [job_skill.skill.id for job_skill in blacklisted_industry_job_skills] + skill_ids.extend([job_skill.skill.id for job_skill in blacklisted_job_skills]) + job.whitelist_job_skills(skill_ids) + + # Make sure there were only 2 queries made to fetch the data, one for job skills + # and another for industry job skills. + with self.assertNumQueries(2): + # Assert database records. + job_skills, industry_job_skills = job.get_whitelisted_job_skills() + assert len(job_skills) == 10 + assert len(industry_job_skills) == 10 + + with self.assertNumQueries(2): + # Assert database records. + job_skills, industry_job_skills = job.get_blacklisted_job_skills() + assert len(job_skills) == 0 + assert len(industry_job_skills) == 0 + + def test_blacklist_job_skills(self): + """ + Validate blacklist_job_skills removes skills from the blacklist. + """ + job = factories.JobFactory.create() + factories.JobSkillFactory.create_batch(5, job=job, is_blacklisted=True) + whitelisted_job_skills = factories.JobSkillFactory.create_batch(5, job=job, is_blacklisted=False) + factories.IndustryJobSkillFactory.create_batch(5, job=job, is_blacklisted=True) + whitelisted_industry_job_skills = factories.IndustryJobSkillFactory.create_batch( + 5, job=job, is_blacklisted=False + ) + + # Assert initial data. + job_skills, industry_job_skills = job.get_blacklisted_job_skills() + assert len(job_skills) == 5 + assert len(industry_job_skills) == 5 + + # blacklist some of the job skills from JobSkill model. + skill_ids = [job_skill.skill.id for job_skill in whitelisted_job_skills][:3] + job.blacklist_job_skills(skill_ids) + + # Make sure there were only 2 queries made to fetch the data, one for job skills + # and another for industry job skills. + with self.assertNumQueries(2): + # Assert database records. + job_skills, industry_job_skills = job.get_blacklisted_job_skills() + assert len(job_skills) == 8 + assert len(industry_job_skills) == 5 + + # Now whitelist some if the industry job skills. + skill_ids = [job_skill.skill.id for job_skill in whitelisted_industry_job_skills][:2] + job.blacklist_job_skills(skill_ids) + + # Make sure there were only 2 queries made to fetch the data, one for job skills + # and another for industry job skills. + with self.assertNumQueries(2): + # Assert database records. + job_skills, industry_job_skills = job.get_blacklisted_job_skills() + assert len(job_skills) == 8 + assert len(industry_job_skills) == 7 + + # Now whitelist everything. + skill_ids = [job_skill.skill.id for job_skill in whitelisted_industry_job_skills] + skill_ids.extend([job_skill.skill.id for job_skill in whitelisted_job_skills]) + job.blacklist_job_skills(skill_ids) + + # Make sure there were only 2 queries made to fetch the data, one for job skills + # and another for industry job skills. + with self.assertNumQueries(2): + # Assert database records. + job_skills, industry_job_skills = job.get_blacklisted_job_skills() + assert len(job_skills) == 10 + assert len(industry_job_skills) == 10 + + with self.assertNumQueries(2): + # Assert database records. + job_skills, industry_job_skills = job.get_whitelisted_job_skills() + assert len(job_skills) == 0 + assert len(industry_job_skills) == 0 + @mark.django_db class TestJobPath(TestCase): diff --git a/tests/test_views.py b/tests/test_views.py index cad77c5b..b96c8de9 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -5,19 +5,22 @@ import json from random import randint +import mock from mock import patch from pytest import mark from rest_framework import status from django.contrib.auth import get_user_model from django.db.models import Count, Sum -from django.test import Client, TestCase +from django.test import Client, RequestFactory, TestCase from django.urls import reverse from taxonomy.models import JobPath, JobSkills, Skill, SkillCategory from taxonomy.utils import generate_and_store_job_to_job_description +from taxonomy.views import JobSkillsView, admin from test_utils.factories import ( CourseSkillsFactory, + IndustryJobSkillFactory, JobFactory, JobPostingsFactory, JobSkillFactory, @@ -405,10 +408,10 @@ def test_success(self): last_category_stats = this_stats continue assert (this_stats['total_significance'] < last_category_stats['total_significance']) \ - or (this_stats['total_significance'] == last_category_stats['total_significance'] - and this_stats['total_unique_postings'] < last_category_stats['total_unique_postings']) \ - or (this_stats['total_unique_postings'] == last_category_stats['total_unique_postings'] - and this_stats['total_skills'] <= last_category_stats['total_skills']) + or (this_stats['total_significance'] == last_category_stats['total_significance'] + and this_stats['total_unique_postings'] < last_category_stats['total_unique_postings']) \ + or (this_stats['total_unique_postings'] == last_category_stats['total_unique_postings'] + and this_stats['total_skills'] <= last_category_stats['total_skills']) # assert every category data individually for index in range(5): @@ -639,7 +642,7 @@ def setUp(self) -> None: 'taxonomy.api.v1.serializers.generate_and_store_job_to_job_description', wraps=generate_and_store_job_to_job_description ) - def test_job_path_api( # pylint: disable=invalid-name + def test_job_path_api( # pylint: disable=invalid-name self, mocked_generate_and_store_job_to_job_description, mocked_chat_completion @@ -703,3 +706,162 @@ def test_job_path_api_with_invalid_job_external_ids(self): response_data = api_response.json() assert response_data['current_job'] == ['Job with external_id=1111 does not exist.'] assert response_data['future_job'] == ['Job with external_id=2222 does not exist.'] + + +@mark.django_db +class TestJobSkillsView(TestCase): + """ + Tests for ``JobSkillsView`` view. + """ + + def setUp(self) -> None: + super(TestJobSkillsView, self).setUp() + + self.job = JobFactory.create() + self.blacklisted_job_skills = JobSkillFactory.create_batch(5, job=self.job, is_blacklisted=True) + self.whitelisted_job_skills = JobSkillFactory.create_batch(5, job=self.job, is_blacklisted=False) + self.blacklisted_industry_job_skills = IndustryJobSkillFactory.create_batch( + 5, job=self.job, is_blacklisted=True + ) + self.whitelisted_listed_industry_job_skills = IndustryJobSkillFactory.create_batch( + 5, job=self.job, is_blacklisted=False + ) + + self.user = User.objects.create(username='rocky') + self.user.set_password(USER_PASSWORD) + self.user.is_admin = True + self.user.is_superuser = True + self.user.save() + self.client = Client() + self.client.login(username=self.user.username, password=USER_PASSWORD) + + @patch('django.urls.reverse', mock.Mock()) + def test_get(self): + """ + Validate the view returns correct HTML content for job skills management view. + """ + request = RequestFactory().get(path=f'/job/{self.job.id}/skills') + request.user = self.user + admin.site.get_app_list = mock.Mock(return_value=[]) + response = JobSkillsView.as_view()(request=request, job_pk=self.job.id) + + assert response.status_code == 200 + assert f'Skills for {self.job.name}'.encode('utf-8') in response.content + + @patch('taxonomy.views.messages') + @patch('taxonomy.views.reverse', mock.Mock(return_value='/')) + def test_post(self, mocked_messages): + """ + Validate the view handles post requests correctly. + """ + mocked_messages.add_message = mock.MagicMock() + request = RequestFactory().post(path=f'/job/{self.job.id}/skills', data={ + 'exclude_skills': [job_skill.skill.id for job_skill in self.whitelisted_job_skills], + 'include_skills': [job_skill.skill.id for job_skill in self.blacklisted_industry_job_skills], + + }) + request.user = self.user + admin.site.get_app_list = mock.Mock(return_value=[]) + response = JobSkillsView.as_view()(request=request, job_pk=self.job.id) + + assert response.status_code == 302 + mocked_messages.add_message.assert_called_once_with( + request=mock.ANY, + level=mock.ANY, + message='Job skills were updated successfully.', + ) + # Make sure all job skills are blacklisted and all industry job skills are whitelisted. + job_skill_qs, industry_job_skill_qs = self.job.get_whitelisted_job_skills() + + assert job_skill_qs.count() == 0 + assert industry_job_skill_qs.count() == 10 + + @patch('taxonomy.views.messages') + @patch('taxonomy.views.reverse', mock.Mock(return_value='/')) + def test_post_exclude_include_all(self, mocked_messages): + """ + Validate the view handles post requests with user tries to exclude/include all skills. + """ + mocked_messages.add_message = mock.MagicMock() + + request = RequestFactory().post(path=f'/job/{self.job.id}/skills', data={ + 'exclude_skills': [ + job_skill.skill.id for job_skill in + self.whitelisted_job_skills + self.whitelisted_listed_industry_job_skills + ], + 'include_skills': [], + + }) + request.user = self.user + admin.site.get_app_list = mock.Mock(return_value=[]) + response = JobSkillsView.as_view()(request=request, job_pk=self.job.id) + + assert response.status_code == 302 + mocked_messages.add_message.assert_called_once_with( + request=mock.ANY, + level=mock.ANY, + message='Job skills were updated successfully.', + ) + # Make sure all job skills and industry job skills are blacklisted. + job_skill_qs, industry_job_skill_qs = self.job.get_whitelisted_job_skills() + + assert job_skill_qs.count() == 0 + assert industry_job_skill_qs.count() == 0 + + blacklisted_job_skill_qs, blacklisted_industry_job_skill_qs = self.job.get_blacklisted_job_skills() + + assert blacklisted_job_skill_qs.count() == 10 + assert blacklisted_industry_job_skill_qs.count() == 10 + + # Now whitelist all job skills + request = RequestFactory().post(path=f'/job/{self.job.id}/skills', data={ + 'exclude_skills': [], + 'include_skills': [ + skill.id for skill in + # pylint: disable=protected-access + JobSkillsView._get_skill_options(blacklisted_job_skill_qs, blacklisted_industry_job_skill_qs) + ], + }) + request.user = self.user + admin.site.get_app_list = mock.Mock(return_value=[]) + response = JobSkillsView.as_view()(request=request, job_pk=self.job.id) + + assert response.status_code == 302 + mocked_messages.add_message.assert_called_with( + request=mock.ANY, + level=mock.ANY, + message='Job skills were updated successfully.', + ) + # Make sure all job skills and industry job skills are blacklisted. + job_skill_qs, industry_job_skill_qs = self.job.get_whitelisted_job_skills() + + assert job_skill_qs.count() == 10 + assert industry_job_skill_qs.count() == 10 + + @patch('taxonomy.views.messages') + @patch('taxonomy.views.reverse', mock.Mock(return_value='/')) + def test_post_with_invalid_data(self, mocked_messages): + """ + Validate the view handles post requests correctly. + """ + mocked_messages.add_message = mock.MagicMock() + request = RequestFactory().post(path=f'/job/{self.job.id}/skills', data={ + 'exclude_skills': [-1, 0], # Invalid choices + 'include_skills': [job_skill.skill.id for job_skill in self.blacklisted_industry_job_skills], + + }) + request.user = self.user + admin.site.get_app_list = mock.Mock(return_value=[]) + response = JobSkillsView.as_view()(request=request, job_pk=self.job.id) + + assert response.status_code == 302 + mocked_messages.add_message.assert_called_once_with( + request=mock.ANY, + level=mock.ANY, + message='Job skills could not be updated, please try again or contact support.', + ) + # Make sure no changes got made to the database. + job_skill_qs, industry_job_skill_qs = self.job.get_whitelisted_job_skills() + + assert job_skill_qs.count() == 5 + assert industry_job_skill_qs.count() == 5