Skip to content

Commit 46044fa

Browse files
feat: skill validation exemption
1 parent dfba55d commit 46044fa

12 files changed

+435
-6
lines changed

CHANGELOG.rst

+4
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ Change Log
1313
1414
Unreleased
1515

16+
[1.50.0] - 2024-03-27
17+
---------------------
18+
* feat: Skill validation can be disbaled for a course or an organization
19+
1620
[1.46.2] - 2024-02-14
1721
---------------------
1822
* feat: Optimized finalize_xblockskill_tags command for memory via chunking

taxonomy/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,6 @@
1515
# 2. MINOR version when you add functionality in a backwards compatible manner, and
1616
# 3. PATCH version when you make backwards compatible bug fixes.
1717
# More details can be found at https://semver.org/
18-
__version__ = '1.46.2'
18+
__version__ = '1.50.0'
1919

2020
default_app_config = 'taxonomy.apps.TaxonomyConfig' # pylint: disable=invalid-name

taxonomy/admin.py

+8
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
Translation,
3333
XBlockSkillData,
3434
XBlockSkills,
35+
SkillValidationConfiguration
3536
)
3637
from taxonomy.views import JobSkillsView
3738

@@ -289,3 +290,10 @@ def job_name(self, obj):
289290
Name of the related job.
290291
"""
291292
return obj.job.name
293+
294+
295+
@admin.register(SkillValidationConfiguration)
296+
class SkillValidationConfiguratonAdmin(admin.ModelAdmin):
297+
"""
298+
Admin view for SkillValidationConfiguration model.
299+
"""

taxonomy/api/v1/views.py

+10-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from django_filters.rest_framework import DjangoFilterBackend
77
from rest_framework import permissions
88
from rest_framework.filters import OrderingFilter
9-
from rest_framework.generics import RetrieveAPIView, ListAPIView
9+
from rest_framework.generics import ListAPIView, RetrieveAPIView
1010
from rest_framework.mixins import ListModelMixin, RetrieveModelMixin
1111
from rest_framework.response import Response
1212
from rest_framework.views import APIView
@@ -35,6 +35,7 @@
3535
SkillCategory,
3636
SkillsQuiz,
3737
SkillSubCategory,
38+
SkillValidationConfiguration,
3839
XBlockSkillData,
3940
XBlockSkills,
4041
)
@@ -292,6 +293,8 @@ def get(self, request, job_id):
292293
class XBlockSkillsViewSet(TaxonomyAPIViewSetMixin, RetrieveModelMixin, ListModelMixin, GenericViewSet):
293294
"""
294295
ViewSet to list and retrieve all XBlockSkills in the system.
296+
297+
If skill validation is disabled for a course, then return an empty queryset.
295298
"""
296299
serializer_class = XBlocksSkillsSerializer
297300
permission_classes = (permissions.IsAuthenticated, )
@@ -302,6 +305,12 @@ def get_queryset(self):
302305
"""
303306
Get all the xblocks skills with prefetch_related objects.
304307
"""
308+
skill_validation_disabled = SkillValidationConfiguration.is_disabled(
309+
self.request.query_params.get('course_key')
310+
)
311+
if skill_validation_disabled:
312+
return XBlockSkills.objects.none()
313+
305314
return XBlockSkills.objects.prefetch_related(
306315
Prefetch(
307316
'skills',
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Generated by Django 3.2.22 on 2024-03-25 16:31
2+
3+
from django.db import migrations, models
4+
import django.utils.timezone
5+
import model_utils.fields
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('taxonomy', '0035_auto_20231013_0324'),
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name='SkillValidationConfiguration',
17+
fields=[
18+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
19+
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
20+
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
21+
('course_key', models.CharField(blank=True, help_text='The course, for which skill validation is disabled.', max_length=255, null=True, unique=True)),
22+
('organization', models.CharField(blank=True, help_text='The organization, for which skill validation is disabled.', max_length=255, null=True, unique=True)),
23+
],
24+
options={
25+
'verbose_name': 'Skill Validation Configuration',
26+
'verbose_name_plural': 'Skill Validation Configurations',
27+
},
28+
),
29+
migrations.AddConstraint(
30+
model_name='skillvalidationconfiguration',
31+
constraint=models.CheckConstraint(check=models.Q(models.Q(('course_key__isnull', False), ('organization__isnull', True)), models.Q(('course_key__isnull', True), ('organization__isnull', False)), _connector='OR'), name='either_course_or_org'),
32+
),
33+
]

taxonomy/models.py

+136
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,24 @@
44
"""
55
from __future__ import unicode_literals
66

7+
import logging
78
import uuid
89

