diff --git a/futurex_openedx_extensions/helpers/admin.py b/futurex_openedx_extensions/helpers/admin.py index e08391a9..11c90e75 100644 --- a/futurex_openedx_extensions/helpers/admin.py +++ b/futurex_openedx_extensions/helpers/admin.py @@ -5,15 +5,28 @@ import yaml # type: ignore from django.contrib import admin +from django.contrib.admin import SimpleListFilter from django.core.cache import cache +from django.db.models import Q from django.http import Http404, HttpResponseRedirect from django.urls import path from django.utils import timezone +from django.utils.html import format_html from rest_framework.response import Response from simple_history.admin import SimpleHistoryAdmin from futurex_openedx_extensions.helpers.constants import CACHE_NAMES -from futurex_openedx_extensions.helpers.models import ClickhouseQuery, DataExportTask, ViewAllowedRoles +from futurex_openedx_extensions.helpers.models import ClickhouseQuery, DataExportTask, ViewAllowedRoles, ViewUserMapping + + +def get_field_span(value: str) -> str: + """Get the field span HTML.""" + defined = { + 'Yes': 'Yes', + 'No': 'No', + '?': f'?', + } + return format_html(defined.get(value, defined['?'])) class ClickhouseQueryAdmin(SimpleHistoryAdmin): @@ -62,6 +75,64 @@ class ViewAllowedRolesHistoryAdmin(SimpleHistoryAdmin): list_display = ('view_name', 'view_description', 'allowed_role') +class UsableFilter(SimpleListFilter): + title = 'Usable' + parameter_name = 'usable' + + def lookups(self, request, model_admin): + """Define filter options.""" + return ( + ('yes', 'Yes'), + ('no', 'No'), + ) + + def queryset(self, request, queryset): + """Filter the queryset based on the selected option.""" + if self.value() == 'yes': + return queryset.filter( + user__is_active=True, enabled=True, + ).filter( + Q(expires_at__isnull=True) | Q(expires_at__gt=timezone.now()), + ) + if self.value() == 'no': + return queryset.filter( + Q(user__is_active=False) | + Q(enabled=False) | + (Q(expires_at__isnull=False) | Q(expires_at__lt=timezone.now())), + ) + + return queryset + + +class ViewUserMappingHistoryAdmin(SimpleHistoryAdmin): + """Admin view for the ViewUserMapping model.""" + list_display = ('user', 'view_name', 'enabled', 'expires_at', 'user_active', 'has_access_role', 'usable') + list_filter = ('view_name', 'enabled', 'expires_at', UsableFilter) + search_fields = ('user__username', 'user__email') + + def user_active(self, obj): + """Check if the user is active or not.""" + if obj.user.is_active: + return get_field_span('Yes') + return get_field_span('No') + + def usable(self, obj): + """Check if the mapping link is usable.""" + if obj.usable: + return get_field_span('Yes') + return get_field_span('No') + + def has_access_role(self, obj): + """Check if the user has access role.""" + if obj.usable: + return get_field_span('Yes') + return get_field_span('No') + + user_active.short_description = 'User is Active' + has_access_role.short_description = 'Has Access Role' + usable.short_description = 'Usable' + + class CacheInvalidator(ViewAllowedRoles): """Dummy class to be able to register the Non-Model admin view CacheInvalidatorAdmin.""" class Meta: @@ -150,6 +221,7 @@ def register_admins() -> None: admin.site.register(CacheInvalidator, CacheInvalidatorAdmin) admin.site.register(ClickhouseQuery, ClickhouseQueryAdmin) admin.site.register(ViewAllowedRoles, ViewAllowedRolesHistoryAdmin) + admin.site.register(ViewUserMapping, ViewUserMappingHistoryAdmin) admin.site.register(DataExportTask, DataExportTaskAdmin) diff --git a/futurex_openedx_extensions/helpers/apps.py b/futurex_openedx_extensions/helpers/apps.py index 9c084d05..9e485641 100644 --- a/futurex_openedx_extensions/helpers/apps.py +++ b/futurex_openedx_extensions/helpers/apps.py @@ -29,4 +29,5 @@ class HelpersConfig(AppConfig): def ready(self) -> None: """Connect handlers to send notifications about discussions.""" + from futurex_openedx_extensions.helpers import custom_roles # pylint: disable=unused-import, import-outside-toplevel from futurex_openedx_extensions.helpers import signals # pylint: disable=unused-import, import-outside-toplevel diff --git a/futurex_openedx_extensions/helpers/constants.py b/futurex_openedx_extensions/helpers/constants.py index 7ab63d0c..f211b952 100644 --- a/futurex_openedx_extensions/helpers/constants.py +++ b/futurex_openedx_extensions/helpers/constants.py @@ -50,6 +50,13 @@ COURSE_CREATOR_ROLE_TENANT = 'org_course_creator_group' COURSE_CREATOR_ROLE_GLOBAL = 'course_creator_group' COURSE_SUPPORT_ROLE_GLOBAL = 'support' +COURSE_FX_API_ACCESS_ROLE = 'fx_api_access' +COURSE_FX_API_ACCESS_ROLE_GLOBAL = 'fx_api_access_global' + +COURSE_ACCESS_ROLES_USER_VIEW_MAPPING = [ + COURSE_FX_API_ACCESS_ROLE, + COURSE_FX_API_ACCESS_ROLE_GLOBAL +] COURSE_ACCESS_ROLES_COURSE_ONLY = [ 'beta_testers', @@ -68,18 +75,24 @@ 'instructor', COURSE_ACCESS_ROLES_LIBRARY_USER, COURSE_ACCESS_ROLES_STAFF_EDITOR, + COURSE_FX_API_ACCESS_ROLE, ] COURSE_ACCESS_ROLES_GLOBAL = [ COURSE_CREATOR_ROLE_GLOBAL, COURSE_SUPPORT_ROLE_GLOBAL, + COURSE_FX_API_ACCESS_ROLE_GLOBAL, ] -COURSE_ACCESS_ROLES_SUPPORTED_EDIT = \ - COURSE_ACCESS_ROLES_COURSE_ONLY + \ - COURSE_ACCESS_ROLES_TENANT_ONLY + \ +COURSE_ACCESS_ROLES_SUPPORTED_EDIT = list(set( + COURSE_ACCESS_ROLES_COURSE_ONLY + + COURSE_ACCESS_ROLES_TENANT_ONLY + COURSE_ACCESS_ROLES_TENANT_OR_COURSE +) - {COURSE_FX_API_ACCESS_ROLE}) -COURSE_ACCESS_ROLES_SUPPORTED_READ = COURSE_ACCESS_ROLES_SUPPORTED_EDIT + COURSE_ACCESS_ROLES_GLOBAL +COURSE_ACCESS_ROLES_SUPPORTED_READ = list( + set(COURSE_ACCESS_ROLES_SUPPORTED_EDIT + COURSE_ACCESS_ROLES_GLOBAL) | + {COURSE_FX_API_ACCESS_ROLE} +) COURSE_ACCESS_ROLES_ACCEPT_COURSE_ID = COURSE_ACCESS_ROLES_COURSE_ONLY + COURSE_ACCESS_ROLES_TENANT_OR_COURSE diff --git a/futurex_openedx_extensions/helpers/custom_roles.py b/futurex_openedx_extensions/helpers/custom_roles.py new file mode 100644 index 00000000..112cd98c --- /dev/null +++ b/futurex_openedx_extensions/helpers/custom_roles.py @@ -0,0 +1,63 @@ +"""New roles for FutureX Open edX Extensions.""" +import logging + +from common.djangoapps.student.admin import CourseAccessRoleForm +from common.djangoapps.student.roles import CourseRole, OrgRole, REGISTERED_ACCESS_ROLES, RoleBase + +from futurex_openedx_extensions.helpers.exceptions import FXCodedException, FXExceptionCodes +from futurex_openedx_extensions.helpers.models import ViewAllowedRoles + + +log = logging.getLogger(__name__) + + +def register_custom_access_role(cls): + """ + Decorator that adds the new access role to the list of registered access roles to be accessible in the Django admin. + + Note: roles inheritances is not supported + """ + try: + role_name = cls.ROLE + if role_name in REGISTERED_ACCESS_ROLES: + raise FXCodedException( + code=FXExceptionCodes.CUSTOM_ROLE_DUPLICATE_DECLARATION, + message=f"Trying to register a custom role {role_name} that is already registered!" + ) + except AttributeError: + log.exception('Role class %s does not have a ROLE attribute', cls) + except FXCodedException as exc: + log.exception(str(exc)) + else: + REGISTERED_ACCESS_ROLES[role_name] = cls + CourseAccessRoleForm.COURSE_ACCESS_ROLES.append((cls.ROLE, cls.ROLE)) + CourseAccessRoleForm.declared_fields['role'].choices = CourseAccessRoleForm.COURSE_ACCESS_ROLES + ViewAllowedRoles.allowed_role.field.choices = CourseAccessRoleForm.COURSE_ACCESS_ROLES + + return cls + + +@register_custom_access_role +class FXAPIAccessRoleCourse(CourseRole): + """Course specific access to the FutureX APIs.""" + ROLE = 'fx_api_access' + + def __init__(self, *args, **kwargs): + super().__init__(self.ROLE, *args, **kwargs) + + +class FXAPIAccessRoleOrg(OrgRole): + """Tenant-wide access to the FutureX APIs.""" + ROLE = 'fx_api_access' + + def __init__(self, *args, **kwargs): + super().__init__(self.ROLE, *args, **kwargs) + + +@register_custom_access_role +class FXAPIAccessRoleGlobal(RoleBase): + """Global access to the FutureX APIs.""" + ROLE = 'fx_api_access_global' + + def __init__(self, *args, **kwargs): + super().__init__(self.ROLE, *args, **kwargs) diff --git a/futurex_openedx_extensions/helpers/exceptions.py b/futurex_openedx_extensions/helpers/exceptions.py index 10dc5e8b..71fcb0dc 100644 --- a/futurex_openedx_extensions/helpers/exceptions.py +++ b/futurex_openedx_extensions/helpers/exceptions.py @@ -38,6 +38,8 @@ class FXExceptionCodes(Enum): SERIALIZER_FILED_NAME_DOES_NOT_EXIST = 7001 + CUSTOM_ROLE_DUPLICATE_DECLARATION = 9001 + class FXCodedException(Exception): """Exception with a code.""" diff --git a/futurex_openedx_extensions/helpers/migrations/0005_view_user_mapping.py b/futurex_openedx_extensions/helpers/migrations/0005_view_user_mapping.py new file mode 100644 index 00000000..f60fbf7d --- /dev/null +++ b/futurex_openedx_extensions/helpers/migrations/0005_view_user_mapping.py @@ -0,0 +1,68 @@ +# Generated by Django 3.2.25 on 2024-11-19 05:51 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import simple_history.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('fx_helpers', '0004_dataexporttask'), + ] + + operations = [ + migrations.AlterField( + model_name='historicalviewallowedroles', + name='allowed_role', + field=models.CharField(choices=[('fx_api_access', 'fx_api_access'), ('fx_api_access_global', 'fx_api_access_global')], max_length=255), + ), + migrations.AlterField( + model_name='viewallowedroles', + name='allowed_role', + field=models.CharField(choices=[('fx_api_access', 'fx_api_access'), ('fx_api_access_global', 'fx_api_access_global')], max_length=255), + ), + migrations.AlterField( + model_name='viewallowedroles', + name='view_name', + field=models.CharField(choices=[('clickhouse_query_fetcher', 'clickhouse_query_fetcher'), ('course_statuses', 'course_statuses'), ('courses_list', 'courses_list'), ('exported_files_data', 'exported_files_data'), ('global_rating', 'global_rating'), ('learner_courses', 'learner_courses'), ('learner_detailed_info', 'learner_detailed_info'), ('learners_list', 'learners_list'), ('learners_with_details_for_course', 'learners_with_details_for_course'), ('my_roles', 'my_roles'), ('total_counts_statistics', 'total_counts_statistics'), ('user_roles', 'user_roles')], max_length=255), + ), + migrations.CreateModel( + name='HistoricalViewUserMapping', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('view_name', models.CharField(max_length=255)), + ('enabled', models.BooleanField(default=True)), + ('expires_at', models.DateTimeField(blank=True, null=True)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'historical View-User Mapping', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': 'history_date', + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='ViewUserMapping', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('view_name', models.CharField(choices=[('clickhouse_query_fetcher', 'clickhouse_query_fetcher'), ('course_statuses', 'course_statuses'), ('courses_list', 'courses_list'), ('exported_files_data', 'exported_files_data'), ('global_rating', 'global_rating'), ('learner_courses', 'learner_courses'), ('learner_detailed_info', 'learner_detailed_info'), ('learners_list', 'learners_list'), ('learners_with_details_for_course', 'learners_with_details_for_course'), ('my_roles', 'my_roles'), ('total_counts_statistics', 'total_counts_statistics'), ('user_roles', 'user_roles')], max_length=255)), + ('enabled', models.BooleanField(default=True)), + ('expires_at', models.DateTimeField(blank=True, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'View-User Mapping', + 'verbose_name_plural': 'Views-Users Mapping', + 'unique_together': {('view_name', 'user')}, + }, + ), + ] diff --git a/futurex_openedx_extensions/helpers/models.py b/futurex_openedx_extensions/helpers/models.py index f17856cd..983a049f 100644 --- a/futurex_openedx_extensions/helpers/models.py +++ b/futurex_openedx_extensions/helpers/models.py @@ -4,15 +4,21 @@ import re from typing import Any, Dict, List, Tuple +from common.djangoapps.student.admin import CourseAccessRoleForm +from common.djangoapps.student.models import CourseAccessRole from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.db import models +from django.db.models import Q from django.utils import timezone from eox_tenant.models import TenantConfig +from opaque_keys.edx.django.models import CourseKeyField from simple_history.models import HistoricalRecords from futurex_openedx_extensions.helpers import clickhouse_operations as ch +from futurex_openedx_extensions.helpers import constants as cs from futurex_openedx_extensions.helpers.converters import DateMethods +from futurex_openedx_extensions.helpers.converters import get_allowed_roles from futurex_openedx_extensions.helpers.exceptions import FXCodedException, FXExceptionCodes User = get_user_model() @@ -22,7 +28,7 @@ class ViewAllowedRoles(models.Model): """Allowed roles for every supported view""" view_name = models.CharField(max_length=255) view_description = models.CharField(null=True, blank=True, max_length=255) - allowed_role = models.CharField(max_length=255) + allowed_role = models.CharField(max_length=255, choices=CourseAccessRoleForm.COURSE_ACCESS_ROLES) allow_write = models.BooleanField(default=False) history = HistoricalRecords() @@ -34,6 +40,70 @@ class Meta: unique_together = ('view_name', 'allowed_role') +class ViewUserMapping(models.Model): + """Allowed roles for every supported view""" + user = models.ForeignKey(User, on_delete=models.CASCADE) + view_name = models.CharField(max_length=255) + enabled = models.BooleanField(default=True) + expires_at = models.DateTimeField(null=True, blank=True) + + history = HistoricalRecords() + + @property + def usable(self): + """Check if the mapping is usable""" + allowed = self.enabled and self.user.is_active and (self.expires_at is None or self.expires_at > timezone.now()) + return allowed and self.has_access_role + + @property + def has_access_role(self): + """Check if the user has at least one of view-user-mapping access roles""" + allowed_roles = get_allowed_roles(cs.COURSE_ACCESS_ROLES_USER_VIEW_MAPPING) + return CourseAccessRole.objects.filter( + user=self.user, + ).filter( + ( + Q(role__in=allowed_roles['global']) & + Q(org='') & + Q(course_id=CourseKeyField.Empty) + ) | + ( + Q(role__in=allowed_roles['tenant_only']) & + ~Q(org='') + ) | + ( + Q(role__in=allowed_roles['course_only']) & + ~Q(org='') & + ~Q(course_id=CourseKeyField.Empty) + ) | + ( + Q(role__in=allowed_roles['tenant_or_course']) & + ~Q(org='') + ), + ).exists() + + @classmethod + def is_usable_access(cls, user: User, view_name: str) -> bool: + """ + Check if the user has usable access to the view. + + :param user: The user to check. + :type user: User + :param view_name: The name of the view. + :type view_name: str + :return: True if the user has usable access to the view. + :rtype: bool + """ + record = cls.objects.filter(user=user, view_name=view_name).first() + return record is not None and record.usable + + class Meta: + """Metaclass for the model""" + verbose_name = 'View-User Mapping' + verbose_name_plural = 'Views-Users Mapping' + unique_together = ('view_name', 'user') + + class ClickhouseQuery(models.Model): """Model for storing Clickhouse queries""" SCOPE_COURSE = 'course' diff --git a/futurex_openedx_extensions/helpers/permissions.py b/futurex_openedx_extensions/helpers/permissions.py index 1176c6bd..aef9138a 100644 --- a/futurex_openedx_extensions/helpers/permissions.py +++ b/futurex_openedx_extensions/helpers/permissions.py @@ -48,8 +48,9 @@ def get_tenant_limited_fx_permission_info(fx_permission_info: dict, tenant_id: i return fx_permission_info_one_tenant +from oauth2_provider.contrib.rest_framework.permissions import IsAuthenticatedOrTokenHasScope -class FXBaseAuthenticatedPermission(IsAuthenticated): +class FXBaseAuthenticatedPermission(IsAuthenticatedOrTokenHasScope): """Base permission class for FutureX Open edX Extensions.""" def verify_access_roles(self, request: Any, view: Any) -> bool: @@ -95,10 +96,16 @@ def has_permission(self, request: Any, view: Any) -> bool: f'permission class ({self.__class__.__name__})' ) + print('------------------01') if not super().has_permission(request, view) or not request.user.is_active: + print(request.user) + print('------------------!!!!!!!!!!!!!!!!!!!!') raise NotAuthenticated() + print('------------------02') - view_allowed_roles: List[str] = view.get_allowed_roles_all_views()[view.fx_view_name] + view_allowed_roles: List[str] = view.get_allowed_roles_on_view_with_user_mapping( + view_name=view.fx_view_name, user=request.user, + ) tenant_ids_string: str | None = request.GET.get('tenant_ids') if tenant_ids_string: diff --git a/futurex_openedx_extensions/helpers/roles.py b/futurex_openedx_extensions/helpers/roles.py index 08d7c525..e8f28cd3 100644 --- a/futurex_openedx_extensions/helpers/roles.py +++ b/futurex_openedx_extensions/helpers/roles.py @@ -16,6 +16,8 @@ from django.db.models.functions import Lower from opaque_keys.edx.django.models import CourseKeyField from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.lib.api.authentication import BearerAuthentication +from rest_framework.authentication import SessionAuthentication from xmodule.modulestore.django import modulestore from futurex_openedx_extensions.helpers import constants as cs @@ -33,7 +35,7 @@ get_orgs_of_courses, verify_course_ids, ) -from futurex_openedx_extensions.helpers.models import ViewAllowedRoles +from futurex_openedx_extensions.helpers.models import ViewAllowedRoles, ViewUserMapping from futurex_openedx_extensions.helpers.querysets import check_staff_exist_queryset from futurex_openedx_extensions.helpers.tenants import ( get_all_tenant_ids, @@ -431,6 +433,8 @@ def __init__(cls, name: str, bases: Tuple, attrs: Dict[str, Any]) -> None: 'default_read_write_roles': cls.fx_default_read_write_roles, } cls._fx_views_with_roles['_all_view_names'][cls.fx_view_name] = cls + ViewAllowedRoles.view_name.field.choices = [(view_name, view_name) for view_name in sorted(cls._fx_views_with_roles['_all_view_names'])] + ViewUserMapping.view_name.field.choices = [(view_name, view_name) for view_name in sorted(cls._fx_views_with_roles['_all_view_names'])] def get_fx_view_with_roles() -> dict: @@ -524,6 +528,28 @@ def get_allowed_roles_all_views() -> Dict[str, List[str]]: return result + def get_allowed_roles_on_view_with_user_mapping(self, view_name: str, user: get_user_model) -> List[str]: + """ + Get the allowed roles on the view for the user. + + :param view_name: The view name + :type view_name: str + :param user: The user + :type user: get_user_model + :return: The allowed roles on the view for the user + :rtype: list + """ + view_allowed_roles = self.get_allowed_roles_all_views().get(view_name, []) + if is_system_staff_user(user): + return view_allowed_roles + + if ViewUserMapping.is_usable_access(user, view_name): + view_allowed_roles.extend(cs.COURSE_ACCESS_ROLES_USER_VIEW_MAPPING) + else: + view_allowed_roles = list(set(view_allowed_roles) - set(cs.COURSE_ACCESS_ROLES_USER_VIEW_MAPPING)) + + return view_allowed_roles + def get_usernames_with_access_roles(orgs: list[str], active_filter: None | bool = None) -> list[str]: """ diff --git a/test_utils/edx_platform_mocks/common/djangoapps/student/admin.py b/test_utils/edx_platform_mocks/common/djangoapps/student/admin.py new file mode 100644 index 00000000..21d00646 --- /dev/null +++ b/test_utils/edx_platform_mocks/common/djangoapps/student/admin.py @@ -0,0 +1,2 @@ +"""edx-platform Mocks""" +from fake_models.models import CourseAccessRoleForm # pylint: disable=unused-import diff --git a/test_utils/edx_platform_mocks/common/djangoapps/student/roles.py b/test_utils/edx_platform_mocks/common/djangoapps/student/roles.py new file mode 100644 index 00000000..9bed9e29 --- /dev/null +++ b/test_utils/edx_platform_mocks/common/djangoapps/student/roles.py @@ -0,0 +1,2 @@ +"""edx-platform Mocks""" +from fake_models.classes import CourseRole, OrgRole, REGISTERED_ACCESS_ROLES, RoleBase # pylint: disable=unused-import diff --git a/test_utils/edx_platform_mocks/fake_models/classes.py b/test_utils/edx_platform_mocks/fake_models/classes.py new file mode 100644 index 00000000..4cad3588 --- /dev/null +++ b/test_utils/edx_platform_mocks/fake_models/classes.py @@ -0,0 +1,17 @@ +"""edx-platform classes mocks for testing purposes.""" + + +class RoleBase: + def __init__(self, role, *args, **kwargs): # pylint: disable=unused-argument + pass + + +class CourseRole(RoleBase): + pass + + +class OrgRole(RoleBase): + pass + + +REGISTERED_ACCESS_ROLES = {} diff --git a/test_utils/edx_platform_mocks/fake_models/models.py b/test_utils/edx_platform_mocks/fake_models/models.py index d5be8c82..49d37ec6 100644 --- a/test_utils/edx_platform_mocks/fake_models/models.py +++ b/test_utils/edx_platform_mocks/fake_models/models.py @@ -1,6 +1,7 @@ """edx-platform models mocks for testing purposes.""" import re +from django import forms from django.contrib.auth import get_user_model from django.db import models from django.db.models.fields import AutoField @@ -306,3 +307,12 @@ class ExtraInfo(models.Model): class Meta: app_label = 'fake_models' db_table = 'custom_reg_form_extra_info' + + +class CourseAccessRoleForm(forms.ModelForm): + class Meta: + model = CourseAccessRole + fields = '__all__' + + COURSE_ACCESS_ROLES = [] + role = forms.ChoiceField(choices=COURSE_ACCESS_ROLES) diff --git a/tox.ini b/tox.ini index 5c13d506..e160290c 100644 --- a/tox.ini +++ b/tox.ini @@ -45,6 +45,7 @@ deps = commands = rm -Rf {toxinidir}/test_utils/edx_platform_mocks/fake_models/migrations python manage.py makemigrations fake_models + python manage.py makemigrations fx_helpers python manage.py check pytest {posargs}