diff --git a/CHANGELOG.rst b/CHANGELOG.rst index fbd5f0fd..bbcca1b3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -13,6 +13,10 @@ Change Log Unreleased +[1.22.2] - 2022-09-06 +--------------------- +* Added support in Skills Quiz API to accept name of skills instead of primary keys. + [1.22.1] - 2022-08-26 --------------------- * Added id field in JobSerializer for Algolia. diff --git a/taxonomy/__init__.py b/taxonomy/__init__.py index ad322695..ab1832a7 100644 --- a/taxonomy/__init__.py +++ b/taxonomy/__init__.py @@ -15,6 +15,6 @@ # 2. MINOR version when you add functionality in a backwards compatible manner, and # 3. PATCH version when you make backwards compatible bug fixes. # More details can be found at https://semver.org/ -__version__ = '1.22.1' +__version__ = '1.22.2' default_app_config = 'taxonomy.apps.TaxonomyConfig' # pylint: disable=invalid-name diff --git a/taxonomy/admin.py b/taxonomy/admin.py index 99bd8663..f723541f 100644 --- a/taxonomy/admin.py +++ b/taxonomy/admin.py @@ -9,7 +9,15 @@ from django.contrib import admin from taxonomy.models import ( - CourseSkills, Job, JobPostings, JobSkills, Skill, Translation, SkillCategory, SkillSubCategory, SkillsQuiz + CourseSkills, + Job, + JobPostings, + JobSkills, + Skill, + SkillCategory, + SkillsQuiz, + SkillSubCategory, + Translation, ) diff --git a/taxonomy/api/v1/serializers.py b/taxonomy/api/v1/serializers.py index 00920abe..53816014 100644 --- a/taxonomy/api/v1/serializers.py +++ b/taxonomy/api/v1/serializers.py @@ -1,6 +1,7 @@ """ Taxonomy API serializers. """ +from rest_framework import serializers from rest_framework.serializers import ModelSerializer from taxonomy.models import CourseSkills, Job, JobPostings, JobSkills, Skill, SkillsQuiz @@ -66,3 +67,25 @@ class Meta: model = SkillsQuiz fields = '__all__' read_only_fields = ('username', ) + + +class SkillsQuizBySkillNameSerializer(ModelSerializer): + skill_names = serializers.ListField(child=serializers.CharField(), required=False) + + class Meta: + model = SkillsQuiz + fields = '__all__' + read_only_fields = ('username', 'skills') + + def validate(self, attrs): + attrs = super().validate(attrs) + skill_names = attrs.pop('skill_names') + attrs['skills'] = Skill.get_skill_ids_by_name(skill_names) + return attrs + + def validate_skill_names(self, skill_names): + valid_skill_names = Skill.objects.filter(name__in=skill_names).values_list('name', flat=True) + if len(valid_skill_names) < len(skill_names): + invalid_skill_names = list(set(skill_names) - set(valid_skill_names)) + raise serializers.ValidationError(f"Invalid skill names: {invalid_skill_names}") + return skill_names diff --git a/taxonomy/api/v1/views.py b/taxonomy/api/v1/views.py index b33a4fa8..3082f58a 100644 --- a/taxonomy/api/v1/views.py +++ b/taxonomy/api/v1/views.py @@ -1,18 +1,23 @@ """ Taxonomy API views. """ -from rest_framework import permissions +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import permissions, status from rest_framework.mixins import ListModelMixin, RetrieveModelMixin +from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet, ModelViewSet -from django_filters.rest_framework import DjangoFilterBackend from django.db.models import Prefetch +from taxonomy.api.permissions import IsOwner from taxonomy.api.v1.serializers import ( - JobPostingsSerializer, JobsListSerializer, SkillListSerializer, SkillsQuizSerializer + JobPostingsSerializer, + JobsListSerializer, + SkillListSerializer, + SkillsQuizSerializer, + SkillsQuizBySkillNameSerializer, ) from taxonomy.models import CourseSkills, Job, JobPostings, Skill, SkillsQuiz -from taxonomy.api.permissions import IsOwner class TaxonomyAPIViewSetMixin: @@ -76,7 +81,6 @@ class SkillsQuizViewSet(TaxonomyAPIViewSetMixin, ModelViewSet): """ ViewSet to list and retrieve all JobPostings in the system. """ - serializer_class = SkillsQuizSerializer permission_classes = (permissions.IsAuthenticated, IsOwner | permissions.IsAdminUser, ) filter_backends = (DjangoFilterBackend,) filterset_fields = ('username', ) @@ -99,3 +103,8 @@ def get_queryset(self): queryset = queryset.filter(username=self.request.user.username) return queryset.all().select_related('current_job').prefetch_related('skills', 'future_jobs') + + def get_serializer_class(self): + if self.action == 'create': + return SkillsQuizBySkillNameSerializer + return SkillsQuizSerializer diff --git a/taxonomy/models.py b/taxonomy/models.py index 654c6d36..379d1950 100644 --- a/taxonomy/models.py +++ b/taxonomy/models.py @@ -5,6 +5,7 @@ from __future__ import unicode_literals import uuid +from typing import List from solo.models import SingletonModel @@ -12,6 +13,7 @@ from django.utils.translation import gettext_lazy as _ from model_utils.models import TimeStampedModel + from taxonomy.choices import UserGoal @@ -93,6 +95,13 @@ def __repr__(self): """ return ''.format(self.id, self.name) + @classmethod + def get_skill_ids_by_name(cls, skill_names: List[str]) -> List[int]: + """ + Return all the matching skill IDs from given skill names. + """ + return list(cls.objects.filter(name__in=skill_names).values_list('id', flat=True)) + class Meta: """ Meta configuration for Skill model. diff --git a/taxonomy/utils.py b/taxonomy/utils.py index 139a5019..68b88a14 100644 --- a/taxonomy/utils.py +++ b/taxonomy/utils.py @@ -3,22 +3,22 @@ """ import logging import time -import boto3 +import boto3 from bs4 import BeautifulSoup -from edx_django_utils.cache import get_cache_key, TieredCache +from edx_django_utils.cache import TieredCache, get_cache_key from taxonomy.constants import ( AMAZON_TRANSLATION_ALLOWED_SIZE, AUTO, + EMSI_API_RATE_LIMIT_PER_SEC, ENGLISH, REGION, TRANSLATE_SERVICE, - EMSI_API_RATE_LIMIT_PER_SEC ) from taxonomy.emsi.client import EMSISkillsApiClient from taxonomy.exceptions import TaxonomyAPIError -from taxonomy.models import CourseSkills, ProgramSkill, JobSkills, Skill, Translation +from taxonomy.models import CourseSkills, JobSkills, ProgramSkill, Skill, Translation from taxonomy.serializers import SkillSerializer LOGGER = logging.getLogger(__name__) diff --git a/test_settings.py b/test_settings.py index 027f8e83..1049427e 100644 --- a/test_settings.py +++ b/test_settings.py @@ -42,10 +42,16 @@ def root(*args): root('taxonomy', 'conf', 'locale'), ] -# ROOT_URLCONF = 'taxonomy.urls' +ROOT_URLCONF = 'taxonomy.urls' SECRET_KEY = 'insecure-secret-key' +MIDDLEWARE = ( + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.contrib.sites.middleware.CurrentSiteMiddleware', +) # Settings related to to EMSI client # API URLs are altered to avoid accidentally calling the API in tests diff --git a/tests/test_models.py b/tests/test_models.py index a1c6c3cb..96df8c01 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -7,7 +7,7 @@ from django.test import TestCase -from taxonomy.models import Job, JobPostings +from taxonomy.models import Job, JobPostings, Skill from test_utils import factories @@ -68,6 +68,16 @@ def test_string_representation(self): assert expected_str == skill.__str__() assert expected_repr == skill.__repr__() + def test_get_skill_ids_by_name(self): + """ + Test the ``get_skill_ids_by_name`` Return correct IDs. + """ + skill_a = factories.SkillFactory() + skill_b = factories.SkillFactory() + skill_c = factories.SkillFactory() # pylint: disable=unused-variable + skill_ids = Skill.get_skill_ids_by_name([skill_a.name, skill_b.name]) + assert skill_ids == [skill_a.id, skill_b.id] + @mark.django_db class TestCourseSkills(TestCase): diff --git a/tests/test_signals.py b/tests/test_signals.py index af2dec68..452624df 100644 --- a/tests/test_signals.py +++ b/tests/test_signals.py @@ -6,7 +6,7 @@ import mock from pytest import mark -from taxonomy.models import CourseSkills, Skill, ProgramSkill +from taxonomy.models import CourseSkills, ProgramSkill, Skill from taxonomy.signals.signals import UPDATE_COURSE_SKILLS, UPDATE_PROGRAM_SKILLS from test_utils.mocks import MockCourse, MockProgram from test_utils.providers import DiscoveryCourseMetadataProvider, DiscoveryProgramMetadataProvider diff --git a/tests/test_tasks.py b/tests/test_tasks.py index bb5e1781..e5c671fc 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -8,7 +8,7 @@ from pytest import mark from testfixtures import LogCapture -from taxonomy.models import CourseSkills, Skill, ProgramSkill +from taxonomy.models import CourseSkills, ProgramSkill, Skill from taxonomy.tasks import update_course_skills, update_program_skills from test_utils.mocks import MockCourse, MockProgram from test_utils.providers import DiscoveryCourseMetadataProvider, DiscoveryProgramMetadataProvider diff --git a/tests/test_views.py b/tests/test_views.py new file mode 100644 index 00000000..565e60d1 --- /dev/null +++ b/tests/test_views.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +""" +Tests for the taxonomy API views. +""" + +import json + +from pytest import mark + +from django.contrib.auth import get_user_model +from django.test import Client, TestCase + +from test_utils.factories import JobFactory, SkillFactory + +User = get_user_model() # pylint: disable=invalid-name +USER_PASSWORD = 'QWERTY' + + +@mark.django_db +class TestSkillsQuizViewSet(TestCase): + """ + Tests for ``SkillsQuizViewSet`` view set. + """ + + def setUp(self) -> None: + super(TestSkillsQuizViewSet, self).setUp() + self.skill_a = SkillFactory() + self.skill_b = SkillFactory() + self.job_a = JobFactory() + self.job_b = JobFactory() + self.user = User.objects.create(username="rocky") + self.user.set_password(USER_PASSWORD) + self.user.save() + self.client = Client() + self.client.login(username=self.user.username, password=USER_PASSWORD) + self.view_url = r'/api/v1/skills-quiz/' + + def test_skills_quiz_api_post(self): + """ + Test the Post endpoint of API. + """ + post_data = { + 'goal': 'change_careers', + 'current_job': self.job_a.id, + 'skill_names': [self.skill_a.name, self.skill_b.name], + 'future_jobs': [self.job_a.id, self.job_b.id] + } + response = self.client.post(self.view_url, json.dumps(post_data), 'application/json') + assert response.status_code == 201 + response = response.json() + assert response['goal'] == post_data['goal'] + assert response['current_job'] == post_data['current_job'] + assert response['username'] == self.user.username + assert response['skills'] + assert response['skills'] == [self.skill_a.id, self.skill_b.id] + assert response['future_jobs'] == post_data['future_jobs'] + assert 'skill_names' not in response + + def test_validation_error_for_skill_names(self): + """ + Test the validation error if wrong skill is sent in the post data. + """ + unsaved_skill = 'skill not saved' + post_data = { + 'goal': 'change_careers', + 'current_job': self.job_a.id, + 'skill_names': [self.skill_a.name, unsaved_skill], + 'future_jobs': [self.job_a.id, self.job_b.id] + } + response = self.client.post(self.view_url, json.dumps(post_data), 'application/json') + assert response.status_code == 400 + response = response.json() + assert 'skill_names' in response + assert response['skill_names'] == [f"Invalid skill names: ['{unsaved_skill}']"]