10+
from opaque_keys import InvalidKeyError
11+
from opaque_keys.edx.keys import CourseKey
912
from solo.models import SingletonModel
1013

1114
from django.core.exceptions import ValidationError
1215
from django.db import models
16+
from django.db.models import Q
1317
from django.utils.translation import gettext_lazy as _
1418

1519
from model_utils.models import TimeStampedModel
1620

1721
from taxonomy.choices import UserGoal
22+
from taxonomy.providers.utils import get_course_metadata_provider
23+
24+
LOGGER = logging.getLogger(__name__)
1825

1926

2027
class Skill(TimeStampedModel):
@@ -1144,3 +1151,132 @@ class Meta:
11441151
app_label = 'taxonomy'
11451152
verbose_name = 'B2C Job Allow List entry'
11461153
verbose_name_plural = 'B2C Job Allow List entries'
1154+
1155+
1156+
class SkillValidationConfiguration(TimeStampedModel):
1157+
"""
1158+
Model to store the configuration for disabling skill validation for a course or organization.
1159+
"""
1160+
1161+
course_key = models.CharField(
1162+
max_length=255,
1163+
null=True,
1164+
blank=True,
1165+
unique=True,
1166+
help_text=_('The course, for which skill validation is disabled.'),
1167+
)
1168+
organization = models.CharField(
1169+
max_length=255,
1170+
null=True,
1171+
blank=True,
1172+
unique=True,
1173+
help_text=_('The organization, for which skill validation is disabled.'),
1174+
)
1175+
1176+
def __str__(self):
1177+
"""
1178+
Create a human-readable string representation of the object.
1179+
"""
1180+
message = ''
1181+
1182+
if self.course_key:
1183+
message = f'Skill validation disabled for course: {self.course_key}'
1184+
elif self.organization:
1185+
message = f'Skill validation disabled for organization: {self.organization}'
1186+
1187+
return message
1188+
1189+
class Meta:
1190+
"""
1191+
Meta configuration for SkillValidationConfiguration model.
1192+
"""
1193+
1194+
constraints = [
1195+
models.CheckConstraint(
1196+
check=(
1197+
Q(course_key__isnull=False) &
1198+
Q(organization__isnull=True)
1199+
) | (
1200+
Q(course_key__isnull=True) &
1201+
Q(organization__isnull=False)
1202+
),
1203+
name='either_course_or_org',
1204+
# This only work on django >= 4.1
1205+
# violation_error_message='Select either course or organization.'
1206+
),
1207+
]
1208+
1209+
verbose_name = 'Skill Validation Configuration'
1210+
verbose_name_plural = 'Skill Validation Configurations'
1211+
1212+
def clean(self):
1213+
"""Override to add custom validation for course and organization fields."""
1214+
if self.course_key:
1215+
if not get_course_metadata_provider().is_valid_course(self.course_key):
1216+
raise ValidationError({
1217+
'course_key': f'Course with key {self.course_key} does not exist.'
1218+
})
1219+
1220+
if self.organization:
1221+
if not get_course_metadata_provider().is_valid_organization(self.organization):
1222+
raise ValidationError({
1223+
'organization': f'Organization with key {self.organization} does not exist.'
1224+
})
1225+
1226+
# pylint: disable=no-member
1227+
def validate_constraints(self, exclude=None):
1228+
"""
1229+
Validate all constraints defined in Meta.constraints.
1230+
1231+
NOTE: We override this method only to return a human readable message.
1232+
We should remove this override once taxonomy-connector is updated to django 4.1
1233+
On django >= 4.1, add violation_error_message in models.CheckConstraint with an appropriate message.
1234+
"""
1235+
try:
1236+
super().validate_constraints(exclude=exclude)
1237+
except ValidationError as ex:
1238+
raise ValidationError({'__all__': 'Add either course key or organization.'}) from ex
1239+
1240+
def save(self, *args, **kwargs):
1241+
"""Override to ensure that custom validation is always called."""
1242+
self.full_clean()
1243+
return super().save(*args, **kwargs)
1244+
1245+
@staticmethod
1246+
def is_valid_course_run_key(course_run_key):
1247+
"""
1248+
Check if the given course run key is in valid format.
1249+
1250+
Arguments:
1251+
course_run_key (str): Course run key
1252+
"""
1253+
try:
1254+
return True, CourseKey.from_string(course_run_key)
1255+
except InvalidKeyError:
1256+
LOGGER.error('[TAXONOMY_SKILL_VALIDATION_CONFIGURATION] Invalid course_run key: [%s]', course_run_key)
1257+
1258+
return False, None
1259+
1260+
@classmethod
1261+
def is_disabled(cls, course_run_key) -> bool:
1262+
"""
1263+
Check if skill validation is disabled for the given course run key.
1264+
1265+
Arguments:
1266+
course_run_key (str): Course run key
1267+
1268+
Returns:
1269+
bool: True if skill validation is disabled for the given course run key.
1270+
"""
1271+
is_valid_course_run_key, course_run_locator = cls.is_valid_course_run_key(course_run_key)
1272+
if not is_valid_course_run_key:
1273+
return False
1274+
1275+
if cls.objects.filter(organization=course_run_locator.org).exists():
1276+
return True
1277+
1278+
course_key = get_course_metadata_provider().get_course_key(course_run_key)
1279+
if course_key and cls.objects.filter(course_key=course_key).exists():
1280+
return True
1281+
1282+
return False

taxonomy/providers/course_metadata.py

+36
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,39 @@ def get_all_courses(self):
4646
4. short_description: Course's short description
4747
5. full_description: Course's full description
4848
"""
49+
50+
@abstractmethod
51+
def get_course_key(self, course_run_key):
52+
"""
53+
Get the course key for the given course run key.
54+
55+
Arguments:
56+
course_run_key(str): Course run key
57+
58+
Returns:
59+
str: course key
60+
"""
61+
62+
@abstractmethod
63+
def is_valid_course(self, course_key):
64+
"""
65+
Validate the course key.
66+
67+
Arguments:
68+
course_key(str): course key
69+
70+
Returns:
71+
bool: True if course is valid, False otherwise
72+
"""
73+
74+
@abstractmethod
75+
def is_valid_organization(self, organization_key):
76+
"""
77+
Validate the organization.
78+
79+
Arguments:
80+
organization(str): organization key
81+
82+
Returns:
83+
bool: True if organization is valid, False otherwise
84+
"""

taxonomy/validators/course_metadata.py

+37
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
55
All host platform must run this validator to make sure providers are working as expected.
66
"""
7+
import inspect
8+
79
from taxonomy.providers.utils import get_course_metadata_provider
810

911

@@ -23,6 +25,11 @@ def __init__(self, test_courses):
2325
self.test_courses = test_courses
2426
self.course_metadata_provider = get_course_metadata_provider()
2527

28+
@property
29+
def provider_class_name(self):
30+
"""Return the name of the provider class."""
31+
return self.course_metadata_provider.__class__.__name__
32+
2633
def validate(self):
2734
"""
2835
Validate CourseMetadataProvider implements the interface as expected.
@@ -32,6 +39,9 @@ def validate(self):
3239
"""
3340
self.validate_get_courses()
3441
self.validate_get_all_courses()
42+
self.validate_get_course_key()
43+
self.validate_is_valid_course()
44+
self.validate_is_valid_organization()
3545

3646
def validate_get_courses(self):
3747
"""
@@ -60,3 +70,30 @@ def validate_get_all_courses(self):
6070
assert 'title' in course
6171
assert 'short_description' in course
6272
assert 'full_description' in course
73+
74+
def validate_get_course_key(self):
75+
"""
76+
Validate `get_course_key` attribute is a callable and has the correct signature.
77+
"""
78+
get_course_key = getattr(self.course_metadata_provider, 'get_course_key') # pylint: disable=literal-used-as-attribute
79+
assert callable(get_course_key)
80+
assert_msg = f'Invalid method signature for {self.provider_class_name}.get_course_key'
81+
assert str(inspect.signature(get_course_key)) == '(course_run_key)', assert_msg
82+
83+
def validate_is_valid_course(self):
84+
"""
85+
Validate `is_valid_course` attribute is a callable and has the correct signature.
86+
"""
87+
is_valid_course = getattr(self.course_metadata_provider, 'is_valid_course') # pylint: disable=literal-used-as-attribute
88+
assert callable(is_valid_course)
89+
assert_msg = f'Invalid method signature for {self.provider_class_name}.is_valid_course'
90+
assert str(inspect.signature(is_valid_course)) == '(course_key)', assert_msg
91+
92+
def validate_is_valid_organization(self):
93+
"""
94+
Validate `is_valid_organization` attribute is a callable and has the correct signature.
95+
"""
96+
is_valid_organization = getattr(self.course_metadata_provider, 'is_valid_organization') # pylint: disable=literal-used-as-attribute
97+
assert callable(is_valid_organization)
98+
assert_msg = f'Invalid method signature for {self.provider_class_name}.is_valid_organization'
99+
assert str(inspect.signature(is_valid_organization)) == '(organization_key)', assert_msg

0 commit comments

Comments
 (0)