diff --git a/CHANGELOG.rst b/CHANGELOG.rst index cc565ebf4f..7eac69ded5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -17,6 +17,42 @@ Unreleased ---------- feat: enable enrolling leaners to invite_only courses via manage learners view +======= +[3.68.1] +-------- +fix: pick first object from CourseDetails + +[3.68.0] +-------- +feat: add more metadata into `EnterpriseCourseEnrollmentView` + +[3.67.7] +-------- +feat: marking orphaned content audits when catalogs are deleted + +[3.67.6] +-------- +chore: fixing doc string linter errors + +[3.67.5] +-------- +chore: better formatting of the enterprise api views + +[3.67.4] +-------- +feat: add button to update customer modified time + +[3.67.3] +-------- +feat: adding managent command to clear error state + +[3.67.2] +-------- +fix: fixing name of table used by model fetching method + +[3.67.1] +-------- +chore: more orphaned content transmission logging [3.67.0] -------- @@ -75,7 +111,7 @@ fix: making sure unenrollment is saved while revoking fulfillment [3.65.0] -------- -feat: new enterprise endpoint to surface filterable unenrolled subsidized enrollments +feat: new enterprise endpoint to surface filterable unenrolled subsidized enrollments [3.64.1] -------- @@ -84,10 +120,10 @@ fix: Reverted course_run_url for Executive Education courses [3.64.0] -------- feat: Updated course_run_url for Executive Education courses - + [3.63.0] -------- -feat: Hooking enterprise enrollments up to platform signals to write unenrollment records. +feat: Hooking enterprise enrollments up to platform signals to write unenrollment records. New field `unenrolled` on enterprise enrollments to track enrollment status, defaults to `None`. [3.62.7] diff --git a/enterprise/__init__.py b/enterprise/__init__.py index fd666bb331..ad74471075 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,6 +2,6 @@ Your project description goes here. """ -__version__ = "3.67.0" +__version__ = "3.68.1" default_app_config = "enterprise.apps.EnterpriseConfig" diff --git a/enterprise/api/throttles.py b/enterprise/api/throttles.py index fc5b4166f5..eed7fcea19 100644 --- a/enterprise/api/throttles.py +++ b/enterprise/api/throttles.py @@ -19,16 +19,16 @@ def allow_request(self, request, view): """ Modify throttling for service users. - Updates throttling rate if the request is coming from the service user, and - defaults to UserRateThrottle's configured setting otherwise. + Updates throttling rate if the request is coming from the service user, and defaults to UserRateThrottle's + configured setting otherwise. + + Updated throttling rate comes from `DEFAULT_THROTTLE_RATES` key in `REST_FRAMEWORK` setting. specific user + throttling is specified in `DEFAULT_THROTTLE_RATES` by it's corresponding key. + + .. code-block:: - Updated throttling rate comes from `DEFAULT_THROTTLE_RATES` key in `REST_FRAMEWORK` - setting. specific user throttling is specified in `DEFAULT_THROTTLE_RATES` by it's corresponding key - Example Setting: REST_FRAMEWORK = { - ... 'DEFAULT_THROTTLE_RATES': { - ... 'service_user': '50/day', 'high_service_user': '2000/minute', } diff --git a/enterprise/api/v1/urls.py b/enterprise/api/v1/urls.py index d0cd54e946..d82ee253eb 100644 --- a/enterprise/api/v1/urls.py +++ b/enterprise/api/v1/urls.py @@ -6,90 +6,118 @@ from django.urls import re_path -from enterprise.api.v1 import views +from enterprise.api.v1.views import ( + coupon_codes, + enterprise_catalog_query, + enterprise_course_enrollment, + enterprise_customer, + enterprise_customer_branding_configuration, + enterprise_customer_catalog, + enterprise_customer_invite_key, + enterprise_customer_reporting, + enterprise_customer_user, + enterprise_subsidy_fulfillment, + notifications, + pending_enterprise_customer_user, + plotly_auth, +) router = DefaultRouter() -router.register("enterprise_catalogs", views.EnterpriseCustomerCatalogViewSet, 'enterprise-catalogs') -router.register("enterprise-course-enrollment", views.EnterpriseCourseEnrollmentViewSet, 'enterprise-course-enrollment') +router.register( + "enterprise-course-enrollment", + enterprise_course_enrollment.EnterpriseCourseEnrollmentViewSet, + 'enterprise-course-enrollment', +) router.register( "licensed-enterprise-course-enrollment", - views.LicensedEnterpriseCourseEnrollmentViewSet, + enterprise_subsidy_fulfillment.LicensedEnterpriseCourseEnrollmentViewSet, 'licensed-enterprise-course-enrollment' ) -router.register("enterprise-customer", views.EnterpriseCustomerViewSet, 'enterprise-customer') -router.register("enterprise-learner", views.EnterpriseCustomerUserViewSet, 'enterprise-learner') -router.register("pending-enterprise-learner", views.PendingEnterpriseCustomerUserViewSet, 'pending-enterprise-learner') +router.register("enterprise-customer", enterprise_customer.EnterpriseCustomerViewSet, 'enterprise-customer') +router.register("enterprise-learner", enterprise_customer_user.EnterpriseCustomerUserViewSet, 'enterprise-learner') +router.register( + "pending-enterprise-learner", + pending_enterprise_customer_user.PendingEnterpriseCustomerUserViewSet, + 'pending-enterprise-learner', +) router.register( "enterprise-customer-branding", - views.EnterpriseCustomerBrandingConfigurationViewSet, + enterprise_customer_branding_configuration.EnterpriseCustomerBrandingConfigurationViewSet, 'enterprise-customer-branding', ) router.register( "enterprise_customer_reporting", - views.EnterpriseCustomerReportingConfigurationViewSet, + enterprise_customer_reporting.EnterpriseCustomerReportingConfigurationViewSet, 'enterprise-customer-reporting', ) router.register( "enterprise-customer-invite-key", - views.EnterpriseCustomerInviteKeyViewSet, + enterprise_customer_invite_key.EnterpriseCustomerInviteKeyViewSet, "enterprise-customer-invite-key" ) router.register( "enterprise_catalog_query", - views.EnterpriseCatalogQueryViewSet, + enterprise_catalog_query.EnterpriseCatalogQueryViewSet, "enterprise_catalog_query" ) router.register( "enterprise_customer_catalog", - views.EnterpriseCustomerCatalogWriteViewSet, + enterprise_customer_catalog.EnterpriseCustomerCatalogWriteViewSet, "enterprise_customer_catalog" ) +router.register( + "enterprise_catalogs", enterprise_customer_catalog.EnterpriseCustomerCatalogViewSet, 'enterprise-catalogs' +) urlpatterns = [ re_path( r'enterprise-subsidy-fulfillment/(?P[A-Za-z0-9-]+)/?$', - views.EnterpriseSubsidyFulfillmentViewSet.as_view({'get': 'retrieve'}), + enterprise_subsidy_fulfillment.EnterpriseSubsidyFulfillmentViewSet.as_view({'get': 'retrieve'}), name='enterprise-subsidy-fulfillment' ), re_path( r'enterprise-subsidy-fulfillment/(?P[A-Za-z0-9-]+)/cancel-fulfillment?$', - views.EnterpriseSubsidyFulfillmentViewSet.as_view({'post': 'cancel_enrollment'}), + enterprise_subsidy_fulfillment.EnterpriseSubsidyFulfillmentViewSet.as_view({'post': 'cancel_enrollment'}), name='enterprise-subsidy-fulfillment-cancel-enrollment' ), re_path( r'operator/enterprise-subsidy-fulfillment/unenrolled/?$', - views.EnterpriseSubsidyFulfillmentViewSet.as_view({'get': 'unenrolled'}), + enterprise_subsidy_fulfillment.EnterpriseSubsidyFulfillmentViewSet.as_view({'get': 'unenrolled'}), name='enterprise-subsidy-fulfillment-unenrolled' ), re_path( r'^read_notification$', - views.NotificationReadView.as_view(), + notifications.NotificationReadView.as_view(), name='read-notification' ), re_path( r'link_pending_enterprise_users/(?P[A-Za-z0-9-]+)/?$', - views.PendingEnterpriseCustomerUserEnterpriseAdminViewSet.as_view({'post': 'link_learners'}), + pending_enterprise_customer_user.PendingEnterpriseCustomerUserEnterpriseAdminViewSet.as_view( + {'post': 'link_learners'} + ), name='link-pending-enterprise-learner' ), re_path( r'^request_codes$', - views.CouponCodesView.as_view(), + coupon_codes.CouponCodesView.as_view(), name='request-codes' ), re_path( r'^plotly_token/(?P[A-Za-z0-9-]+)$', - views.PlotlyAuthView.as_view(), + plotly_auth.PlotlyAuthView.as_view(), name='plotly-token' ), re_path( r'^enterprise_report_types/(?P[A-Za-z0-9-]+)$', - views.EnterpriseCustomerReportTypesView.as_view(), + enterprise_customer_reporting.EnterpriseCustomerReportTypesView.as_view(), name='enterprise-report-types' ), re_path( r'^enterprise-customer-branding/update-branding/(?P[A-Za-z0-9-]+)/$', - views.EnterpriseCustomerBrandingConfigurationViewSet.as_view({'patch': 'update_branding'}), + enterprise_customer_branding_configuration.EnterpriseCustomerBrandingConfigurationViewSet.as_view( + {'patch': 'update_branding'} + ), name='enterprise-customer-update-branding') ] diff --git a/enterprise/api/v1/views.py b/enterprise/api/v1/views.py deleted file mode 100644 index 8db99b9ebf..0000000000 --- a/enterprise/api/v1/views.py +++ /dev/null @@ -1,2020 +0,0 @@ -""" -Views for enterprise api version 1 endpoint. -""" - -from smtplib import SMTPException -from time import time -from urllib.parse import quote_plus, unquote - -import jwt -from django_filters.rest_framework import DjangoFilterBackend -from edx_rbac.decorators import permission_required -from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication -from rest_framework import filters, generics, permissions, status, viewsets -from rest_framework.authentication import SessionAuthentication -from rest_framework.decorators import action -from rest_framework.exceptions import NotFound, ValidationError -from rest_framework.mixins import CreateModelMixin -from rest_framework.pagination import PageNumberPagination -from rest_framework.parsers import FormParser, MultiPartParser -from rest_framework.permissions import IsAuthenticated -from rest_framework.renderers import JSONRenderer -from rest_framework.response import Response -from rest_framework.status import ( - HTTP_200_OK, - HTTP_201_CREATED, - HTTP_202_ACCEPTED, - HTTP_400_BAD_REQUEST, - HTTP_404_NOT_FOUND, - HTTP_409_CONFLICT, - HTTP_422_UNPROCESSABLE_ENTITY, - HTTP_500_INTERNAL_SERVER_ERROR, -) -from rest_framework.views import APIView -from rest_framework_xml.renderers import XMLRenderer - -from django.apps import apps -from django.conf import settings -from django.contrib import auth -from django.core import exceptions, mail -from django.db import transaction -from django.db.models import Q -from django.http import Http404, JsonResponse -from django.shortcuts import get_object_or_404 -from django.utils.dateparse import parse_datetime -from django.utils.decorators import method_decorator -from django.utils.translation import gettext as _ - -from enterprise import models -from enterprise.api.filters import ( - EnterpriseCustomerInviteKeyFilterBackend, - EnterpriseCustomerUserFilterBackend, - EnterpriseLinkedUserFilterBackend, - UserFilterBackend, -) -from enterprise.api.throttles import HighServiceUserThrottle, ServiceUserThrottle -from enterprise.api.utils import ( - create_message_body, - get_ent_cust_from_enterprise_customer_key, - get_ent_cust_from_report_config_uuid, - get_enterprise_customer_from_catalog_id, - get_enterprise_customer_from_user_id, -) -from enterprise.api.v1 import serializers -from enterprise.api.v1.decorators import require_at_least_one_query_parameter -from enterprise.api.v1.permissions import IsInEnterpriseGroup -from enterprise.constants import COURSE_KEY_URL_PATTERN, PATHWAY_CUSTOMER_ADMIN_ENROLLMENT -from enterprise.errors import ( - AdminNotificationAPIRequestError, - CodesAPIRequestError, - LinkUserToEnterpriseError, - UnlinkUserFromEnterpriseError, -) -from enterprise.logging import getEnterpriseLogger -from enterprise.utils import ( - NotConnectedToOpenEdX, - enroll_subsidy_users_in_courses, - get_best_mode_from_course_key, - get_enterprise_customer, - get_request_value, - track_enrollment, - track_enterprise_user_linked, - validate_email_to_link, -) -from enterprise_learner_portal.utils import CourseRunProgressStatuses, get_course_run_status - -try: - from common.djangoapps.course_modes.models import CourseMode - from common.djangoapps.student.models import CourseEnrollment - from lms.djangoapps.certificates.api import get_certificate_for_user - from openedx.core.djangoapps.content.course_overviews.api import get_course_overviews - from openedx.core.djangoapps.enrollments import api as enrollment_api -except ImportError: - get_course_overviews = None - get_certificate_for_user = None - CourseEnrollment = None - CourseMode = None - enrollment_api = None - -LOGGER = getEnterpriseLogger(__name__) - -User = auth.get_user_model() - - -class EnterpriseViewSet: - """ - Base class for all Enterprise view sets. - """ - - permission_classes = (permissions.IsAuthenticated,) - authentication_classes = (JwtAuthentication, SessionAuthentication,) - throttle_classes = (ServiceUserThrottle,) - - def ensure_data_exists(self, request, data, error_message=None): - """ - Ensure that the wrapped API client's response brings us valid data. If not, raise an error and log it. - """ - if not data: - error_message = ( - error_message or "Unable to fetch API response from endpoint '{}'.".format(request.get_full_path()) - ) - LOGGER.error(error_message) - raise NotFound(error_message) - - -class EnterpriseWrapperApiViewSet(EnterpriseViewSet, viewsets.ViewSet): - """ - Base class for attribute and method definitions common to all view sets which wrap external APIs. - """ - - -class EnterpriseModelViewSet(EnterpriseViewSet): - """ - Base class for attribute and method definitions common to all view sets. - """ - - filter_backends = (filters.OrderingFilter, DjangoFilterBackend, UserFilterBackend,) - permission_classes = (permissions.IsAuthenticated, permissions.DjangoModelPermissions,) - USER_ID_FILTER = 'id' - - -class EnterpriseReadOnlyModelViewSet(EnterpriseModelViewSet, viewsets.ReadOnlyModelViewSet): - """ - Base class for all read only Enterprise model view sets. - """ - - -class EnterpriseReadWriteModelViewSet(EnterpriseModelViewSet, viewsets.ModelViewSet): - """ - Base class for all read/write Enterprise model view sets. - """ - - permission_classes = (permissions.IsAuthenticated, permissions.DjangoModelPermissions,) - - -class EnterpriseWriteOnlyModelViewSet(EnterpriseModelViewSet, CreateModelMixin, viewsets.GenericViewSet): - """ - Base class for all write only Enterprise model view sets. - """ - - permission_classes = (permissions.IsAuthenticated, permissions.DjangoModelPermissions) - - -class EnterpriseCustomerViewSet(EnterpriseReadWriteModelViewSet): - """ - API views for the ``enterprise-customer`` API endpoint. - """ - throttle_classes = (HighServiceUserThrottle, ) - queryset = models.EnterpriseCustomer.active_customers.all() - serializer_class = serializers.EnterpriseCustomerSerializer - filter_backends = EnterpriseReadWriteModelViewSet.filter_backends + (EnterpriseLinkedUserFilterBackend,) - - USER_ID_FILTER = 'enterprise_customer_users__user_id' - FIELDS = ( - 'uuid', 'slug', 'name', 'active', 'site', 'enable_data_sharing_consent', - 'enforce_data_sharing_consent', - ) - filterset_fields = FIELDS - ordering_fields = FIELDS - - def get_permissions(self): - if self.action == 'create': - return [permissions.IsAuthenticated()] - elif self.action == 'partial_update': - return [permissions.IsAuthenticated()] - else: - return [permission() for permission in self.permission_classes] - - def get_serializer_class(self): - if self.action == 'basic_list': - return serializers.EnterpriseCustomerBasicSerializer - return self.serializer_class - - @action(detail=False) - # pylint: disable=unused-argument - def basic_list(self, request, *arg, **kwargs): - """ - Enterprise Customer's Basic data list without pagination - - Two query parameters are supported: - - name_or_uuid: filter by name or uuid substring search in a single query parameter. - Primarily used for frontend debounced input search. - - startswith: filter by name starting with the given string - """ - startswith = request.GET.get('startswith') - name_or_uuid = request.GET.get('name_or_uuid') - queryset = self.get_queryset().order_by('name') - if startswith: - queryset = queryset.filter(name__istartswith=startswith) - if name_or_uuid: - queryset = queryset.filter(Q(name__icontains=name_or_uuid) | Q(uuid__icontains=name_or_uuid)) - serializer = self.get_serializer(queryset, many=True) - return Response(serializer.data) - - @permission_required('enterprise.can_access_admin_dashboard') - def create(self, request, *args, **kwargs): - """ - POST /enterprise/api/v1/enterprise-customer/ - """ - return super().create(request, *args, **kwargs) - - @permission_required('enterprise.can_access_admin_dashboard', fn=lambda request, pk: pk) - def partial_update(self, request, *args, **kwargs): - return super().partial_update(request, *args, **kwargs) - - @method_decorator(require_at_least_one_query_parameter('course_run_ids', 'program_uuids')) - @action(detail=True) - @permission_required('enterprise.can_view_catalog', fn=lambda request, pk, course_run_ids, program_uuids: pk) - # pylint: disable=unused-argument - def contains_content_items(self, request, pk, course_run_ids, program_uuids): - """ - Return whether or not the specified content is available to the EnterpriseCustomer. - - Multiple course_run_ids and/or program_uuids query parameters can be sent to this view to check - for their existence in the EnterpriseCustomerCatalogs associated with this EnterpriseCustomer. - At least one course run key or program UUID value must be included in the request. - """ - enterprise_customer = self.get_object() - - # Maintain plus characters in course key. - course_run_ids = [unquote(quote_plus(course_run_id)) for course_run_id in course_run_ids] - - contains_content_items = False - for catalog in enterprise_customer.enterprise_customer_catalogs.all(): - contains_course_runs = not course_run_ids or catalog.contains_courses(course_run_ids) - contains_program_uuids = not program_uuids or catalog.contains_programs(program_uuids) - if contains_course_runs and contains_program_uuids: - contains_content_items = True - break - - return Response({'contains_content_items': contains_content_items}) - - @action(methods=['post'], permission_classes=[permissions.IsAuthenticated], detail=True) - @permission_required('enterprise.can_enroll_learners', fn=lambda request, pk: pk) - # pylint: disable=unused-argument - def course_enrollments(self, request, pk): - """ - Creates a course enrollment for an EnterpriseCustomerUser. - """ - enterprise_customer = self.get_object() - serializer = serializers.EnterpriseCustomerCourseEnrollmentsSerializer( - data=request.data, - many=True, - context={ - 'enterprise_customer': enterprise_customer, - 'request_user': request.user, - } - ) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=HTTP_200_OK) - - return Response(serializer.errors, status=HTTP_400_BAD_REQUEST) - - @action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated]) - @permission_required('enterprise.can_enroll_learners', fn=lambda request, pk: pk) - # pylint: disable=unused-argument, too-many-statements - def enroll_learners_in_courses(self, request, pk): - """ - Creates a set of enterprise enrollments for specified learners by bulk enrolling them in provided courses. - This endpoint is not transactional, in that any one or more failures will not affect other successful - enrollments made within the same request. - - Parameters: - enrollments_info (list of dicts): an array of dictionaries, each containing the necessary information to - create an enrollment based on a subsidy for a user in a specified course. Each dictionary must contain - a user email (or user_id), a course run key, and either a UUID of the license that the learner is using - to enroll with or a transaction ID related to Executive Education the enrollment. `licenses_info` is - also accepted as a body param name. - - Example:: - - enrollments_info: [ - { - 'email': 'newuser@test.com', - 'course_run_key': 'course-v1:edX+DemoX+Demo_Course', - 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae', - }, - { - 'email': 'newuser2@test.com', - 'course_run_key': 'course-v2:edX+FunX+Fun_Course', - 'transaction_id': '84kdbdbade7b4fcb838f8asjke8e18ae', - }, - { - 'user_id': 1234, - 'course_run_key': 'course-v2:edX+SadX+Sad_Course', - 'transaction_id': 'ba1f7b61951987dc2e1743fa4886b62d', - }, - ... - ] - - discount (int): the percent discount to be applied to all enrollments. Defaults to 100. - - Returns: - Success cases: - - All users exist and are enrolled - - {'successes': [], 'pending': [], 'failures': []}, 201 - - Some or none of the users exist but are enrolled - - {'successes': [], 'pending': [], 'failures': []}, 202 - - Failure cases: - - Some or all of the users can't be enrolled, no users were enrolled - - {'successes': [], 'pending': [], 'failures': []}, 409 - - - Some or all of the provided emails are invalid - {'successes': [], 'pending': [], 'failures': [] 'invalid_email_addresses': []}, 409 - """ - enterprise_customer = self.get_object() - serializer = serializers.EnterpriseCustomerBulkSubscriptionEnrollmentsSerializer( - data=request.data, - context={ - 'enterprise_customer': enterprise_customer, - 'request_user': request.user, - } - ) - try: - serializer.is_valid(raise_exception=True) - except ValidationError: - error_message = "Something went wrong while validating bulk enrollment requests." \ - "Received exception: {}".format(serializer.errors) - LOGGER.warning(error_message) - return Response(serializer.errors, status=HTTP_400_BAD_REQUEST) - - user_id_errors = [] - email_errors = [] - serialized_data = serializer.validated_data - enrollments_info = serialized_data.get('licenses_info', serialized_data.get('enrollments_info')) - - # Default subscription discount is 100% - discount = serialized_data.get('discount', 100.00) - - # Retrieve and store course modes for each unique course provided - course_runs_modes = {enrollment_info['course_run_key']: None for enrollment_info in enrollments_info} - for course_run in course_runs_modes: - course_runs_modes[course_run] = get_best_mode_from_course_key(course_run) - - emails = set() - - for info in enrollments_info: - if 'user_id' in info: - user = User.objects.filter(id=info['user_id']).first() - if user: - info['email'] = user.email - emails.add(user.email) - else: - user_id_errors.append(info['user_id']) - else: - emails.add(info['email']) - info['course_mode'] = course_runs_modes[info['course_run_key']] - - for email in emails: - try: - validate_email_to_link(email, enterprise_customer, raise_exception=False) - except exceptions.ValidationError: - email_errors.append(email) - - for email in emails: - try: - models.EnterpriseCustomerUser.all_objects.link_user(enterprise_customer, email) - except LinkUserToEnterpriseError: - email_errors.append(email) - - # Remove the bad emails and bad user_ids from enrollments_info; don't attempt to enroll or link them. - enrollments_info = [ - info for info in enrollments_info - if info.get('email') not in email_errors and info.get('user_id') not in user_id_errors - ] - - results = enroll_subsidy_users_in_courses(enterprise_customer, enrollments_info, discount) - - # collect the returned activation links for licenses which need activation - activation_links = {} - for result_kind in ['successes', 'pending']: - for result in results[result_kind]: - if result.get('activation_link') is not None: - activation_links[result['email']] = result.get('activation_link') - - for course_run in course_runs_modes: - pending_users = { - result.pop('user') for result in results['pending'] - if result['course_run_key'] == course_run and result.get('created') - } - existing_users = { - result.pop('user') for result in results['successes'] - if result['course_run_key'] == course_run and result.get('created') - } - if len(pending_users | existing_users) > 0: - LOGGER.info("Successfully bulk enrolled learners: {} into course {}".format( - pending_users | existing_users, - course_run, - )) - track_enrollment(PATHWAY_CUSTOMER_ADMIN_ENROLLMENT, request.user.id, course_run) - if serializer.validated_data.get('notify'): - enterprise_customer.notify_enrolled_learners( - catalog_api_user=request.user, - course_id=course_run, - users=pending_users | existing_users, - admin_enrollment=True, - activation_links=activation_links, - ) - - # Remove the user object from the results for any already existing enrollment cases (ie created = False) as - # these are not JSON serializable - existing_enrollments = [] - for result in results['pending']: - already_enrolled_pending_user = result.pop('user', None) - existing_enrollments.append(already_enrolled_pending_user) - - for result in results['successes']: - already_enrolled_user = result.pop('user', None) - existing_enrollments.append(already_enrolled_user) - - if existing_enrollments: - LOGGER.info( - f'Bulk enrollment request submitted for users: {existing_enrollments} who already have enrollments' - ) - - if user_id_errors: - results['invalid_user_ids'] = user_id_errors - if email_errors: - results['invalid_email_addresses'] = email_errors - - if results['failures'] or email_errors or user_id_errors: - return Response(results, status=HTTP_409_CONFLICT) - if results['pending']: - return Response(results, status=HTTP_202_ACCEPTED) - return Response(results, status=HTTP_201_CREATED) - - @method_decorator(require_at_least_one_query_parameter('permissions')) - @action(permission_classes=[permissions.IsAuthenticated, IsInEnterpriseGroup], detail=False) - def with_access_to(self, request, *args, **kwargs): - """ - Returns the list of enterprise customers the user has a specified group permission access to. - """ - self.queryset = self.queryset.order_by('name') - enterprise_id = self.request.query_params.get('enterprise_id', None) - enterprise_slug = self.request.query_params.get('enterprise_slug', None) - enterprise_name = self.request.query_params.get('search', None) - - if enterprise_id is not None: - self.queryset = self.queryset.filter(uuid=enterprise_id) - elif enterprise_slug is not None: - self.queryset = self.queryset.filter(slug=enterprise_slug) - elif enterprise_name is not None: - self.queryset = self.queryset.filter(name__icontains=enterprise_name) - return self.list(request, *args, **kwargs) - - @action(detail=False) - @permission_required('enterprise.can_access_admin_dashboard') - def dashboard_list(self, request, *args, **kwargs): - """ - Supports listing dashboard enterprises for frontend-app-admin-portal. - """ - self.queryset = self.queryset.order_by('name') - enterprise_id = self.request.query_params.get('enterprise_id', None) - enterprise_slug = self.request.query_params.get('enterprise_slug', None) - enterprise_name = self.request.query_params.get('search', None) - - if enterprise_id is not None: - self.queryset = self.queryset.filter(uuid=enterprise_id) - elif enterprise_slug is not None: - self.queryset = self.queryset.filter(slug=enterprise_slug) - elif enterprise_name is not None: - self.queryset = self.queryset.filter(name__icontains=enterprise_name) - return self.list(request, *args, **kwargs) - - @action(methods=['patch'], detail=True, permission_classes=[permissions.IsAuthenticated]) - @permission_required('enterprise.can_access_admin_dashboard') - def toggle_universal_link(self, request, pk=None): - """ - Enables/Disables universal link config. - """ - - enterprise_customer = get_object_or_404(models.EnterpriseCustomer, uuid=pk) - serializer = serializers.EnterpriseCustomerToggleUniversalLinkSerializer( - data=request.data, - context={ - 'enterprise_customer': enterprise_customer, - 'request_user': request.user, - } - ) - - if not serializer.is_valid(): - return Response(serializer.errors, status=HTTP_400_BAD_REQUEST) - - enable_universal_link = serializer.validated_data.get('enable_universal_link') - - if enterprise_customer.enable_universal_link == enable_universal_link: - return Response({"detail": "No changes"}, status=HTTP_200_OK) - - enterprise_customer.toggle_universal_link( - enable_universal_link, - ) - - response_body = {"enable_universal_link": enable_universal_link} - headers = self.get_success_headers(response_body) - return Response(response_body, status=HTTP_200_OK, headers=headers) - - @action(methods=['post'], detail=True, permission_classes=[permissions.IsAuthenticated]) - @permission_required('enterprise.can_access_admin_dashboard', fn=lambda request, pk: pk) - def unlink_users(self, request, pk=None): # pylint: disable=unused-argument - """ - Unlinks users with the given emails from the enterprise. - """ - - serializer = serializers.EnterpriseCustomerUnlinkUsersSerializer( - data=request.data - ) - - serializer.is_valid(raise_exception=True) - - enterprise_customer = self.get_object() - emails_to_unlink = serializer.data.get('user_emails', []) - is_relinkable = serializer.data.get('is_relinkable', True) - - with transaction.atomic(): - for email in emails_to_unlink: - try: - models.EnterpriseCustomerUser.objects.unlink_user( - enterprise_customer=enterprise_customer, - user_email=email, - is_relinkable=is_relinkable - ) - except (models.EnterpriseCustomerUser.DoesNotExist, models.PendingEnterpriseCustomerUser.DoesNotExist): - msg = "User with email {} does not exist in enterprise {}.".format(email, enterprise_customer) - LOGGER.warning(msg) - except Exception as exc: - msg = "Could not unlink {} from {}".format(email, enterprise_customer) - raise UnlinkUserFromEnterpriseError(msg) from exc - - return Response(status=HTTP_200_OK) - - -class EnterpriseCourseEnrollmentViewSet(EnterpriseReadWriteModelViewSet): - """ - API views for the ``enterprise-course-enrollment`` API endpoint. - """ - - queryset = models.EnterpriseCourseEnrollment.objects.all() - - USER_ID_FILTER = 'enterprise_customer_user__user_id' - FIELDS = ( - 'enterprise_customer_user', 'course_id' - ) - filterset_fields = FIELDS - ordering_fields = FIELDS - - def get_serializer_class(self): - """ - Use a special serializer for any requests that aren't read-only. - """ - if self.request.method in ('GET',): - return serializers.EnterpriseCourseEnrollmentReadOnlySerializer - return serializers.EnterpriseCourseEnrollmentWriteSerializer - - -class EnrollmentModificationException(Exception): - """ - An exception that represents an error when modifying the state - of an enrollment via the EnrollmentApiClient. - """ - - -class EnterpriseSubsidyFulfillmentViewSet(EnterpriseWrapperApiViewSet): - """ - General API views for subsidized enterprise course enrollments. - - Supported operations: - * Fetch a subsidy fulfillment record by uuid. - /enterprise/api/v1/subsidy-fulfillment/{fulfillment_source_uuid}/ - * Cancel a subsidy fulfillment enrollment record by uuid. - /enterprise/api/v1/subsidy-fulfillment/{fulfillment_source_uuid}/cancel-enrollment/ - * Fetch all unenrolled subsidy fulfillment records. - /enterprise/api/v1/operator/subsidy-fulfillment/unenrolled/ - - Cancel and fetch endpoints require a fulfillment source uuid query parameter. Fetching unenrollments supports - an optional ``unenrolled_after`` query parameter to filter the returned queryset down to only enterprise - enrollments unenrolled after the supplied datetime. - - Arguments (Fetch & Cancel): - fulfillment_source_uuid (str): The uuid of the subsidy fulfillment record. - Arguments (Unenrolled): - unenrolled_after (str): A datetime string. Only return enrollments unenrolled after this time. - Returns (Fetch): - (Response): JSON response containing the subsidy fulfillment record. - Returns (Unenrolled): - (Response): JSON list response containing the unenrolled subsidy fulfillment records. - Example: - [ - { - enterprise_course_enrollment: { - enterprise_customer_user: , - course_id: , - unenrolled: - created: - } - license_uuid/transaction_id: , - uuid: , - }, - ] - Raises - (Http404): If the subsidy fulfillment record does not exist or if subsidy fulfillment exists under a separate - enterprise. - (Http403): If the requesting user does not have the appropriate permissions. - (EnrollmentModificationException): If something goes wrong while updating the platform CourseEnrollment object. - """ - - def get_subsidy_fulfillment_queryset(self): - """ - Return the queryset for this view. Queries across subsidy types until it finds a match for the provided uuid. - Returns a 404 if no subsidy fulfillment record is found. - """ - enterprise_customer_uuid = get_enterprise_customer_from_user_id(self.request.user.id) - fulfillment_source_uuid = self.kwargs.get('fulfillment_source_uuid') - - # Get learner credit enrollments under the supplied fulfillment source uuid. - learner_credit_enrollments = models.LearnerCreditEnterpriseCourseEnrollment.objects.filter( - uuid=fulfillment_source_uuid - ) - - # Filters to match fulfillment enrollments' and entitlements' enterprise customer uuid to the requesting - # user's enterprise customer uuid. - subsidy_fulfillment_filter = Q( - enterprise_course_enrollment__enterprise_customer_user__enterprise_customer__uuid=enterprise_customer_uuid - ) - subsidy_fulfillment_filter |= Q( - enterprise_course_entitlement__enterprise_customer_user__enterprise_customer__uuid=enterprise_customer_uuid - ) - - # If the requester isn't staff, apply the filters - if not self.request.user.is_staff: - learner_credit_enrollments = learner_credit_enrollments.filter(subsidy_fulfillment_filter) - - # Return if we get any hits - if learner_credit_enrollments: - return learner_credit_enrollments - - # Get licensed enrollments under the supplied fulfillment source uuid and repeat the same process. - licensed_enrollments = models.LicensedEnterpriseCourseEnrollment.objects.filter( - uuid=fulfillment_source_uuid - ) - if not self.request.user.is_staff: - licensed_enrollments = licensed_enrollments.filter(subsidy_fulfillment_filter) - - if licensed_enrollments: - return licensed_enrollments - raise ValidationError('No enrollment found for the given fulfillment source uuid.', code=HTTP_404_NOT_FOUND) - - def get_subsidy_fulfillment_serializer_class(self): - """ - Fetch the correct serializer class based on the subsidy type. - """ - fulfillment_source_uuid = self.kwargs.get('fulfillment_source_uuid') - - learner_credit_enrollments = models.LearnerCreditEnterpriseCourseEnrollment.objects.filter( - uuid=fulfillment_source_uuid - ) - if len(learner_credit_enrollments): - return serializers.LearnerCreditEnterpriseCourseEnrollmentReadOnlySerializer - licensed_enrollments = models.LicensedEnterpriseCourseEnrollment.objects.filter( - uuid=fulfillment_source_uuid - ) - if len(licensed_enrollments): - return serializers.LicensedEnterpriseCourseEnrollmentReadOnlySerializer - - raise ValidationError('No enrollment found for the given fulfillment source uuid.', code=HTTP_404_NOT_FOUND) - - def get_unenrolled_fulfillment_queryset(self): - """ - Return the queryset for unenrolled subsidy fulfillment records. Applies a modified timestamp filter to fetch - records modified after if provided from query params. - """ - # Adding licensed enrollment support for future implementations - if self.request.query_params.get('retrieve_licensed_enrollments'): - enrollment_table = models.LicensedEnterpriseCourseEnrollment - else: - enrollment_table = models.LearnerCreditEnterpriseCourseEnrollment - - # Apply a modified filter if one is provided via query params - if self.request.query_params.get('unenrolled_after'): - unenrolled_queryset = enrollment_table.objects.filter( - enterprise_course_enrollment__unenrolled_at__gte=self.request.query_params.get('unenrolled_after') - ) - return unenrolled_queryset - - unenrolled_queryset = enrollment_table.objects.filter( - enterprise_course_enrollment__unenrolled_at__isnull=False, - ) - - return unenrolled_queryset - - def get_unenrolled_fulfillment_serializer_class(self): - """ - Fetch the correct recently unenrolled serializer class based on provided querysets. - """ - if self.request.query_params.get('retrieve_licensed_enrollments'): - return serializers.LicensedEnterpriseCourseEnrollmentReadOnlySerializer - else: - return serializers.LearnerCreditEnterpriseCourseEnrollmentReadOnlySerializer - - @permission_required( - 'enterprise.can_manage_enterprise_fulfillments', - fn=lambda request: get_enterprise_customer_from_user_id(request.user.id) - ) - def unenrolled(self, request, *args, **kwargs): - """ - List all unenrolled subsidy fulfillments. - /enterprise/api/v1/operator/enterprise-subsidy-fulfillment/unenrolled/ - - Args: - modified (str): A datetime string. Only return enrollments modified after this time. - retrieve_licensed_enrollments (bool): If true, return data related to licensed enrollments instead of - learner credit - """ - queryset = self.get_unenrolled_fulfillment_queryset() - serializer_class = self.get_unenrolled_fulfillment_serializer_class() - serializer = serializer_class(queryset, many=True) - return Response(serializer.data) - - @permission_required( - 'enterprise.can_access_admin_dashboard', - fn=lambda request, fulfillment_source_uuid: get_enterprise_customer_from_user_id(request.user.id) - ) - def retrieve(self, request, fulfillment_source_uuid, *args, **kwargs): - """ - Retrieve a single subsidized enrollment. - /enterprise/api/v1/subsidy-fulfillment/{fulfillment_source_uuid}/ - """ - try: - queryset = self.get_subsidy_fulfillment_queryset() - fulfillment = get_object_or_404(queryset, uuid=fulfillment_source_uuid) - serializer_class = self.get_subsidy_fulfillment_serializer_class() - serialized_object = serializer_class(fulfillment) - except ValidationError as exc: - return Response( - status=HTTP_404_NOT_FOUND, - data={'detail': exc.detail} - ) - return Response(serialized_object.data) - - @action(methods=['post'], detail=True) - @permission_required( - 'enterprise.can_enroll_learners', - fn=lambda request, fulfillment_source_uuid: get_enterprise_customer_from_user_id(request.user.id) - ) - def cancel_enrollment(self, request, fulfillment_source_uuid): - """ - Cancel a single subsidized enrollment. Assumes fulfillment source has a valid enterprise enrollment. - /enterprise/api/v1/subsidy-fulfillment/{fulfillment_source_uuid}/cancel-enrollment/ - """ - try: - subsidy_fulfillment = get_object_or_404( - self.get_subsidy_fulfillment_queryset(), uuid=fulfillment_source_uuid - ) - if subsidy_fulfillment.is_revoked: - return Response( - status=HTTP_400_BAD_REQUEST, - data={'detail': 'Enrollment is already canceled.'} - ) - except ValidationError as exc: - return Response( - status=HTTP_404_NOT_FOUND, - data={'detail': exc.detail} - ) - - try: - username = subsidy_fulfillment.enterprise_course_enrollment.enterprise_customer_user.username - enrollment_api.update_enrollment( - username, - subsidy_fulfillment.enterprise_course_enrollment.course_id, - is_active=False, - ) - subsidy_fulfillment.revoke() - except Exception as exc: # pylint: disable=broad-except - msg = ( - f'Subsidized enrollment terminations error: unable to unenroll User {username} ' - f'from Course {subsidy_fulfillment.enterprise_course_enrollment.course_id} because: {str(exc)}' - ) - LOGGER.error(msg) - return Response(msg, status=HTTP_500_INTERNAL_SERVER_ERROR) - return Response(status=HTTP_200_OK) - - -class LicensedEnterpriseCourseEnrollmentViewSet(EnterpriseWrapperApiViewSet): - """ - API views for the ``licensed-enterprise-course-enrollment`` API endpoint. - """ - - queryset = models.LicensedEnterpriseCourseEnrollment.objects.all() - serializer_class = serializers.LicensedEnterpriseCourseEnrollmentReadOnlySerializer - REQ_EXP_LICENSE_UUIDS_PARAM = 'expired_license_uuids' - OPT_IGNORE_ENROLLMENTS_MODIFIED_AFTER_PARAM = 'ignore_enrollments_modified_after' - - class EnrollmentTerminationStatus: - """ - Defines statuses related to enrollment states during the course unenrollment process. - """ - COURSE_COMPLETED = 'course already completed' - MOVED_TO_AUDIT = 'moved to audit' - UNENROLLED = 'unenrolled' - UNENROLL_FAILED = 'unenroll_user_from_course returned false.' - - @staticmethod - def _validate_license_revoke_data(request_data): - """ - Ensures the request data contains the necessary information. - - Arguments: - request_data (dict): A dictionary of data passed to the request - """ - user_id = request_data.get('user_id') - enterprise_id = request_data.get('enterprise_id') - - if not user_id or not enterprise_id: - msg = 'user_id and enterprise_id must be provided.' - return Response(msg, status=status.HTTP_400_BAD_REQUEST) - - return None - - @staticmethod - def _has_user_completed_course_run(enterprise_enrollment, course_overview): - """ - Returns True if the user who is enrolled in the given course has already - completed this course, false otherwise. The course may be "completed" - if the user earned a certificate, or if the course run has ended. - - Args: - enterprise_enrollment (EnterpriseCourseEnrollment): The enrollment object for which we check - if the associated user has completed the given course. - course_overview (CourseOverview): The course overview of which we are checking completion. We need this - to check certificate status. It's a model defined in edx-platform. - """ - certificate_info = get_certificate_for_user( - enterprise_enrollment.enterprise_customer_user.username, - course_overview.get('id'), - ) or {} - course_run_status = get_course_run_status( - course_overview, - certificate_info, - enterprise_enrollment, - ) - - return course_run_status == CourseRunProgressStatuses.COMPLETED - - def _enrollments_by_course_for_licensed_user(self, enterprise_customer_user): - """ - Helper method to return a dictionary mapping course ids to EnterpriseCourseEnrollments - for each licensed enrollment associated with the given enterprise user. - - Args: - enterprise_customer_user (EnterpriseCustomerUser): The user for which we are fetching enrollments. - """ - licensed_enrollments = models.LicensedEnterpriseCourseEnrollment.enrollments_for_user( - enterprise_customer_user - ) - return { - enrollment.enterprise_course_enrollment.course_id: enrollment.enterprise_course_enrollment - for enrollment in licensed_enrollments - } - - def _terminate_enrollment(self, enterprise_enrollment, course_overview): - """ - Helper method that switches the given enrollment to audit track, or, if - no audit track exists for the given course, deletes the enrollment. - Will do nothing if the user has already "completed" the course run. - - Args: - enterprise_enrollment (EnterpriseCourseEnrollment): The enterprise enrollment which we attempt to revoke. - course_overview (CourseOverview): The course overview object associated with the enrollment. Used - to check for course completion. - """ - course_run_id = course_overview.get('id') - enterprise_customer_user = enterprise_enrollment.enterprise_customer_user - audit_mode = CourseMode.AUDIT - enterprise_id = enterprise_customer_user.enterprise_customer.uuid - - log_message_kwargs = { - 'user': enterprise_customer_user.username, - 'enterprise': enterprise_id, - 'course_id': course_run_id, - 'mode': audit_mode, - } - - if self._has_user_completed_course_run(enterprise_enrollment, course_overview): - LOGGER.info( - 'enrollment termination: not updating enrollment in {course_id} for User {user} ' - 'in Enterprise {enterprise}, course is already complete.'.format(**log_message_kwargs) - ) - return self.EnrollmentTerminationStatus.COURSE_COMPLETED - - if CourseMode.mode_for_course(course_run_id, audit_mode): - try: - enrollment_api.update_enrollment( - username=enterprise_customer_user.username, - course_id=course_run_id, - mode=audit_mode, - ) - LOGGER.info( - 'Enrollment termination: updated LMS enrollment for User {user} and Enterprise {enterprise} ' - 'in Course {course_id} to Course Mode {mode}.'.format(**log_message_kwargs) - ) - return self.EnrollmentTerminationStatus.MOVED_TO_AUDIT - except Exception as exc: - msg = ( - 'Enrollment termination: unable to update LMS enrollment for User {user} and ' - 'Enterprise {enterprise} in Course {course_id} to Course Mode {mode} because: {reason}'.format( - reason=str(exc), - **log_message_kwargs - ) - ) - LOGGER.error('{msg}: {exc}'.format(msg=msg, exc=exc)) - raise EnrollmentModificationException(msg) from exc - else: - try: - enrollment_api.update_enrollment( - username=enterprise_customer_user.username, - course_id=course_run_id, - is_active=False - ) - LOGGER.info( - 'Enrollment termination: successfully unenrolled User {user}, in Enterprise {enterprise} ' - 'from Course {course_id} that contains no audit mode.'.format(**log_message_kwargs) - ) - return self.EnrollmentTerminationStatus.UNENROLLED - except Exception as exc: - msg = ( - 'Enrollment termination: unable to unenroll User {user} in Enterprise {enterprise} ' - 'from Course {course_id} because: {reason}'.format( - reason=str(exc), - **log_message_kwargs - ) - ) - LOGGER.error('{msg}: {exc}'.format(msg=msg, exc=exc)) - raise EnrollmentModificationException(msg) from exc - - def _course_enrollment_modified_at_by_user_and_course_id(self, licensed_enrollments): - """ - Returns a dict containing the last time a course enrollment was modified. - The keys are in the form of f'{user_id}{course_id}'. - """ - enterprise_course_enrollments = [ - licensed_enrollment.enterprise_course_enrollment for licensed_enrollment in licensed_enrollments - ] - user_ids = [str(ece.enterprise_customer_user.user_id) for ece in enterprise_course_enrollments] - course_ids = [str(ece.course_id) for ece in enterprise_course_enrollments] - course_enrollment_histories = CourseEnrollment.history.filter( - user_id__in=user_ids, - course_id__in=course_ids - ).order_by('-history_date') - - result = {} - - for history in course_enrollment_histories: - user_id = history.user_id - course_id = str(history.course_id) - key = f'{user_id}{course_id}' - if key not in result: - result[key] = history.history_date - - return result - - @action(methods=['post'], detail=False) - @permission_required('enterprise.can_access_admin_dashboard', fn=lambda request: request.data.get('enterprise_id')) - def license_revoke(self, request, *args, **kwargs): - """ - Changes the mode for a user's licensed enterprise course enrollments to the "audit" course mode, - or unenroll the user if no audit mode exists for a given course. - - Will return a response with status 200 if no errors were encountered while modifying the course enrollment, - or a 422 if any errors were encountered. The content of the response is of the form:: - - { - 'course-v1:puppies': {'success': true, 'message': 'unenrolled'}, - 'course-v1:birds': {'success': true, 'message': 'moved to audit'}, - 'course-v1:kittens': {'success': true, 'message': 'course already completed'}, - 'course-v1:snakes': {'success': false, 'message': 'unenroll_user_from_course returned false'}, - 'course-v1:lizards': {'success': false, 'message': 'Some other exception'}, - } - - The first four messages are the values of constants that a client may expect to receive and parse accordingly. - """ - dependencies = [ - CourseMode, get_certificate_for_user, get_course_overviews, enrollment_api - ] - if not all(dependencies): - raise NotConnectedToOpenEdX( - _('To use this endpoint, this package must be ' - 'installed in an Open edX environment.') - ) - - request_data = request.data.copy() - invalid_response = self._validate_license_revoke_data(request_data) - if invalid_response: - return invalid_response - - user_id = request_data.get('user_id') - enterprise_id = request_data.get('enterprise_id') - - enterprise_customer_user = get_object_or_404( - models.EnterpriseCustomerUser, - user_id=user_id, - enterprise_customer=enterprise_id, - ) - enrollments_by_course_id = self._enrollments_by_course_for_licensed_user(enterprise_customer_user) - - revocation_results = {} - any_failures = False - for course_overview in get_course_overviews(list(enrollments_by_course_id.keys())): - course_id = str(course_overview.get('id')) - enterprise_enrollment = enrollments_by_course_id.get(course_id) - try: - revocation_status = self._terminate_enrollment(enterprise_enrollment, course_overview) - revocation_results[course_id] = {'success': True, 'message': revocation_status} - if revocation_status != self.EnrollmentTerminationStatus.COURSE_COMPLETED: - enterprise_enrollment.license.revoke() - except EnrollmentModificationException as exc: - revocation_results[course_id] = {'success': False, 'message': str(exc)} - any_failures = True - - status_code = status.HTTP_200_OK if not any_failures else status.HTTP_422_UNPROCESSABLE_ENTITY - return Response(revocation_results, status=status_code) - - @action(methods=['post'], detail=False) - @permission_required('enterprise.can_enroll_learners') - def bulk_licensed_enrollments_expiration(self, request): - """ - Changes the mode for licensed enterprise course enrollments to the "audit" course mode, - or unenroll the user if no audit mode exists for each expired license uuid - - Args: - expired_license_uuids: The expired license uuids. - ignore_enrollments_modified_after: All course enrollments modified past this given date will be ignored, - i.e. the enterprise subscription plan expiration date. - """ - - dependencies = [ - CourseEnrollment, CourseMode, get_certificate_for_user, get_course_overviews, enrollment_api - ] - if not all(dependencies): - raise NotConnectedToOpenEdX( - _('To use this endpoint, this package must be ' - 'installed in an Open edX environment.') - ) - - expired_license_uuids = get_request_value(request, self.REQ_EXP_LICENSE_UUIDS_PARAM, '') - ignore_enrollments_modified_after = get_request_value( - request, - self.OPT_IGNORE_ENROLLMENTS_MODIFIED_AFTER_PARAM, - None - ) - - if not expired_license_uuids: - return Response( - 'Parameter {} must be provided'.format(self.REQ_EXP_LICENSE_UUIDS_PARAM), - status=status.HTTP_400_BAD_REQUEST - ) - - if ignore_enrollments_modified_after: - ignore_enrollments_modified_after = parse_datetime(ignore_enrollments_modified_after) - if not ignore_enrollments_modified_after: - return Response( - 'Parameter {} is malformed, please provide a date in ISO-8601 format'.format( - self.OPT_IGNORE_ENROLLMENTS_MODIFIED_AFTER_PARAM - ), - status=status.HTTP_400_BAD_REQUEST - ) - - licensed_enrollments = models.LicensedEnterpriseCourseEnrollment.objects.filter( - license_uuid__in=expired_license_uuids - ).select_related('enterprise_course_enrollment') - - course_overviews = get_course_overviews( - list(licensed_enrollments.values_list('enterprise_course_enrollment__course_id', flat=True)) - ) - indexed_overviews = {overview.get('id'): overview for overview in course_overviews} - - course_enrollment_modified_at_by_user_and_course_id = \ - self._course_enrollment_modified_at_by_user_and_course_id( - licensed_enrollments - ) if ignore_enrollments_modified_after else {} - - any_failures = False - - for licensed_enrollment in licensed_enrollments: - enterprise_course_enrollment = licensed_enrollment.enterprise_course_enrollment - user_id = enterprise_course_enrollment.enterprise_customer_user.user_id - course_id = enterprise_course_enrollment.course_id - course_overview = indexed_overviews.get(course_id) - - if licensed_enrollment.is_revoked: - LOGGER.info( - 'Enrollment termination: not updating enrollment in {} for User {} ' - 'licensed enterprise enrollment has already been revoked in the past.'.format( - course_id, - user_id - ) - ) - continue - - if ignore_enrollments_modified_after: - key = f'{user_id}{course_id}' - course_enrollment_modified_at = course_enrollment_modified_at_by_user_and_course_id[key] - if course_enrollment_modified_at >= ignore_enrollments_modified_after: - LOGGER.info( - 'Enrollment termination: not updating enrollment in {} for User {} ' - 'course enrollment has been modified past {}.'.format( - course_id, - user_id, - ignore_enrollments_modified_after - ) - ) - continue - - try: - termination_status = self._terminate_enrollment(enterprise_course_enrollment, course_overview) - license_uuid = enterprise_course_enrollment.license.license_uuid - LOGGER.info( - f"EnterpriseCourseEnrollment record with enterprise license {license_uuid} " - f"unenrolled to status {termination_status}." - ) - if termination_status != self.EnrollmentTerminationStatus.COURSE_COMPLETED: - enterprise_course_enrollment.license.revoke() - except EnrollmentModificationException as exc: - LOGGER.error( - f"Failed to unenroll EnterpriseCourseEnrollment record for enterprise license " - f"{enterprise_course_enrollment.license.license_uuid}. error message {str(exc)}." - ) - any_failures = True - - status_code = status.HTTP_200_OK if not any_failures else status.HTTP_422_UNPROCESSABLE_ENTITY - return Response(status=status_code) - - -class EnterpriseCustomerUserViewSet(EnterpriseReadWriteModelViewSet): - """ - API views for the ``enterprise-learner`` API endpoint. - """ - - queryset = models.EnterpriseCustomerUser.objects.all() - filter_backends = (filters.OrderingFilter, DjangoFilterBackend, EnterpriseCustomerUserFilterBackend) - - FIELDS = ( - 'enterprise_customer', 'user_id', 'active', - ) - filterset_fields = FIELDS - ordering_fields = FIELDS - - def get_serializer_class(self): - """ - Use a flat serializer for any requests that aren't read-only. - """ - if self.request.method in ('GET',): - return serializers.EnterpriseCustomerUserReadOnlySerializer - - return serializers.EnterpriseCustomerUserWriteSerializer - - -class PendingEnterpriseCustomerUserViewSet(EnterpriseReadWriteModelViewSet): - """ - API views for the ``pending-enterprise-learner`` API endpoint. - Requires staff permissions - """ - queryset = models.PendingEnterpriseCustomerUser.objects.all() - filter_backends = (filters.OrderingFilter, DjangoFilterBackend) - serializer_class = serializers.PendingEnterpriseCustomerUserSerializer - permission_classes = (permissions.IsAuthenticated, permissions.IsAdminUser) - - FIELDS = ( - 'enterprise_customer', 'user_email', - ) - filterset_fields = FIELDS - ordering_fields = FIELDS - - UNIQUE = 'unique' - USER_EXISTS_ERROR = 'EnterpriseCustomerUser record already exists' - - def _get_return_status(self, serializer, many): - """ - Run serializer validation and get return status - """ - return_status = None - serializer.is_valid(raise_exception=True) - if not many: - _, created = serializer.save() - return_status = status.HTTP_201_CREATED if created else status.HTTP_204_NO_CONTENT - return return_status - - data_list = serializer.save() - for _, created in data_list: - if created: - return status.HTTP_201_CREATED - return status.HTTP_204_NO_CONTENT - - def create(self, request, *args, **kwargs): - """ - Creates a PendingEnterpriseCustomerUser if no EnterpriseCustomerUser for the given (customer, email) - combination(s) exists. - Can accept one user or a list of users. - - Returns 201 if any users were created, 204 if no users were created. - """ - serializer = self.get_serializer(data=request.data, many=isinstance(request.data, list)) - return_status = self._get_return_status(serializer, many=isinstance(request.data, list)) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=return_status, headers=headers) - - -class PendingEnterpriseCustomerUserEnterpriseAdminViewSet(PendingEnterpriseCustomerUserViewSet): - """ - Viewset for allowing enterprise admins to create linked learners - Endpoint url: link_pending_enterprise_users/(?P[A-Za-z0-9-]+)/?$ - Admin must be an administrator for the enterprise in question - """ - permission_classes = (permissions.IsAuthenticated,) - serializer_class = serializers.LinkLearnersSerializer - - @action(methods=['post'], detail=False) - @permission_required('enterprise.can_access_admin_dashboard', fn=lambda request, enterprise_uuid: enterprise_uuid) - def link_learners(self, request, enterprise_uuid): - """ - Creates a PendingEnterpriseCustomerUser if no EnterpriseCustomerUser for the given (customer, email) - combination(s) exists. - Can accept one user or a list of users. - - Returns 201 if any users were created, 204 if no users were created. - """ - if not request.data: - LOGGER.error('Empty user email payload in link_learners for enterprise: %s', enterprise_uuid) - return Response( - 'At least one user email is required.', - status=HTTP_400_BAD_REQUEST, - ) - context = {'enterprise_customer__uuid': enterprise_uuid} - serializer = self.get_serializer( - data=request.data, - many=isinstance(request.data, list), - context=context, - ) - return_status = self._get_return_status(serializer, many=isinstance(request.data, list)) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=return_status, headers=headers) - - -class EnterpriseCustomerBrandingConfigurationViewSet(EnterpriseReadWriteModelViewSet): - """ - API views for the ``enterprise-customer-branding`` API endpoint. - """ - permission_classes = (permissions.IsAuthenticated,) - parser_classes = [MultiPartParser, FormParser] - queryset = models.EnterpriseCustomerBrandingConfiguration.objects.all() - serializer_class = serializers.EnterpriseCustomerBrandingConfigurationSerializer - - USER_ID_FILTER = 'enterprise_customer__enterprise_customer_users__user_id' - FIELDS = ( - 'enterprise_customer__slug', - ) - filterset_fields = FIELDS - ordering_fields = FIELDS - lookup_field = 'enterprise_customer__slug' - - @action(methods=['patch'], detail=False, permission_classes=[permissions.IsAuthenticated]) - @permission_required('enterprise.can_access_admin_dashboard', fn=lambda request, enterprise_uuid: enterprise_uuid) - def update_branding(self, request, enterprise_uuid): - """ - PATCH /enterprise/api/v1/enterprise-customer-branding/update_branding/uuid - - Requires enterprise customer uuid path parameter - """ - try: - enterprise_customer = models.EnterpriseCustomer.objects.get(uuid=enterprise_uuid) - branding_configs = models.EnterpriseCustomerBrandingConfiguration.objects.filter( - enterprise_customer=enterprise_customer) - if len(branding_configs) > 0: - branding_config = models.EnterpriseCustomerBrandingConfiguration.objects.get( - enterprise_customer=enterprise_customer) - else: - branding_config = models.EnterpriseCustomerBrandingConfiguration( - enterprise_customer=enterprise_customer) - - if 'logo' in request.data: - branding_config.logo = request.data['logo'] - if 'primary_color' in request.data: - branding_config.primary_color = request.data['primary_color'] - if 'secondary_color' in request.data: - branding_config.secondary_color = request.data['secondary_color'] - if 'tertiary_color' in request.data: - branding_config.tertiary_color = request.data['tertiary_color'] - branding_config.save() - except Exception: # pylint: disable=broad-except - LOGGER.exception( - 'Error with updating branding configuration' - ) - return Response("Error with updating branding configuration", status=status.HTTP_500_INTERNAL_SERVER_ERROR) - return Response("Branding was updated", status=status.HTTP_204_NO_CONTENT) - - -class EnterpriseCustomerCatalogWriteViewSet(EnterpriseWriteOnlyModelViewSet): - """ - API write only views for the ``enterprise-customer-catalog`` API endpoint. - """ - queryset = models.EnterpriseCustomerCatalog.objects.all() - permission_classes = (permissions.IsAdminUser,) - serializer_class = serializers.EnterpriseCustomerCatalogWriteOnlySerializer - - def create(self, request, *args, **kwargs): - """ - Creates a new EnterpriseCustomerCatalog and returns the created object. - - If an EnterpriseCustomerCatalog already exists for the given enterprise_customer and enterprise_catalog_query, - returns the existing object. - - URL: /enterprise/api/v1/enterprise-customer-catalog/ - - Method: POST - - Payload:: - - { - "title": string - Title of the catalog, - "enterprise_customer": string - UUID of an existing enterprise customer, - "enterprise_catalog_query": string - id of an existing enterprise catalog query, - } - - Returns 201 if a new EnterpriseCustomerCatalog was created, 200 if an existing EnterpriseCustomerCatalog was - """ - - enterprise_customer_uuid = request.data.get('enterprise_customer') - enterprise_catalog_query_id = request.data.get('enterprise_catalog_query') - enterprise_customer_catalog_list = models.EnterpriseCustomerCatalog.objects.filter( - enterprise_customer=enterprise_customer_uuid) - for catalog in enterprise_customer_catalog_list: - catalog_query = catalog.enterprise_catalog_query - if catalog_query is not None and catalog_query.id == int(enterprise_catalog_query_id): - seralized_customer_catalog = serializers.EnterpriseCustomerCatalogWriteOnlySerializer( - catalog) - LOGGER.info( - 'EnterpriseCustomerCatalog already exists for enterprise_customer_uuid: %s ' - 'and enterprise_catalog_query_id: %s, using existing catalog: %s', - enterprise_customer_uuid, enterprise_catalog_query_id, catalog.uuid) - return Response(seralized_customer_catalog.data, status=status.HTTP_200_OK) - LOGGER.info( - 'Creating new EnterpriseCustomerCatalog for enterprise_customer_uuid: %s ' - 'and enterprise_catalog_query_id: %s', - enterprise_customer_uuid, enterprise_catalog_query_id) - return super().create(request, *args, **kwargs) - - -class EnterpriseCustomerCatalogViewSet(EnterpriseReadOnlyModelViewSet): - """ - API Views for performing search through course discovery at the ``enterprise_catalogs`` API endpoint. - """ - queryset = models.EnterpriseCustomerCatalog.objects.all() - - USER_ID_FILTER = 'enterprise_customer__enterprise_customer_users__user_id' - FIELDS = ( - 'uuid', 'enterprise_customer', - ) - filterset_fields = FIELDS - ordering_fields = FIELDS - renderer_classes = (JSONRenderer, XMLRenderer,) - - @permission_required('enterprise.can_view_catalog', fn=lambda request, *args, **kwargs: None) - def list(self, request, *args, **kwargs): - return super().list(request, *args, **kwargs) - - @permission_required( - 'enterprise.can_view_catalog', - fn=lambda request, *args, **kwargs: get_enterprise_customer_from_catalog_id(kwargs['pk'])) - def retrieve(self, request, *args, **kwargs): - return super().retrieve(request, *args, **kwargs) - - def get_serializer_class(self): - view_action = getattr(self, 'action', None) - if view_action == 'retrieve': - return serializers.EnterpriseCustomerCatalogDetailSerializer - return serializers.EnterpriseCustomerCatalogSerializer - - @method_decorator(require_at_least_one_query_parameter('course_run_ids', 'program_uuids')) - @action(detail=True) - # pylint: disable=unused-argument - def contains_content_items(self, request, pk, course_run_ids, program_uuids): - """ - Return whether or not the EnterpriseCustomerCatalog contains the specified content. - - Multiple course_run_ids and/or program_uuids query parameters can be sent to this view to check - for their existence in the EnterpriseCustomerCatalog. At least one course run key - or program UUID value must be included in the request. - """ - enterprise_customer_catalog = self.get_object() - - # Maintain plus characters in course key. - course_run_ids = [unquote(quote_plus(course_run_id)) for course_run_id in course_run_ids] - - contains_content_items = True - if course_run_ids: - contains_content_items = enterprise_customer_catalog.contains_courses(course_run_ids) - if program_uuids: - contains_content_items = ( - contains_content_items and - enterprise_customer_catalog.contains_programs(program_uuids) - ) - - return Response({'contains_content_items': contains_content_items}) - - @action(detail=True, url_path='courses/{}'.format(COURSE_KEY_URL_PATTERN)) - @permission_required( - 'enterprise.can_view_catalog', - fn=lambda request, pk, course_key: get_enterprise_customer_from_catalog_id(pk)) - def course_detail(self, request, pk, course_key): # pylint: disable=unused-argument - """ - Return the metadata for the specified course. - - The course needs to be included in the specified EnterpriseCustomerCatalog - in order for metadata to be returned from this endpoint. - """ - enterprise_customer_catalog = self.get_object() - course = enterprise_customer_catalog.get_course(course_key) - if not course: - error_message = _( - '[Enterprise API] CourseKey not found in the Catalog. Course: {course_key}, Catalog: {catalog_id}' - ).format( - course_key=course_key, - catalog_id=enterprise_customer_catalog.uuid, - ) - LOGGER.warning(error_message) - raise Http404 - - context = self.get_serializer_context() - context['enterprise_customer_catalog'] = enterprise_customer_catalog - serializer = serializers.CourseDetailSerializer(course, context=context) - return Response(serializer.data) - - @action(detail=True, url_path='course_runs/{}'.format(settings.COURSE_ID_PATTERN)) - @permission_required( - 'enterprise.can_view_catalog', - fn=lambda request, pk, course_id: get_enterprise_customer_from_catalog_id(pk)) - def course_run_detail(self, request, pk, course_id): # pylint: disable=unused-argument - """ - Return the metadata for the specified course run. - - The course run needs to be included in the specified EnterpriseCustomerCatalog - in order for metadata to be returned from this endpoint. - """ - enterprise_customer_catalog = self.get_object() - course_run = enterprise_customer_catalog.get_course_run(course_id) - if not course_run: - error_message = _( - '[Enterprise API] CourseRun not found in the Catalog. CourseRun: {course_id}, Catalog: {catalog_id}' - ).format( - course_id=course_id, - catalog_id=enterprise_customer_catalog.uuid, - ) - LOGGER.warning(error_message) - raise Http404 - - context = self.get_serializer_context() - context['enterprise_customer_catalog'] = enterprise_customer_catalog - serializer = serializers.CourseRunDetailSerializer(course_run, context=context) - return Response(serializer.data) - - @action(detail=True, url_path='programs/(?P[^/]+)') - @permission_required( - 'enterprise.can_view_catalog', - fn=lambda request, pk, program_uuid: get_enterprise_customer_from_catalog_id(pk)) - def program_detail(self, request, pk, program_uuid): # pylint: disable=unused-argument - """ - Return the metadata for the specified program. - - The program needs to be included in the specified EnterpriseCustomerCatalog - in order for metadata to be returned from this endpoint. - """ - enterprise_customer_catalog = self.get_object() - program = enterprise_customer_catalog.get_program(program_uuid) - if not program: - error_message = _( - '[Enterprise API] Program not found in the Catalog. Program: {program_uuid}, Catalog: {catalog_id}' - ).format( - program_uuid=program_uuid, - catalog_id=enterprise_customer_catalog.uuid, - ) - LOGGER.warning(error_message) - raise Http404 - - context = self.get_serializer_context() - context['enterprise_customer_catalog'] = enterprise_customer_catalog - serializer = serializers.ProgramDetailSerializer(program, context=context) - return Response(serializer.data) - - -class EnterpriseCustomerReportingConfigurationViewSet(EnterpriseReadWriteModelViewSet): - """ - API views for the ``enterprise-customer-reporting`` API endpoint. - """ - - queryset = models.EnterpriseCustomerReportingConfiguration.objects.all() - serializer_class = serializers.EnterpriseCustomerReportingConfigurationSerializer - lookup_field = 'uuid' - permission_classes = [permissions.IsAuthenticated] - - USER_ID_FILTER = 'enterprise_customer__enterprise_customer_users__user_id' - FIELDS = ( - 'enterprise_customer', - ) - filterset_fields = FIELDS - ordering_fields = FIELDS - - @permission_required( - 'enterprise.can_manage_reporting_config', - fn=lambda request, *args, **kwargs: get_ent_cust_from_report_config_uuid(kwargs['uuid'])) - def retrieve(self, request, *args, **kwargs): - return super().retrieve(request, *args, **kwargs) - - @permission_required( - 'enterprise.can_manage_reporting_config', - fn=lambda request, *args, **kwargs: get_enterprise_customer_from_user_id(request.user.id)) - def list(self, request, *args, **kwargs): - return super().list(request, *args, **kwargs) - - @permission_required( - 'enterprise.can_manage_reporting_config', - fn=lambda request, *args, **kwargs: request.data.get('enterprise_customer_id')) - def create(self, request, *args, **kwargs): - config_data = request.data.copy() - serializer = self.get_serializer(data=config_data) - serializer.is_valid(raise_exception=True) - serializer.save() - - return Response(serializer.data, status=status.HTTP_201_CREATED) - - @permission_required( - 'enterprise.can_manage_reporting_config', - fn=lambda request, *args, **kwargs: get_ent_cust_from_report_config_uuid(kwargs['uuid'])) - def update(self, request, *args, **kwargs): - return super().update(request, *args, **kwargs) - - @permission_required( - 'enterprise.can_manage_reporting_config', - fn=lambda request, *args, **kwargs: get_ent_cust_from_report_config_uuid(kwargs['uuid'])) - def partial_update(self, request, *args, **kwargs): - return super().partial_update(request, *args, **kwargs) - - @permission_required( - 'enterprise.can_manage_reporting_config', - fn=lambda request, *args, **kwargs: get_ent_cust_from_report_config_uuid(kwargs['uuid'])) - def destroy(self, request, *args, **kwargs): - return super().destroy(request, *args, **kwargs) - - -class ExpandDefaultPageSize(PageNumberPagination): - """ - Expands page size for the API. - Used to populate support-tools repo's provisioning form catalog query dropdown component. - """ - page_size = 100 - - -class EnterpriseCatalogQueryViewSet(EnterpriseReadOnlyModelViewSet): - """ - API views for the ``enterprise_catalog_query`` API endpoint. - """ - queryset = models.EnterpriseCatalogQuery.objects.all() - serializer_class = serializers.EnterpriseCatalogQuerySerializer - permission_classes = (permissions.IsAuthenticated, permissions.IsAdminUser,) - authentication_classes = (JwtAuthentication, SessionAuthentication,) - pagination_class = ExpandDefaultPageSize - - -class CouponCodesView(APIView): - """ - API to request coupon codes. - """ - permission_classes = (permissions.IsAuthenticated,) - authentication_classes = (JwtAuthentication, SessionAuthentication,) - throttle_classes = (ServiceUserThrottle,) - - REQUIRED_PARAM_EMAIL = 'email' - REQUIRED_PARAM_ENTERPRISE_NAME = 'enterprise_name' - OPTIONAL_PARAM_NUMBER_OF_CODES = 'number_of_codes' - OPTIONAL_PARAM_NOTES = 'notes' - - MISSING_REQUIRED_PARAMS_MSG = "Some required parameter(s) missing: {}" - - def get_required_query_params(self, request): - """ - Gets ``email``, ``enterprise_name``, ``number_of_codes``, and ``notes``, - which are the relevant parameters for this API endpoint. - - :param request: The request to this endpoint. - :return: The ``email``, ``enterprise_name``, ``number_of_codes`` and ``notes`` from the request. - """ - email = get_request_value(request, self.REQUIRED_PARAM_EMAIL, '') - enterprise_name = get_request_value(request, self.REQUIRED_PARAM_ENTERPRISE_NAME, '') - number_of_codes = get_request_value(request, self.OPTIONAL_PARAM_NUMBER_OF_CODES, '') - notes = get_request_value(request, self.OPTIONAL_PARAM_NOTES, '') - if not (email and enterprise_name): - raise CodesAPIRequestError( - self.get_missing_params_message([ - (self.REQUIRED_PARAM_EMAIL, bool(email)), - (self.REQUIRED_PARAM_ENTERPRISE_NAME, bool(enterprise_name)), - ]) - ) - return email, enterprise_name, number_of_codes, notes - - def get_missing_params_message(self, parameter_state): - """ - Get a user-friendly message indicating a missing parameter for the API endpoint. - """ - params = ', '.join(name for name, present in parameter_state if not present) - return self.MISSING_REQUIRED_PARAMS_MSG.format(params) - - @permission_required('enterprise.can_access_admin_dashboard') - def post(self, request): - """ - POST /enterprise/api/v1/request_codes - - Requires a JSON object of the following format:: - - { - "email": "bob@alice.com", - "enterprise_name": "IBM", - "number_of_codes": "50", - "notes": "Help notes for codes request", - } - - Keys: - email: Email of the customer who has requested more codes. - enterprise_name: The name of the enterprise requesting more codes. - number_of_codes: The number of codes requested. - notes: Help notes related to codes request. - """ - try: - email, enterprise_name, number_of_codes, notes = self.get_required_query_params(request) - except CodesAPIRequestError as invalid_request: - return Response({'error': str(invalid_request)}, status=HTTP_400_BAD_REQUEST) - - subject_line = _('Code Management - Request for Codes by {token_enterprise_name}').format( - token_enterprise_name=enterprise_name - ) - body_msg = create_message_body(email, enterprise_name, number_of_codes, notes) - app_config = apps.get_app_config("enterprise") - from_email_address = app_config.enterprise_integrations_email - cs_email = app_config.customer_success_email - data = { - self.REQUIRED_PARAM_EMAIL: email, - self.REQUIRED_PARAM_ENTERPRISE_NAME: enterprise_name, - self.OPTIONAL_PARAM_NUMBER_OF_CODES: number_of_codes, - self.OPTIONAL_PARAM_NOTES: notes, - } - try: - messages_sent = mail.send_mail( - subject_line, - body_msg, - from_email_address, - [cs_email], - fail_silently=False - ) - LOGGER.info('[Enterprise API] Coupon code request emails sent: %s', messages_sent) - return Response(data, status=HTTP_200_OK) - except SMTPException: - error_message = _( - '[Enterprise API] Failure in sending e-mail to support.' - ' SupportEmail: {token_cs_email}, UserEmail: {token_email}, EnterpriseName: {token_enterprise_name}' - ).format( - token_cs_email=cs_email, - token_email=email, - token_enterprise_name=enterprise_name - ) - LOGGER.error(error_message) - return Response( - {'error': 'Request codes email could not be sent'}, - status=HTTP_500_INTERNAL_SERVER_ERROR - ) - - -class NotificationReadView(APIView): - """ - API to mark notifications as read. - """ - permission_classes = (permissions.IsAuthenticated,) - authentication_classes = (JwtAuthentication, SessionAuthentication,) - throttle_classes = (ServiceUserThrottle,) - - REQUIRED_PARAM_NOTIFICATION_ID = 'notification_id' - REQUIRED_PARAM_ENTERPRISE_SLUG = 'enterprise_slug' - - MISSING_REQUIRED_PARAMS_MSG = 'Some required parameter(s) missing: {}' - - def get_required_query_params(self, request): - """ - Gets ``notification_id`` and ``enterprise_slug``. - which are the relevant parameters for this API endpoint. - - :param request: The request to this endpoint. - :return: The ``notification_id`` and ``enterprise_slug`` from the request. - """ - enterprise_slug = get_request_value(request, self.REQUIRED_PARAM_ENTERPRISE_SLUG, '') - notification_id = get_request_value(request, self.REQUIRED_PARAM_NOTIFICATION_ID, '') - if not (notification_id and enterprise_slug): - raise AdminNotificationAPIRequestError( - self.get_missing_params_message([ - (self.REQUIRED_PARAM_NOTIFICATION_ID, bool(notification_id)), - (self.REQUIRED_PARAM_ENTERPRISE_SLUG, bool(enterprise_slug)), - ]) - ) - return notification_id, enterprise_slug - - def get_missing_params_message(self, parameter_state): - """ - Get a user-friendly message indicating a missing parameter for the API endpoint. - """ - params = ', '.join(name for name, present in parameter_state if not present) - return self.MISSING_REQUIRED_PARAMS_MSG.format(params) - - @permission_required('enterprise.can_access_admin_dashboard') - def post(self, request): - """ - POST /enterprise/api/v1/read_notification - - Requires a JSON object of the following format:: - - { - 'notification_id': 1, - 'enterprise_slug': 'enterprise_slug', - } - - Keys: - notification_id: Notification ID which is read by Current User. - enterprise_slug: The slug of the enterprise. - """ - try: - notification_id, enterprise_slug = self.get_required_query_params(request) - except AdminNotificationAPIRequestError as invalid_request: - return Response({'error': str(invalid_request)}, status=HTTP_400_BAD_REQUEST) - - try: - data = { - self.REQUIRED_PARAM_NOTIFICATION_ID: notification_id, - self.REQUIRED_PARAM_ENTERPRISE_SLUG: enterprise_slug, - } - enterprise_customer_user = models.EnterpriseCustomerUser.objects.get( - enterprise_customer__slug=enterprise_slug, user_id=request.user.id - ) - notification_read, _ = models.AdminNotificationRead.objects.get_or_create( - enterprise_customer_user=enterprise_customer_user, - admin_notification_id=notification_id, - is_read=True - ) - LOGGER.info( - '[Admin Notification API] Notification read request successful. AdminNotificationRead ID' - ' {}.'.format(notification_read.id) - ) - return Response(data, status=HTTP_200_OK) - except Exception as exc: # pylint: disable=broad-except - LOGGER.error( - '[Admin Notification API] Notification read request failed, AdminNotification ID:{},Enterprise Slug:{}' - ' User ID:{}, Exception:{}.'.format(notification_id, enterprise_slug, request.user.id, exc) - ) - return Response( - {'error': 'Notification read request failed'}, - status=HTTP_500_INTERNAL_SERVER_ERROR - ) - - -class EnterpriseCustomerReportTypesView(APIView): - """ - API for getting the report types associated with an enterprise customer - """ - authentication_classes = [JwtAuthentication, SessionAuthentication] - permission_classes = [permissions.IsAuthenticated] - http_method_names = ['get'] - - @staticmethod - def _get_data_types_with_recent_progress_type(data_types): - """ - Get the data types with only the most recent 'progress' type version - - Arguments: - data_types (list): List of data type tuples. - - Returns: - (list): List of data type tuples with only the most recent 'progress' type. - e.g. [ ... ('progress', 'progress_v3')] - """ - progress_data_types = [data_type for data_type in data_types if data_type[1].startswith('progress')] - progress_data_types.sort(key=lambda data_type: data_type[1]) - data_types_for_frontend = [data_type for data_type in data_types if not data_type[1].startswith('progress')] - data_types_for_frontend.append((progress_data_types[-1][1], 'progress')) - return data_types_for_frontend - - @staticmethod - def _get_data_types_for_non_pearson_customers(data_types): - """ - Get the data types for non-pearson customers - - Arguments: - data_types (list): List of data type tuples. - - Returns: - (list): List of data type tuples without the Pearson specific types. - """ - reduced_data_types = [] - for data_type in data_types: - if data_type[1] not in models.EnterpriseCustomerReportingConfiguration.MANUAL_REPORTS: - reduced_data_types.append(data_type) - return reduced_data_types - - @permission_required( - 'enterprise.can_access_admin_dashboard', - fn=lambda request, enterprise_uuid: enterprise_uuid - ) - def get(self, request, enterprise_uuid): - """ - Get the dropdown choices for EnterpriseCustomerReportingConfiguration - """ - enterprise_customer = get_enterprise_customer(enterprise_uuid) - if not enterprise_customer: - return Response({'detail': 'Could not find the enterprise customer.'}, status=HTTP_404_NOT_FOUND) - - meta = models.EnterpriseCustomerReportingConfiguration._meta - choices = {} - for field in meta.get_fields(): - if hasattr(field, 'choices') and field.choices: - choices[field.name] = field.choices - # filter out deprecated 'progress' type report versions - data_types_for_frontend = self._get_data_types_with_recent_progress_type(list(choices.get('data_type', []))) - # remove Pearson only reports - choices['data_type'] = ( - self._get_data_types_for_non_pearson_customers(data_types_for_frontend) - if 'pearson' not in enterprise_customer.slug - else data_types_for_frontend - ) - - return Response(data=choices, status=HTTP_200_OK) - - -class EnterpriseCustomerInviteKeyViewSet(EnterpriseReadWriteModelViewSet): - """ - API for accessing enterprise customer keys. - """ - queryset = models.EnterpriseCustomerInviteKey.objects.all() - authentication_classes = (JwtAuthentication, SessionAuthentication) - permission_classes = (permissions.IsAuthenticated,) - - filter_backends = (filters.OrderingFilter, DjangoFilterBackend, EnterpriseCustomerInviteKeyFilterBackend) - http_method_names = ['get', 'post', 'patch'] - - def get_serializer_class(self): - """ - Use a special serializer for any requests that aren't read-only. - """ - if self.request.method in ('POST', 'DELETE'): - return serializers.EnterpriseCustomerInviteKeyWriteSerializer - - if self.request.method == 'PATCH': - return serializers.EnterpriseCustomerInviteKeyPartialUpdateSerializer - - return serializers.EnterpriseCustomerInviteKeyReadOnlySerializer - - def retrieve(self, request, *args, **kwargs): - invite_key = get_object_or_404(models.EnterpriseCustomerInviteKey, pk=kwargs['pk']) - serializer = self.get_serializer(invite_key) - return Response(serializer.data) - - @permission_required('enterprise.can_access_admin_dashboard') - def list(self, request, *args, **kwargs): - return super().list(request, *args, **kwargs) - - @permission_required('enterprise.can_access_admin_dashboard') - @action(methods=['get'], detail=False, url_path='basic-list') - def basic_list(self, request, *args, **kwargs): - """ - Unpaginated list of all invite keys matching the filters. - """ - queryset = self.get_queryset() - queryset = self.filter_queryset(queryset) - serializer = self.get_serializer(queryset, many=True) - return Response(serializer.data) - - @permission_required( - 'enterprise.can_access_admin_dashboard', - fn=lambda request: request.data.get('enterprise_customer_uuid') - ) - def create(self, request, *args, **kwargs): - return super().create(request, *args, **kwargs) - - @permission_required( - 'enterprise.can_access_admin_dashboard', - fn=lambda request, pk: get_ent_cust_from_enterprise_customer_key(pk) - ) - def partial_update(self, request, *args, **kwargs): - try: - return super().partial_update(request, *args, **kwargs) - except ValueError as ex: - return Response({'detail': str(ex)}, status=HTTP_422_UNPROCESSABLE_ENTITY) - - @permission_required( - 'enterprise.can_access_admin_dashboard', - fn=lambda request, pk: get_ent_cust_from_enterprise_customer_key(pk) - ) - def destroy(self, request, *args, **kwargs): - return super().destroy(request, *args, **kwargs) - - @action(methods=['post'], detail=True, url_path='link-user') - def link_user(self, request, pk=None): - """ - Post - Links user using enterprise_customer_key - /enterprise/api/enterprise-customer-invite-key/{enterprise_customer_key}/link-user - - Given a enterprise_customer_key, link user to the appropriate enterprise. - - If the key is not found, returns 404 - If the key is not valid, returns 422 - If we create an `EnterpriseCustomerUser` returns 201 - If an `EnterpriseCustomerUser` if found returns 200 - """ - enterprise_customer_key = get_object_or_404( - models.EnterpriseCustomerInviteKey, - uuid=pk - ) - - if not enterprise_customer_key.is_valid: - return Response( - {"detail": "Enterprise customer invite key is not valid"}, - status=status.HTTP_422_UNPROCESSABLE_ENTITY, - ) - - enterprise_customer = enterprise_customer_key.enterprise_customer - - enterprise_user, created = models.EnterpriseCustomerUser.all_objects.get_or_create( - user_id=request.user.id, - enterprise_customer=enterprise_customer, - ) - - response_body = { - "enterprise_customer_slug": enterprise_customer.slug, - "enterprise_customer_uuid": enterprise_customer.uuid, - } - headers = self.get_success_headers(response_body) - - track_enterprise_user_linked( - request.user.id, - pk, - enterprise_customer.uuid, - created, - ) - - if created: - enterprise_user.invite_key = enterprise_customer_key - enterprise_user.save() - return Response(response_body, status=HTTP_201_CREATED, headers=headers) - - elif not enterprise_user.active or not enterprise_user.linked: - try: - models.EnterpriseCustomerUser.all_objects.link_user( - enterprise_customer, - request.user.email - ) - except LinkUserToEnterpriseError: - return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY) - - enterprise_user.refresh_from_db() - enterprise_user.invite_key = enterprise_customer_key - enterprise_user.save() - - return Response(response_body, status=HTTP_200_OK, headers=headers) - - -class PlotlyAuthView(generics.GenericAPIView): - """ - API to generate a signed token for an enterprise admin to use Plotly analytics. - """ - permission_classes = (IsAuthenticated,) - - @permission_required( - 'enterprise.can_access_admin_dashboard', - fn=lambda request, enterprise_uuid: enterprise_uuid - ) - def get(self, request, enterprise_uuid): - """ - Generate auth token for plotly. - """ - # This is a new secret key and will be only shared between LMS and our Plotly server. - secret_key = settings.ENTERPRISE_PLOTLY_SECRET - - now = int(time()) - expires_in = 3600 # time in seconds after which token will be expired - exp = now + expires_in - - CLAIMS = { - "exp": exp, - "iat": now - } - - jwt_payload = dict({ - 'enterprise_uuid': enterprise_uuid, - }, **CLAIMS) - - token = jwt.encode(jwt_payload, secret_key, algorithm='HS512') - json_payload = {'token': token} - return JsonResponse(json_payload) diff --git a/enterprise/api/v1/views/__init__.py b/enterprise/api/v1/views/__init__.py new file mode 100644 index 0000000000..41d9f57aaf --- /dev/null +++ b/enterprise/api/v1/views/__init__.py @@ -0,0 +1,3 @@ +""" +API views for the enterprise app. +""" diff --git a/enterprise/api/v1/views/base_views.py b/enterprise/api/v1/views/base_views.py new file mode 100644 index 0000000000..d1a1958741 --- /dev/null +++ b/enterprise/api/v1/views/base_views.py @@ -0,0 +1,75 @@ +""" +Base API views for the enterprise app. +""" + +from django_filters.rest_framework import DjangoFilterBackend +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from rest_framework import filters, permissions, viewsets +from rest_framework.authentication import SessionAuthentication +from rest_framework.exceptions import NotFound +from rest_framework.mixins import CreateModelMixin + +from enterprise.api.filters import UserFilterBackend +from enterprise.api.throttles import ServiceUserThrottle +from enterprise.logging import getEnterpriseLogger + +LOGGER = getEnterpriseLogger(__name__) + + +class EnterpriseViewSet: + """ + Base class for all Enterprise view sets. + """ + + permission_classes = (permissions.IsAuthenticated,) + authentication_classes = (JwtAuthentication, SessionAuthentication,) + throttle_classes = (ServiceUserThrottle,) + + def ensure_data_exists(self, request, data, error_message=None): + """ + Ensure that the wrapped API client's response brings us valid data. If not, raise an error and log it. + """ + if not data: + error_message = ( + error_message or "Unable to fetch API response from endpoint '{}'.".format(request.get_full_path()) + ) + LOGGER.error(error_message) + raise NotFound(error_message) + + +class EnterpriseWrapperApiViewSet(EnterpriseViewSet, viewsets.ViewSet): + """ + Base class for attribute and method definitions common to all view sets which wrap external APIs. + """ + + +class EnterpriseModelViewSet(EnterpriseViewSet): + """ + Base class for attribute and method definitions common to all view sets. + """ + + filter_backends = (filters.OrderingFilter, DjangoFilterBackend, UserFilterBackend,) + permission_classes = (permissions.IsAuthenticated, permissions.DjangoModelPermissions,) + USER_ID_FILTER = 'id' + + +class EnterpriseReadOnlyModelViewSet(EnterpriseModelViewSet, viewsets.ReadOnlyModelViewSet): + """ + Base class for all read only Enterprise model view sets. + """ + + +class EnterpriseReadWriteModelViewSet(EnterpriseModelViewSet, viewsets.ModelViewSet): + """ + Base class for all read/write Enterprise model view sets. + """ + + permission_classes = (permissions.IsAuthenticated, permissions.DjangoModelPermissions,) + + +class EnterpriseWriteOnlyModelViewSet(EnterpriseModelViewSet, CreateModelMixin, viewsets.GenericViewSet): + """ + Base class for all write only Enterprise model view sets. + """ + + permission_classes = (permissions.IsAuthenticated, permissions.DjangoModelPermissions) diff --git a/enterprise/api/v1/views/coupon_codes.py b/enterprise/api/v1/views/coupon_codes.py new file mode 100644 index 0000000000..890b5f3613 --- /dev/null +++ b/enterprise/api/v1/views/coupon_codes.py @@ -0,0 +1,132 @@ +""" +Views for coupon codes. +""" + +from smtplib import SMTPException + +from edx_rbac.decorators import permission_required +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from rest_framework import permissions +from rest_framework.authentication import SessionAuthentication +from rest_framework.response import Response +from rest_framework.status import HTTP_200_OK, HTTP_400_BAD_REQUEST, HTTP_500_INTERNAL_SERVER_ERROR +from rest_framework.views import APIView + +from django.apps import apps +from django.core import mail +from django.utils.translation import gettext as _ + +from enterprise.api.throttles import ServiceUserThrottle +from enterprise.api.utils import create_message_body +from enterprise.errors import CodesAPIRequestError +from enterprise.logging import getEnterpriseLogger +from enterprise.utils import get_request_value + +LOGGER = getEnterpriseLogger(__name__) + + +class CouponCodesView(APIView): + """ + API to request coupon codes. + """ + permission_classes = (permissions.IsAuthenticated,) + authentication_classes = (JwtAuthentication, SessionAuthentication,) + throttle_classes = (ServiceUserThrottle,) + + REQUIRED_PARAM_EMAIL = 'email' + REQUIRED_PARAM_ENTERPRISE_NAME = 'enterprise_name' + OPTIONAL_PARAM_NUMBER_OF_CODES = 'number_of_codes' + OPTIONAL_PARAM_NOTES = 'notes' + + MISSING_REQUIRED_PARAMS_MSG = "Some required parameter(s) missing: {}" + + def get_required_query_params(self, request): + """ + Gets ``email``, ``enterprise_name``, ``number_of_codes``, and ``notes``, + which are the relevant parameters for this API endpoint. + + :param request: The request to this endpoint. + :return: The ``email``, ``enterprise_name``, ``number_of_codes`` and ``notes`` from the request. + """ + email = get_request_value(request, self.REQUIRED_PARAM_EMAIL, '') + enterprise_name = get_request_value(request, self.REQUIRED_PARAM_ENTERPRISE_NAME, '') + number_of_codes = get_request_value(request, self.OPTIONAL_PARAM_NUMBER_OF_CODES, '') + notes = get_request_value(request, self.OPTIONAL_PARAM_NOTES, '') + if not (email and enterprise_name): + raise CodesAPIRequestError( + self.get_missing_params_message([ + (self.REQUIRED_PARAM_EMAIL, bool(email)), + (self.REQUIRED_PARAM_ENTERPRISE_NAME, bool(enterprise_name)), + ]) + ) + return email, enterprise_name, number_of_codes, notes + + def get_missing_params_message(self, parameter_state): + """ + Get a user-friendly message indicating a missing parameter for the API endpoint. + """ + params = ', '.join(name for name, present in parameter_state if not present) + return self.MISSING_REQUIRED_PARAMS_MSG.format(params) + + @permission_required('enterprise.can_access_admin_dashboard') + def post(self, request): + """ + POST /enterprise/api/v1/request_codes + + Requires a JSON object of the following format:: + + { + "email": "bob@alice.com", + "enterprise_name": "IBM", + "number_of_codes": "50", + "notes": "Help notes for codes request", + } + + Keys: + email: Email of the customer who has requested more codes. + enterprise_name: The name of the enterprise requesting more codes. + number_of_codes: The number of codes requested. + notes: Help notes related to codes request. + """ + try: + email, enterprise_name, number_of_codes, notes = self.get_required_query_params(request) + except CodesAPIRequestError as invalid_request: + return Response({'error': str(invalid_request)}, status=HTTP_400_BAD_REQUEST) + + subject_line = _('Code Management - Request for Codes by {token_enterprise_name}').format( + token_enterprise_name=enterprise_name + ) + body_msg = create_message_body(email, enterprise_name, number_of_codes, notes) + app_config = apps.get_app_config("enterprise") + from_email_address = app_config.enterprise_integrations_email + cs_email = app_config.customer_success_email + data = { + self.REQUIRED_PARAM_EMAIL: email, + self.REQUIRED_PARAM_ENTERPRISE_NAME: enterprise_name, + self.OPTIONAL_PARAM_NUMBER_OF_CODES: number_of_codes, + self.OPTIONAL_PARAM_NOTES: notes, + } + try: + messages_sent = mail.send_mail( + subject_line, + body_msg, + from_email_address, + [cs_email], + fail_silently=False + ) + LOGGER.info('[Enterprise API] Coupon code request emails sent: %s', messages_sent) + return Response(data, status=HTTP_200_OK) + except SMTPException: + error_message = _( + '[Enterprise API] Failure in sending e-mail to support.' + ' SupportEmail: {token_cs_email}, UserEmail: {token_email}, EnterpriseName: {token_enterprise_name}' + ).format( + token_cs_email=cs_email, + token_email=email, + token_enterprise_name=enterprise_name + ) + LOGGER.error(error_message) + return Response( + {'error': 'Request codes email could not be sent'}, + status=HTTP_500_INTERNAL_SERVER_ERROR + ) diff --git a/enterprise/api/v1/views/enterprise_catalog_query.py b/enterprise/api/v1/views/enterprise_catalog_query.py new file mode 100644 index 0000000000..39a55b0a2f --- /dev/null +++ b/enterprise/api/v1/views/enterprise_catalog_query.py @@ -0,0 +1,31 @@ +""" +Views for the ``enterprise-catalog-query`` API endpoint. +""" + +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from rest_framework import permissions +from rest_framework.authentication import SessionAuthentication +from rest_framework.pagination import PageNumberPagination + +from enterprise import models +from enterprise.api.v1 import serializers +from enterprise.api.v1.views.base_views import EnterpriseReadOnlyModelViewSet + + +class ExpandDefaultPageSize(PageNumberPagination): + """ + Expands page size for the API. + Used to populate support-tools repo's provisioning form catalog query dropdown component. + """ + page_size = 100 + + +class EnterpriseCatalogQueryViewSet(EnterpriseReadOnlyModelViewSet): + """ + API views for the ``enterprise_catalog_query`` API endpoint. + """ + queryset = models.EnterpriseCatalogQuery.objects.all() + serializer_class = serializers.EnterpriseCatalogQuerySerializer + permission_classes = (permissions.IsAuthenticated, permissions.IsAdminUser,) + authentication_classes = (JwtAuthentication, SessionAuthentication,) + pagination_class = ExpandDefaultPageSize diff --git a/enterprise/api/v1/views/enterprise_course_enrollment.py b/enterprise/api/v1/views/enterprise_course_enrollment.py new file mode 100644 index 0000000000..c7aef5ffc0 --- /dev/null +++ b/enterprise/api/v1/views/enterprise_course_enrollment.py @@ -0,0 +1,29 @@ +""" +Views for the ``enterprise-course-enrollment`` API endpoint. +""" +from enterprise import models +from enterprise.api.v1 import serializers +from enterprise.api.v1.views.base_views import EnterpriseReadWriteModelViewSet + + +class EnterpriseCourseEnrollmentViewSet(EnterpriseReadWriteModelViewSet): + """ + API views for the ``enterprise-course-enrollment`` API endpoint. + """ + + queryset = models.EnterpriseCourseEnrollment.objects.all() + + USER_ID_FILTER = 'enterprise_customer_user__user_id' + FIELDS = ( + 'enterprise_customer_user', 'course_id' + ) + filterset_fields = FIELDS + ordering_fields = FIELDS + + def get_serializer_class(self): + """ + Use a special serializer for any requests that aren't read-only. + """ + if self.request.method in ('GET',): + return serializers.EnterpriseCourseEnrollmentReadOnlySerializer + return serializers.EnterpriseCourseEnrollmentWriteSerializer diff --git a/enterprise/api/v1/views/enterprise_customer.py b/enterprise/api/v1/views/enterprise_customer.py new file mode 100644 index 0000000000..097e17cca8 --- /dev/null +++ b/enterprise/api/v1/views/enterprise_customer.py @@ -0,0 +1,436 @@ +""" +Views for the ``enterprise-customer`` API endpoint. +""" + +from urllib.parse import quote_plus, unquote + +from edx_rbac.decorators import permission_required +from rest_framework import permissions +from rest_framework.decorators import action +from rest_framework.exceptions import ValidationError +from rest_framework.response import Response +from rest_framework.status import ( + HTTP_200_OK, + HTTP_201_CREATED, + HTTP_202_ACCEPTED, + HTTP_400_BAD_REQUEST, + HTTP_409_CONFLICT, +) + +from django.contrib import auth +from django.core import exceptions +from django.db import transaction +from django.db.models import Q +from django.shortcuts import get_object_or_404 +from django.utils.decorators import method_decorator + +from enterprise import models +from enterprise.api.filters import EnterpriseLinkedUserFilterBackend +from enterprise.api.throttles import HighServiceUserThrottle +from enterprise.api.v1 import serializers +from enterprise.api.v1.decorators import require_at_least_one_query_parameter +from enterprise.api.v1.permissions import IsInEnterpriseGroup +from enterprise.api.v1.views.base_views import EnterpriseReadWriteModelViewSet +from enterprise.constants import PATHWAY_CUSTOMER_ADMIN_ENROLLMENT +from enterprise.errors import LinkUserToEnterpriseError, UnlinkUserFromEnterpriseError +from enterprise.logging import getEnterpriseLogger +from enterprise.utils import ( + enroll_subsidy_users_in_courses, + get_best_mode_from_course_key, + track_enrollment, + validate_email_to_link, +) + +User = auth.get_user_model() + +LOGGER = getEnterpriseLogger(__name__) + + +class EnterpriseCustomerViewSet(EnterpriseReadWriteModelViewSet): + """ + API views for the ``enterprise-customer`` API endpoint. + """ + throttle_classes = (HighServiceUserThrottle, ) + queryset = models.EnterpriseCustomer.active_customers.all() + serializer_class = serializers.EnterpriseCustomerSerializer + filter_backends = EnterpriseReadWriteModelViewSet.filter_backends + (EnterpriseLinkedUserFilterBackend,) + + USER_ID_FILTER = 'enterprise_customer_users__user_id' + FIELDS = ( + 'uuid', 'slug', 'name', 'active', 'site', 'enable_data_sharing_consent', + 'enforce_data_sharing_consent', + ) + filterset_fields = FIELDS + ordering_fields = FIELDS + + def get_permissions(self): + if self.action == 'create': + return [permissions.IsAuthenticated()] + elif self.action == 'partial_update': + return [permissions.IsAuthenticated()] + else: + return [permission() for permission in self.permission_classes] + + def get_serializer_class(self): + if self.action == 'basic_list': + return serializers.EnterpriseCustomerBasicSerializer + return self.serializer_class + + @action(detail=False) + # pylint: disable=unused-argument + def basic_list(self, request, *arg, **kwargs): + """ + Enterprise Customer's Basic data list without pagination + + Two query parameters are supported: + - name_or_uuid: filter by name or uuid substring search in a single query parameter. + Primarily used for frontend debounced input search. + - startswith: filter by name starting with the given string + """ + startswith = request.GET.get('startswith') + name_or_uuid = request.GET.get('name_or_uuid') + queryset = self.get_queryset().order_by('name') + if startswith: + queryset = queryset.filter(name__istartswith=startswith) + if name_or_uuid: + queryset = queryset.filter(Q(name__icontains=name_or_uuid) | Q(uuid__icontains=name_or_uuid)) + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + + @permission_required('enterprise.can_access_admin_dashboard') + def create(self, request, *args, **kwargs): + """ + POST /enterprise/api/v1/enterprise-customer/ + """ + return super().create(request, *args, **kwargs) + + @permission_required('enterprise.can_access_admin_dashboard', fn=lambda request, pk: pk) + def partial_update(self, request, *args, **kwargs): + return super().partial_update(request, *args, **kwargs) + + @method_decorator(require_at_least_one_query_parameter('course_run_ids', 'program_uuids')) + @action(detail=True) + @permission_required('enterprise.can_view_catalog', fn=lambda request, pk, course_run_ids, program_uuids: pk) + # pylint: disable=unused-argument + def contains_content_items(self, request, pk, course_run_ids, program_uuids): + """ + Return whether or not the specified content is available to the EnterpriseCustomer. + + Multiple course_run_ids and/or program_uuids query parameters can be sent to this view to check + for their existence in the EnterpriseCustomerCatalogs associated with this EnterpriseCustomer. + At least one course run key or program UUID value must be included in the request. + """ + enterprise_customer = self.get_object() + + # Maintain plus characters in course key. + course_run_ids = [unquote(quote_plus(course_run_id)) for course_run_id in course_run_ids] + + contains_content_items = False + for catalog in enterprise_customer.enterprise_customer_catalogs.all(): + contains_course_runs = not course_run_ids or catalog.contains_courses(course_run_ids) + contains_program_uuids = not program_uuids or catalog.contains_programs(program_uuids) + if contains_course_runs and contains_program_uuids: + contains_content_items = True + break + + return Response({'contains_content_items': contains_content_items}) + + @action(methods=['post'], permission_classes=[permissions.IsAuthenticated], detail=True) + @permission_required('enterprise.can_enroll_learners', fn=lambda request, pk: pk) + # pylint: disable=unused-argument + def course_enrollments(self, request, pk): + """ + Creates a course enrollment for an EnterpriseCustomerUser. + """ + enterprise_customer = self.get_object() + serializer = serializers.EnterpriseCustomerCourseEnrollmentsSerializer( + data=request.data, + many=True, + context={ + 'enterprise_customer': enterprise_customer, + 'request_user': request.user, + } + ) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=HTTP_200_OK) + + return Response(serializer.errors, status=HTTP_400_BAD_REQUEST) + + @action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated]) + @permission_required('enterprise.can_enroll_learners', fn=lambda request, pk: pk) + # pylint: disable=unused-argument, too-many-statements + def enroll_learners_in_courses(self, request, pk): + """ + Creates a set of enterprise enrollments for specified learners by bulk enrolling them in provided courses. + This endpoint is not transactional, in that any one or more failures will not affect other successful + enrollments made within the same request. + + Parameters: + enrollments_info (list of dicts): an array of dictionaries, each containing the necessary information to + create an enrollment based on a subsidy for a user in a specified course. Each dictionary must contain + a user email (or user_id), a course run key, and either a UUID of the license that the learner is using + to enroll with or a transaction ID related to Executive Education the enrollment. `licenses_info` is + also accepted as a body param name. + + Example:: + + enrollments_info: [ + { + 'email': 'newuser@test.com', + 'course_run_key': 'course-v1:edX+DemoX+Demo_Course', + 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae', + }, + { + 'email': 'newuser2@test.com', + 'course_run_key': 'course-v2:edX+FunX+Fun_Course', + 'transaction_id': '84kdbdbade7b4fcb838f8asjke8e18ae', + }, + { + 'user_id': 1234, + 'course_run_key': 'course-v2:edX+SadX+Sad_Course', + 'transaction_id': 'ba1f7b61951987dc2e1743fa4886b62d', + }, + ... + ] + + discount (int): the percent discount to be applied to all enrollments. Defaults to 100. + + Returns: + Success cases: + - All users exist and are enrolled - + {'successes': [], 'pending': [], 'failures': []}, 201 + - Some or none of the users exist but are enrolled - + {'successes': [], 'pending': [], 'failures': []}, 202 + + Failure cases: + - Some or all of the users can't be enrolled, no users were enrolled - + {'successes': [], 'pending': [], 'failures': []}, 409 + + - Some or all of the provided emails are invalid + {'successes': [], 'pending': [], 'failures': [] 'invalid_email_addresses': []}, 409 + """ + enterprise_customer = self.get_object() + serializer = serializers.EnterpriseCustomerBulkSubscriptionEnrollmentsSerializer( + data=request.data, + context={ + 'enterprise_customer': enterprise_customer, + 'request_user': request.user, + } + ) + try: + serializer.is_valid(raise_exception=True) + except ValidationError: + error_message = "Something went wrong while validating bulk enrollment requests." \ + "Received exception: {}".format(serializer.errors) + LOGGER.warning(error_message) + return Response(serializer.errors, status=HTTP_400_BAD_REQUEST) + + user_id_errors = [] + email_errors = [] + serialized_data = serializer.validated_data + enrollments_info = serialized_data.get('licenses_info', serialized_data.get('enrollments_info')) + + # Default subscription discount is 100% + discount = serialized_data.get('discount', 100.00) + + # Retrieve and store course modes for each unique course provided + course_runs_modes = {enrollment_info['course_run_key']: None for enrollment_info in enrollments_info} + for course_run in course_runs_modes: + course_runs_modes[course_run] = get_best_mode_from_course_key(course_run) + + emails = set() + + for info in enrollments_info: + if 'user_id' in info: + user = User.objects.filter(id=info['user_id']).first() + if user: + info['email'] = user.email + emails.add(user.email) + else: + user_id_errors.append(info['user_id']) + else: + emails.add(info['email']) + info['course_mode'] = course_runs_modes[info['course_run_key']] + + for email in emails: + try: + validate_email_to_link(email, enterprise_customer, raise_exception=False) + except exceptions.ValidationError: + email_errors.append(email) + + for email in emails: + try: + models.EnterpriseCustomerUser.all_objects.link_user(enterprise_customer, email) + except LinkUserToEnterpriseError: + email_errors.append(email) + + # Remove the bad emails and bad user_ids from enrollments_info; don't attempt to enroll or link them. + enrollments_info = [ + info for info in enrollments_info + if info.get('email') not in email_errors and info.get('user_id') not in user_id_errors + ] + + results = enroll_subsidy_users_in_courses(enterprise_customer, enrollments_info, discount) + + # collect the returned activation links for licenses which need activation + activation_links = {} + for result_kind in ['successes', 'pending']: + for result in results[result_kind]: + if result.get('activation_link') is not None: + activation_links[result['email']] = result.get('activation_link') + + for course_run in course_runs_modes: + pending_users = { + result.pop('user') for result in results['pending'] + if result['course_run_key'] == course_run and result.get('created') + } + existing_users = { + result.pop('user') for result in results['successes'] + if result['course_run_key'] == course_run and result.get('created') + } + if len(pending_users | existing_users) > 0: + LOGGER.info("Successfully bulk enrolled learners: {} into course {}".format( + pending_users | existing_users, + course_run, + )) + track_enrollment(PATHWAY_CUSTOMER_ADMIN_ENROLLMENT, request.user.id, course_run) + if serializer.validated_data.get('notify'): + enterprise_customer.notify_enrolled_learners( + catalog_api_user=request.user, + course_id=course_run, + users=pending_users | existing_users, + admin_enrollment=True, + activation_links=activation_links, + ) + + # Remove the user object from the results for any already existing enrollment cases (ie created = False) as + # these are not JSON serializable + existing_enrollments = [] + for result in results['pending']: + already_enrolled_pending_user = result.pop('user', None) + existing_enrollments.append(already_enrolled_pending_user) + + for result in results['successes']: + already_enrolled_user = result.pop('user', None) + existing_enrollments.append(already_enrolled_user) + + if existing_enrollments: + LOGGER.info( + f'Bulk enrollment request submitted for users: {existing_enrollments} who already have enrollments' + ) + + if user_id_errors: + results['invalid_user_ids'] = user_id_errors + if email_errors: + results['invalid_email_addresses'] = email_errors + + if results['failures'] or email_errors or user_id_errors: + return Response(results, status=HTTP_409_CONFLICT) + if results['pending']: + return Response(results, status=HTTP_202_ACCEPTED) + return Response(results, status=HTTP_201_CREATED) + + @method_decorator(require_at_least_one_query_parameter('permissions')) + @action(permission_classes=[permissions.IsAuthenticated, IsInEnterpriseGroup], detail=False) + def with_access_to(self, request, *args, **kwargs): + """ + Returns the list of enterprise customers the user has a specified group permission access to. + """ + self.queryset = self.queryset.order_by('name') + enterprise_id = self.request.query_params.get('enterprise_id', None) + enterprise_slug = self.request.query_params.get('enterprise_slug', None) + enterprise_name = self.request.query_params.get('search', None) + + if enterprise_id is not None: + self.queryset = self.queryset.filter(uuid=enterprise_id) + elif enterprise_slug is not None: + self.queryset = self.queryset.filter(slug=enterprise_slug) + elif enterprise_name is not None: + self.queryset = self.queryset.filter(name__icontains=enterprise_name) + return self.list(request, *args, **kwargs) + + @action(detail=False) + @permission_required('enterprise.can_access_admin_dashboard') + def dashboard_list(self, request, *args, **kwargs): + """ + Supports listing dashboard enterprises for frontend-app-admin-portal. + """ + self.queryset = self.queryset.order_by('name') + enterprise_id = self.request.query_params.get('enterprise_id', None) + enterprise_slug = self.request.query_params.get('enterprise_slug', None) + enterprise_name = self.request.query_params.get('search', None) + + if enterprise_id is not None: + self.queryset = self.queryset.filter(uuid=enterprise_id) + elif enterprise_slug is not None: + self.queryset = self.queryset.filter(slug=enterprise_slug) + elif enterprise_name is not None: + self.queryset = self.queryset.filter(name__icontains=enterprise_name) + return self.list(request, *args, **kwargs) + + @action(methods=['patch'], detail=True, permission_classes=[permissions.IsAuthenticated]) + @permission_required('enterprise.can_access_admin_dashboard') + def toggle_universal_link(self, request, pk=None): + """ + Enables/Disables universal link config. + """ + + enterprise_customer = get_object_or_404(models.EnterpriseCustomer, uuid=pk) + serializer = serializers.EnterpriseCustomerToggleUniversalLinkSerializer( + data=request.data, + context={ + 'enterprise_customer': enterprise_customer, + 'request_user': request.user, + } + ) + + if not serializer.is_valid(): + return Response(serializer.errors, status=HTTP_400_BAD_REQUEST) + + enable_universal_link = serializer.validated_data.get('enable_universal_link') + + if enterprise_customer.enable_universal_link == enable_universal_link: + return Response({"detail": "No changes"}, status=HTTP_200_OK) + + enterprise_customer.toggle_universal_link( + enable_universal_link, + ) + + response_body = {"enable_universal_link": enable_universal_link} + headers = self.get_success_headers(response_body) + return Response(response_body, status=HTTP_200_OK, headers=headers) + + @action(methods=['post'], detail=True, permission_classes=[permissions.IsAuthenticated]) + @permission_required('enterprise.can_access_admin_dashboard', fn=lambda request, pk: pk) + def unlink_users(self, request, pk=None): # pylint: disable=unused-argument + """ + Unlinks users with the given emails from the enterprise. + """ + + serializer = serializers.EnterpriseCustomerUnlinkUsersSerializer( + data=request.data + ) + + serializer.is_valid(raise_exception=True) + + enterprise_customer = self.get_object() + emails_to_unlink = serializer.data.get('user_emails', []) + is_relinkable = serializer.data.get('is_relinkable', True) + + with transaction.atomic(): + for email in emails_to_unlink: + try: + models.EnterpriseCustomerUser.objects.unlink_user( + enterprise_customer=enterprise_customer, + user_email=email, + is_relinkable=is_relinkable + ) + except (models.EnterpriseCustomerUser.DoesNotExist, models.PendingEnterpriseCustomerUser.DoesNotExist): + msg = "User with email {} does not exist in enterprise {}.".format(email, enterprise_customer) + LOGGER.warning(msg) + except Exception as exc: + msg = "Could not unlink {} from {}".format(email, enterprise_customer) + raise UnlinkUserFromEnterpriseError(msg) from exc + + return Response(status=HTTP_200_OK) diff --git a/enterprise/api/v1/views/enterprise_customer_branding_configuration.py b/enterprise/api/v1/views/enterprise_customer_branding_configuration.py new file mode 100644 index 0000000000..9183c4de27 --- /dev/null +++ b/enterprise/api/v1/views/enterprise_customer_branding_configuration.py @@ -0,0 +1,69 @@ +""" +Views for the ``enterprise-customer-branding`` API endpoint. +""" + +from edx_rbac.decorators import permission_required +from rest_framework import permissions, status +from rest_framework.decorators import action +from rest_framework.parsers import FormParser, MultiPartParser +from rest_framework.response import Response + +from enterprise import models +from enterprise.api.v1 import serializers +from enterprise.api.v1.views.base_views import EnterpriseReadWriteModelViewSet +from enterprise.logging import getEnterpriseLogger + +LOGGER = getEnterpriseLogger(__name__) + + +class EnterpriseCustomerBrandingConfigurationViewSet(EnterpriseReadWriteModelViewSet): + """ + API views for the ``enterprise-customer-branding`` API endpoint. + """ + permission_classes = (permissions.IsAuthenticated,) + parser_classes = [MultiPartParser, FormParser] + queryset = models.EnterpriseCustomerBrandingConfiguration.objects.all() + serializer_class = serializers.EnterpriseCustomerBrandingConfigurationSerializer + + USER_ID_FILTER = 'enterprise_customer__enterprise_customer_users__user_id' + FIELDS = ( + 'enterprise_customer__slug', + ) + filterset_fields = FIELDS + ordering_fields = FIELDS + lookup_field = 'enterprise_customer__slug' + + @action(methods=['patch'], detail=False, permission_classes=[permissions.IsAuthenticated]) + @permission_required('enterprise.can_access_admin_dashboard', fn=lambda request, enterprise_uuid: enterprise_uuid) + def update_branding(self, request, enterprise_uuid): + """ + PATCH /enterprise/api/v1/enterprise-customer-branding/update_branding/uuid + + Requires enterprise customer uuid path parameter + """ + try: + enterprise_customer = models.EnterpriseCustomer.objects.get(uuid=enterprise_uuid) + branding_configs = models.EnterpriseCustomerBrandingConfiguration.objects.filter( + enterprise_customer=enterprise_customer) + if len(branding_configs) > 0: + branding_config = models.EnterpriseCustomerBrandingConfiguration.objects.get( + enterprise_customer=enterprise_customer) + else: + branding_config = models.EnterpriseCustomerBrandingConfiguration( + enterprise_customer=enterprise_customer) + + if 'logo' in request.data: + branding_config.logo = request.data['logo'] + if 'primary_color' in request.data: + branding_config.primary_color = request.data['primary_color'] + if 'secondary_color' in request.data: + branding_config.secondary_color = request.data['secondary_color'] + if 'tertiary_color' in request.data: + branding_config.tertiary_color = request.data['tertiary_color'] + branding_config.save() + except Exception: # pylint: disable=broad-except + LOGGER.exception( + 'Error with updating branding configuration' + ) + return Response("Error with updating branding configuration", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return Response("Branding was updated", status=status.HTTP_204_NO_CONTENT) diff --git a/enterprise/api/v1/views/enterprise_customer_catalog.py b/enterprise/api/v1/views/enterprise_customer_catalog.py new file mode 100644 index 0000000000..a128712192 --- /dev/null +++ b/enterprise/api/v1/views/enterprise_customer_catalog.py @@ -0,0 +1,219 @@ +""" +Write views for the ``enterprise-customer-catalog`` API endpoint. +""" +from urllib.parse import quote_plus, unquote + +from edx_rbac.decorators import permission_required +from rest_framework import permissions, status +from rest_framework.decorators import action +from rest_framework.renderers import JSONRenderer +from rest_framework.response import Response +from rest_framework_xml.renderers import XMLRenderer + +from django.conf import settings +from django.http import Http404 +from django.utils.decorators import method_decorator +from django.utils.translation import gettext as _ + +from enterprise import models +from enterprise.api.utils import get_enterprise_customer_from_catalog_id +from enterprise.api.v1 import serializers +from enterprise.api.v1.decorators import require_at_least_one_query_parameter +from enterprise.api.v1.views.base_views import EnterpriseReadOnlyModelViewSet, EnterpriseWriteOnlyModelViewSet +from enterprise.constants import COURSE_KEY_URL_PATTERN +from enterprise.logging import getEnterpriseLogger + +LOGGER = getEnterpriseLogger(__name__) + + +class EnterpriseCustomerCatalogWriteViewSet(EnterpriseWriteOnlyModelViewSet): + """ + API write only views for the ``enterprise-customer-catalog`` API endpoint. + """ + queryset = models.EnterpriseCustomerCatalog.objects.all() + permission_classes = (permissions.IsAdminUser,) + serializer_class = serializers.EnterpriseCustomerCatalogWriteOnlySerializer + + def create(self, request, *args, **kwargs): + """ + Creates a new EnterpriseCustomerCatalog and returns the created object. + + If an EnterpriseCustomerCatalog already exists for the given enterprise_customer and enterprise_catalog_query, + returns the existing object. + + URL: /enterprise/api/v1/enterprise-customer-catalog/ + + Method: POST + + Payload:: + + { + "title": string - Title of the catalog, + "enterprise_customer": string - UUID of an existing enterprise customer, + "enterprise_catalog_query": string - id of an existing enterprise catalog query, + } + + Returns 201 if a new EnterpriseCustomerCatalog was created, 200 if an existing EnterpriseCustomerCatalog was + """ + + enterprise_customer_uuid = request.data.get('enterprise_customer') + enterprise_catalog_query_id = request.data.get('enterprise_catalog_query') + enterprise_customer_catalog_list = models.EnterpriseCustomerCatalog.objects.filter( + enterprise_customer=enterprise_customer_uuid) + for catalog in enterprise_customer_catalog_list: + catalog_query = catalog.enterprise_catalog_query + if catalog_query is not None and catalog_query.id == int(enterprise_catalog_query_id): + serialized_customer_catalog = serializers.EnterpriseCustomerCatalogWriteOnlySerializer( + catalog) + LOGGER.info( + 'EnterpriseCustomerCatalog already exists for enterprise_customer_uuid: %s ' + 'and enterprise_catalog_query_id: %s, using existing catalog: %s', + enterprise_customer_uuid, enterprise_catalog_query_id, catalog.uuid) + return Response(serialized_customer_catalog.data, status=status.HTTP_200_OK) + LOGGER.info( + 'Creating new EnterpriseCustomerCatalog for enterprise_customer_uuid: %s ' + 'and enterprise_catalog_query_id: %s', + enterprise_customer_uuid, enterprise_catalog_query_id) + return super().create(request, *args, **kwargs) + + +class EnterpriseCustomerCatalogViewSet(EnterpriseReadOnlyModelViewSet): + """ + API Views for performing search through course discovery at the ``enterprise_catalogs`` API endpoint. + """ + queryset = models.EnterpriseCustomerCatalog.objects.all() + + USER_ID_FILTER = 'enterprise_customer__enterprise_customer_users__user_id' + FIELDS = ( + 'uuid', 'enterprise_customer', + ) + filterset_fields = FIELDS + ordering_fields = FIELDS + renderer_classes = (JSONRenderer, XMLRenderer,) + + @permission_required('enterprise.can_view_catalog', fn=lambda request, *args, **kwargs: None) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + + @permission_required( + 'enterprise.can_view_catalog', + fn=lambda request, *args, **kwargs: get_enterprise_customer_from_catalog_id(kwargs['pk'])) + def retrieve(self, request, *args, **kwargs): + return super().retrieve(request, *args, **kwargs) + + def get_serializer_class(self): + view_action = getattr(self, 'action', None) + if view_action == 'retrieve': + return serializers.EnterpriseCustomerCatalogDetailSerializer + return serializers.EnterpriseCustomerCatalogSerializer + + @method_decorator(require_at_least_one_query_parameter('course_run_ids', 'program_uuids')) + @action(detail=True) + # pylint: disable=unused-argument + def contains_content_items(self, request, pk, course_run_ids, program_uuids): + """ + Return whether or not the EnterpriseCustomerCatalog contains the specified content. + + Multiple course_run_ids and/or program_uuids query parameters can be sent to this view to check + for their existence in the EnterpriseCustomerCatalog. At least one course run key + or program UUID value must be included in the request. + """ + enterprise_customer_catalog = self.get_object() + + # Maintain plus characters in course key. + course_run_ids = [unquote(quote_plus(course_run_id)) for course_run_id in course_run_ids] + + contains_content_items = True + if course_run_ids: + contains_content_items = enterprise_customer_catalog.contains_courses(course_run_ids) + if program_uuids: + contains_content_items = ( + contains_content_items and + enterprise_customer_catalog.contains_programs(program_uuids) + ) + + return Response({'contains_content_items': contains_content_items}) + + @action(detail=True, url_path='courses/{}'.format(COURSE_KEY_URL_PATTERN)) + @permission_required( + 'enterprise.can_view_catalog', + fn=lambda request, pk, course_key: get_enterprise_customer_from_catalog_id(pk)) + def course_detail(self, request, pk, course_key): # pylint: disable=unused-argument + """ + Return the metadata for the specified course. + + The course needs to be included in the specified EnterpriseCustomerCatalog + in order for metadata to be returned from this endpoint. + """ + enterprise_customer_catalog = self.get_object() + course = enterprise_customer_catalog.get_course(course_key) + if not course: + error_message = _( + '[Enterprise API] CourseKey not found in the Catalog. Course: {course_key}, Catalog: {catalog_id}' + ).format( + course_key=course_key, + catalog_id=enterprise_customer_catalog.uuid, + ) + LOGGER.warning(error_message) + raise Http404 + + context = self.get_serializer_context() + context['enterprise_customer_catalog'] = enterprise_customer_catalog + serializer = serializers.CourseDetailSerializer(course, context=context) + return Response(serializer.data) + + @action(detail=True, url_path='course_runs/{}'.format(settings.COURSE_ID_PATTERN)) + @permission_required( + 'enterprise.can_view_catalog', + fn=lambda request, pk, course_id: get_enterprise_customer_from_catalog_id(pk)) + def course_run_detail(self, request, pk, course_id): # pylint: disable=unused-argument + """ + Return the metadata for the specified course run. + + The course run needs to be included in the specified EnterpriseCustomerCatalog + in order for metadata to be returned from this endpoint. + """ + enterprise_customer_catalog = self.get_object() + course_run = enterprise_customer_catalog.get_course_run(course_id) + if not course_run: + error_message = _( + '[Enterprise API] CourseRun not found in the Catalog. CourseRun: {course_id}, Catalog: {catalog_id}' + ).format( + course_id=course_id, + catalog_id=enterprise_customer_catalog.uuid, + ) + LOGGER.warning(error_message) + raise Http404 + + context = self.get_serializer_context() + context['enterprise_customer_catalog'] = enterprise_customer_catalog + serializer = serializers.CourseRunDetailSerializer(course_run, context=context) + return Response(serializer.data) + + @action(detail=True, url_path='programs/(?P[^/]+)') + @permission_required( + 'enterprise.can_view_catalog', + fn=lambda request, pk, program_uuid: get_enterprise_customer_from_catalog_id(pk)) + def program_detail(self, request, pk, program_uuid): # pylint: disable=unused-argument + """ + Return the metadata for the specified program. + + The program needs to be included in the specified EnterpriseCustomerCatalog + in order for metadata to be returned from this endpoint. + """ + enterprise_customer_catalog = self.get_object() + program = enterprise_customer_catalog.get_program(program_uuid) + if not program: + error_message = _( + '[Enterprise API] Program not found in the Catalog. Program: {program_uuid}, Catalog: {catalog_id}' + ).format( + program_uuid=program_uuid, + catalog_id=enterprise_customer_catalog.uuid, + ) + LOGGER.warning(error_message) + raise Http404 + + context = self.get_serializer_context() + context['enterprise_customer_catalog'] = enterprise_customer_catalog + serializer = serializers.ProgramDetailSerializer(program, context=context) + return Response(serializer.data) diff --git a/enterprise/api/v1/views/enterprise_customer_invite_key.py b/enterprise/api/v1/views/enterprise_customer_invite_key.py new file mode 100644 index 0000000000..8c2b641d70 --- /dev/null +++ b/enterprise/api/v1/views/enterprise_customer_invite_key.py @@ -0,0 +1,158 @@ +""" +Views for enterprise customer invite keys. +""" + +from django_filters.rest_framework import DjangoFilterBackend +from edx_rbac.decorators import permission_required +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from rest_framework import filters, permissions, status +from rest_framework.authentication import SessionAuthentication +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_422_UNPROCESSABLE_ENTITY + +from django.shortcuts import get_object_or_404 + +from enterprise import models +from enterprise.api.filters import EnterpriseCustomerInviteKeyFilterBackend +from enterprise.api.utils import get_ent_cust_from_enterprise_customer_key +from enterprise.api.v1 import serializers +from enterprise.api.v1.views.base_views import EnterpriseReadWriteModelViewSet +from enterprise.errors import LinkUserToEnterpriseError +from enterprise.logging import getEnterpriseLogger +from enterprise.utils import track_enterprise_user_linked + +LOGGER = getEnterpriseLogger(__name__) + + +class EnterpriseCustomerInviteKeyViewSet(EnterpriseReadWriteModelViewSet): + """ + API for accessing enterprise customer keys. + """ + queryset = models.EnterpriseCustomerInviteKey.objects.all() + authentication_classes = (JwtAuthentication, SessionAuthentication) + permission_classes = (permissions.IsAuthenticated,) + + filter_backends = (filters.OrderingFilter, DjangoFilterBackend, EnterpriseCustomerInviteKeyFilterBackend) + http_method_names = ['get', 'post', 'patch'] + + def get_serializer_class(self): + """ + Use a special serializer for any requests that aren't read-only. + """ + if self.request.method in ('POST', 'DELETE'): + return serializers.EnterpriseCustomerInviteKeyWriteSerializer + + if self.request.method == 'PATCH': + return serializers.EnterpriseCustomerInviteKeyPartialUpdateSerializer + + return serializers.EnterpriseCustomerInviteKeyReadOnlySerializer + + def retrieve(self, request, *args, **kwargs): + invite_key = get_object_or_404(models.EnterpriseCustomerInviteKey, pk=kwargs['pk']) + serializer = self.get_serializer(invite_key) + return Response(serializer.data) + + @permission_required('enterprise.can_access_admin_dashboard') + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + + @permission_required('enterprise.can_access_admin_dashboard') + @action(methods=['get'], detail=False, url_path='basic-list') + def basic_list(self, request, *args, **kwargs): + """ + Unpaginated list of all invite keys matching the filters. + """ + queryset = self.get_queryset() + queryset = self.filter_queryset(queryset) + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + + @permission_required( + 'enterprise.can_access_admin_dashboard', + fn=lambda request: request.data.get('enterprise_customer_uuid') + ) + def create(self, request, *args, **kwargs): + return super().create(request, *args, **kwargs) + + @permission_required( + 'enterprise.can_access_admin_dashboard', + fn=lambda request, pk: get_ent_cust_from_enterprise_customer_key(pk) + ) + def partial_update(self, request, *args, **kwargs): + try: + return super().partial_update(request, *args, **kwargs) + except ValueError as ex: + return Response({'detail': str(ex)}, status=HTTP_422_UNPROCESSABLE_ENTITY) + + @permission_required( + 'enterprise.can_access_admin_dashboard', + fn=lambda request, pk: get_ent_cust_from_enterprise_customer_key(pk) + ) + def destroy(self, request, *args, **kwargs): + return super().destroy(request, *args, **kwargs) + + @action(methods=['post'], detail=True, url_path='link-user') + def link_user(self, request, pk=None): + """ + Post + Links user using enterprise_customer_key + /enterprise/api/enterprise-customer-invite-key/{enterprise_customer_key}/link-user + + Given a enterprise_customer_key, link user to the appropriate enterprise. + + If the key is not found, returns 404 + If the key is not valid, returns 422 + If we create an `EnterpriseCustomerUser` returns 201 + If an `EnterpriseCustomerUser` if found returns 200 + """ + enterprise_customer_key = get_object_or_404( + models.EnterpriseCustomerInviteKey, + uuid=pk + ) + + if not enterprise_customer_key.is_valid: + return Response( + {"detail": "Enterprise customer invite key is not valid"}, + status=status.HTTP_422_UNPROCESSABLE_ENTITY, + ) + + enterprise_customer = enterprise_customer_key.enterprise_customer + + enterprise_user, created = models.EnterpriseCustomerUser.all_objects.get_or_create( + user_id=request.user.id, + enterprise_customer=enterprise_customer, + ) + + response_body = { + "enterprise_customer_slug": enterprise_customer.slug, + "enterprise_customer_uuid": enterprise_customer.uuid, + } + headers = self.get_success_headers(response_body) + + track_enterprise_user_linked( + request.user.id, + pk, + enterprise_customer.uuid, + created, + ) + + if created: + enterprise_user.invite_key = enterprise_customer_key + enterprise_user.save() + return Response(response_body, status=HTTP_201_CREATED, headers=headers) + + elif not enterprise_user.active or not enterprise_user.linked: + try: + models.EnterpriseCustomerUser.all_objects.link_user( + enterprise_customer, + request.user.email + ) + except LinkUserToEnterpriseError: + return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY) + + enterprise_user.refresh_from_db() + enterprise_user.invite_key = enterprise_customer_key + enterprise_user.save() + + return Response(response_body, status=HTTP_200_OK, headers=headers) diff --git a/enterprise/api/v1/views/enterprise_customer_reporting.py b/enterprise/api/v1/views/enterprise_customer_reporting.py new file mode 100644 index 0000000000..c35783cce8 --- /dev/null +++ b/enterprise/api/v1/views/enterprise_customer_reporting.py @@ -0,0 +1,148 @@ +""" +Views for the Enterprise Customer Reporting API. +""" + +from edx_rbac.decorators import permission_required +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from rest_framework import permissions, status +from rest_framework.authentication import SessionAuthentication +from rest_framework.response import Response +from rest_framework.status import HTTP_200_OK, HTTP_404_NOT_FOUND +from rest_framework.views import APIView + +from enterprise import models +from enterprise.api.utils import get_ent_cust_from_report_config_uuid, get_enterprise_customer_from_user_id +from enterprise.api.v1 import serializers +from enterprise.api.v1.views.base_views import EnterpriseReadWriteModelViewSet +from enterprise.utils import get_enterprise_customer + + +class EnterpriseCustomerReportingConfigurationViewSet(EnterpriseReadWriteModelViewSet): + """ + API views for the ``enterprise-customer-reporting`` API endpoint. + """ + + queryset = models.EnterpriseCustomerReportingConfiguration.objects.all() + serializer_class = serializers.EnterpriseCustomerReportingConfigurationSerializer + lookup_field = 'uuid' + permission_classes = [permissions.IsAuthenticated] + + USER_ID_FILTER = 'enterprise_customer__enterprise_customer_users__user_id' + FIELDS = ( + 'enterprise_customer', + ) + filterset_fields = FIELDS + ordering_fields = FIELDS + + @permission_required( + 'enterprise.can_manage_reporting_config', + fn=lambda request, *args, **kwargs: get_ent_cust_from_report_config_uuid(kwargs['uuid'])) + def retrieve(self, request, *args, **kwargs): + return super().retrieve(request, *args, **kwargs) + + @permission_required( + 'enterprise.can_manage_reporting_config', + fn=lambda request, *args, **kwargs: get_enterprise_customer_from_user_id(request.user.id)) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + + @permission_required( + 'enterprise.can_manage_reporting_config', + fn=lambda request, *args, **kwargs: request.data.get('enterprise_customer_id')) + def create(self, request, *args, **kwargs): + config_data = request.data.copy() + serializer = self.get_serializer(data=config_data) + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @permission_required( + 'enterprise.can_manage_reporting_config', + fn=lambda request, *args, **kwargs: get_ent_cust_from_report_config_uuid(kwargs['uuid'])) + def update(self, request, *args, **kwargs): + return super().update(request, *args, **kwargs) + + @permission_required( + 'enterprise.can_manage_reporting_config', + fn=lambda request, *args, **kwargs: get_ent_cust_from_report_config_uuid(kwargs['uuid'])) + def partial_update(self, request, *args, **kwargs): + return super().partial_update(request, *args, **kwargs) + + @permission_required( + 'enterprise.can_manage_reporting_config', + fn=lambda request, *args, **kwargs: get_ent_cust_from_report_config_uuid(kwargs['uuid'])) + def destroy(self, request, *args, **kwargs): + return super().destroy(request, *args, **kwargs) + + +class EnterpriseCustomerReportTypesView(APIView): + """ + API for getting the report types associated with an enterprise customer + """ + authentication_classes = [JwtAuthentication, SessionAuthentication] + permission_classes = [permissions.IsAuthenticated] + http_method_names = ['get'] + + @staticmethod + def _get_data_types_with_recent_progress_type(data_types): + """ + Get the data types with only the most recent 'progress' type version + + Arguments: + data_types (list): List of data type tuples. + + Returns: + (list): List of data type tuples with only the most recent 'progress' type. + e.g. [ ... ('progress', 'progress_v3')] + """ + progress_data_types = [data_type for data_type in data_types if data_type[1].startswith('progress')] + progress_data_types.sort(key=lambda data_type: data_type[1]) + data_types_for_frontend = [data_type for data_type in data_types if not data_type[1].startswith('progress')] + data_types_for_frontend.append((progress_data_types[-1][1], 'progress')) + return data_types_for_frontend + + @staticmethod + def _get_data_types_for_non_pearson_customers(data_types): + """ + Get the data types for non-pearson customers + + Arguments: + data_types (list): List of data type tuples. + + Returns: + (list): List of data type tuples without the Pearson specific types. + """ + reduced_data_types = [] + for data_type in data_types: + if data_type[1] not in models.EnterpriseCustomerReportingConfiguration.MANUAL_REPORTS: + reduced_data_types.append(data_type) + return reduced_data_types + + @permission_required( + 'enterprise.can_access_admin_dashboard', + fn=lambda request, enterprise_uuid: enterprise_uuid + ) + def get(self, request, enterprise_uuid): + """ + Get the dropdown choices for EnterpriseCustomerReportingConfiguration + """ + enterprise_customer = get_enterprise_customer(enterprise_uuid) + if not enterprise_customer: + return Response({'detail': 'Could not find the enterprise customer.'}, status=HTTP_404_NOT_FOUND) + + meta = models.EnterpriseCustomerReportingConfiguration._meta + choices = {} + for field in meta.get_fields(): + if hasattr(field, 'choices') and field.choices: + choices[field.name] = field.choices + # filter out deprecated 'progress' type report versions + data_types_for_frontend = self._get_data_types_with_recent_progress_type(list(choices.get('data_type', []))) + # remove Pearson only reports + choices['data_type'] = ( + self._get_data_types_for_non_pearson_customers(data_types_for_frontend) + if 'pearson' not in enterprise_customer.slug + else data_types_for_frontend + ) + + return Response(data=choices, status=HTTP_200_OK) diff --git a/enterprise/api/v1/views/enterprise_customer_user.py b/enterprise/api/v1/views/enterprise_customer_user.py new file mode 100644 index 0000000000..0873df114c --- /dev/null +++ b/enterprise/api/v1/views/enterprise_customer_user.py @@ -0,0 +1,34 @@ +""" +Views for the ``enterprise-customer-user`` API endpoint. +""" +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import filters + +from enterprise import models +from enterprise.api.filters import EnterpriseCustomerUserFilterBackend +from enterprise.api.v1 import serializers +from enterprise.api.v1.views.base_views import EnterpriseReadWriteModelViewSet + + +class EnterpriseCustomerUserViewSet(EnterpriseReadWriteModelViewSet): + """ + API views for the ``enterprise-learner`` API endpoint. + """ + + queryset = models.EnterpriseCustomerUser.objects.all() + filter_backends = (filters.OrderingFilter, DjangoFilterBackend, EnterpriseCustomerUserFilterBackend) + + FIELDS = ( + 'enterprise_customer', 'user_id', 'active', + ) + filterset_fields = FIELDS + ordering_fields = FIELDS + + def get_serializer_class(self): + """ + Use a flat serializer for any requests that aren't read-only. + """ + if self.request.method in ('GET',): + return serializers.EnterpriseCustomerUserReadOnlySerializer + + return serializers.EnterpriseCustomerUserWriteSerializer diff --git a/enterprise/api/v1/views/enterprise_subsidy_fulfillment.py b/enterprise/api/v1/views/enterprise_subsidy_fulfillment.py new file mode 100644 index 0000000000..c8e94dc4f2 --- /dev/null +++ b/enterprise/api/v1/views/enterprise_subsidy_fulfillment.py @@ -0,0 +1,621 @@ +""" +Views for the Enterprise Subsidy Fulfillment API. +""" + +from edx_rbac.decorators import permission_required +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.exceptions import ValidationError +from rest_framework.response import Response +from rest_framework.status import HTTP_200_OK, HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND, HTTP_500_INTERNAL_SERVER_ERROR + +from django.db.models import Q +from django.shortcuts import get_object_or_404 +from django.utils.dateparse import parse_datetime +from django.utils.translation import gettext as _ + +from enterprise import models +from enterprise.api.utils import get_enterprise_customer_from_user_id +from enterprise.api.v1 import serializers +from enterprise.api.v1.views.base_views import EnterpriseWrapperApiViewSet +from enterprise.logging import getEnterpriseLogger +from enterprise.utils import NotConnectedToOpenEdX, get_request_value +from enterprise_learner_portal.utils import CourseRunProgressStatuses, get_course_run_status + +try: + from common.djangoapps.course_modes.models import CourseMode + from common.djangoapps.student.models import CourseEnrollment + from lms.djangoapps.certificates.api import get_certificate_for_user + from openedx.core.djangoapps.content.course_overviews.api import get_course_overviews + from openedx.core.djangoapps.enrollments import api as enrollment_api +except ImportError: + get_course_overviews = None + get_certificate_for_user = None + CourseEnrollment = None + CourseMode = None + enrollment_api = None + +LOGGER = getEnterpriseLogger(__name__) + + +class EnrollmentModificationException(Exception): + """ + An exception that represents an error when modifying the state + of an enrollment via the EnrollmentApiClient. + """ + + +class EnterpriseSubsidyFulfillmentViewSet(EnterpriseWrapperApiViewSet): + """ + General API views for subsidized enterprise course enrollments. + + Supported operations: + * Fetch a subsidy fulfillment record by uuid. + /enterprise/api/v1/subsidy-fulfillment/{fulfillment_source_uuid}/ + * Cancel a subsidy fulfillment enrollment record by uuid. + /enterprise/api/v1/subsidy-fulfillment/{fulfillment_source_uuid}/cancel-enrollment/ + * Fetch all unenrolled subsidy fulfillment records. + /enterprise/api/v1/operator/subsidy-fulfillment/unenrolled/ + + Cancel and fetch endpoints require a fulfillment source uuid query parameter. Fetching unenrollments supports + an optional ``unenrolled_after`` query parameter to filter the returned queryset down to only enterprise + enrollments unenrolled after the supplied datetime. + + Arguments (Fetch & Cancel): + fulfillment_source_uuid (str): The uuid of the subsidy fulfillment record. + + Arguments (Unenrolled): + unenrolled_after (str): A datetime string. Only return enrollments unenrolled after this time. + + Returns (Fetch): + (Response): JSON response containing the subsidy fulfillment record. + + Returns (Unenrolled): + (Response): JSON list response containing the unenrolled subsidy fulfillment records. + + .. code-block:: + + api_response = [ + { + enterprise_course_enrollment: { + enterprise_customer_user: , + course_id: , + unenrolled: + created: + } + license_uuid/transaction_id: , + uuid: , + }, + ] + + Raises + (Http404): If the subsidy fulfillment record does not exist or if subsidy fulfillment exists under a separate + enterprise. + (Http403): If the requesting user does not have the appropriate permissions. + (EnrollmentModificationException): If something goes wrong while updating the platform CourseEnrollment object. + """ + + def get_subsidy_fulfillment_queryset(self): + """ + Return the queryset for this view. Queries across subsidy types until it finds a match for the provided uuid. + Returns a 404 if no subsidy fulfillment record is found. + """ + enterprise_customer_uuid = get_enterprise_customer_from_user_id(self.request.user.id) + fulfillment_source_uuid = self.kwargs.get('fulfillment_source_uuid') + + # Get learner credit enrollments under the supplied fulfillment source uuid. + learner_credit_enrollments = models.LearnerCreditEnterpriseCourseEnrollment.objects.filter( + uuid=fulfillment_source_uuid + ) + + # Filters to match fulfillment enrollments' and entitlements' enterprise customer uuid to the requesting + # user's enterprise customer uuid. + subsidy_fulfillment_filter = Q( + enterprise_course_enrollment__enterprise_customer_user__enterprise_customer__uuid=enterprise_customer_uuid + ) + subsidy_fulfillment_filter |= Q( + enterprise_course_entitlement__enterprise_customer_user__enterprise_customer__uuid=enterprise_customer_uuid + ) + + # If the requester isn't staff, apply the filters + if not self.request.user.is_staff: + learner_credit_enrollments = learner_credit_enrollments.filter(subsidy_fulfillment_filter) + + # Return if we get any hits + if learner_credit_enrollments: + return learner_credit_enrollments + + # Get licensed enrollments under the supplied fulfillment source uuid and repeat the same process. + licensed_enrollments = models.LicensedEnterpriseCourseEnrollment.objects.filter( + uuid=fulfillment_source_uuid + ) + if not self.request.user.is_staff: + licensed_enrollments = licensed_enrollments.filter(subsidy_fulfillment_filter) + + if licensed_enrollments: + return licensed_enrollments + raise ValidationError('No enrollment found for the given fulfillment source uuid.', code=HTTP_404_NOT_FOUND) + + def get_subsidy_fulfillment_serializer_class(self): + """ + Fetch the correct serializer class based on the subsidy type. + """ + fulfillment_source_uuid = self.kwargs.get('fulfillment_source_uuid') + + learner_credit_enrollments = models.LearnerCreditEnterpriseCourseEnrollment.objects.filter( + uuid=fulfillment_source_uuid + ) + if len(learner_credit_enrollments): + return serializers.LearnerCreditEnterpriseCourseEnrollmentReadOnlySerializer + licensed_enrollments = models.LicensedEnterpriseCourseEnrollment.objects.filter( + uuid=fulfillment_source_uuid + ) + if len(licensed_enrollments): + return serializers.LicensedEnterpriseCourseEnrollmentReadOnlySerializer + + raise ValidationError('No enrollment found for the given fulfillment source uuid.', code=HTTP_404_NOT_FOUND) + + def get_unenrolled_fulfillment_queryset(self): + """ + Return the queryset for unenrolled subsidy fulfillment records. Applies a modified timestamp filter to fetch + records modified after if provided from query params. + """ + # Adding licensed enrollment support for future implementations + if self.request.query_params.get('retrieve_licensed_enrollments'): + enrollment_table = models.LicensedEnterpriseCourseEnrollment + else: + enrollment_table = models.LearnerCreditEnterpriseCourseEnrollment + + # Apply a modified filter if one is provided via query params + if self.request.query_params.get('unenrolled_after'): + unenrolled_queryset = enrollment_table.objects.filter( + enterprise_course_enrollment__unenrolled_at__gte=self.request.query_params.get('unenrolled_after') + ) + return unenrolled_queryset + + unenrolled_queryset = enrollment_table.objects.filter( + enterprise_course_enrollment__unenrolled_at__isnull=False, + ) + + return unenrolled_queryset + + def get_unenrolled_fulfillment_serializer_class(self): + """ + Fetch the correct recently unenrolled serializer class based on provided querysets. + """ + if self.request.query_params.get('retrieve_licensed_enrollments'): + return serializers.LicensedEnterpriseCourseEnrollmentReadOnlySerializer + else: + return serializers.LearnerCreditEnterpriseCourseEnrollmentReadOnlySerializer + + @permission_required( + 'enterprise.can_manage_enterprise_fulfillments', + fn=lambda request: get_enterprise_customer_from_user_id(request.user.id) + ) + def unenrolled(self, request, *args, **kwargs): + """ + List all unenrolled subsidy fulfillments. + /enterprise/api/v1/operator/enterprise-subsidy-fulfillment/unenrolled/ + + Args: + modified (str): A datetime string. Only return enrollments modified after this time. + retrieve_licensed_enrollments (bool): If true, return data related to licensed enrollments instead of + learner credit + """ + queryset = self.get_unenrolled_fulfillment_queryset() + serializer_class = self.get_unenrolled_fulfillment_serializer_class() + serializer = serializer_class(queryset, many=True) + return Response(serializer.data) + + @permission_required( + 'enterprise.can_access_admin_dashboard', + fn=lambda request, fulfillment_source_uuid: get_enterprise_customer_from_user_id(request.user.id) + ) + def retrieve(self, request, fulfillment_source_uuid, *args, **kwargs): + """ + Retrieve a single subsidized enrollment. + /enterprise/api/v1/subsidy-fulfillment/{fulfillment_source_uuid}/ + """ + try: + queryset = self.get_subsidy_fulfillment_queryset() + fulfillment = get_object_or_404(queryset, uuid=fulfillment_source_uuid) + serializer_class = self.get_subsidy_fulfillment_serializer_class() + serialized_object = serializer_class(fulfillment) + except ValidationError as exc: + return Response( + status=HTTP_404_NOT_FOUND, + data={'detail': exc.detail} + ) + return Response(serialized_object.data) + + @action(methods=['post'], detail=True) + @permission_required( + 'enterprise.can_enroll_learners', + fn=lambda request, fulfillment_source_uuid: get_enterprise_customer_from_user_id(request.user.id) + ) + def cancel_enrollment(self, request, fulfillment_source_uuid): + """ + Cancel a single subsidized enrollment. Assumes fulfillment source has a valid enterprise enrollment. + /enterprise/api/v1/subsidy-fulfillment/{fulfillment_source_uuid}/cancel-enrollment/ + """ + try: + subsidy_fulfillment = get_object_or_404( + self.get_subsidy_fulfillment_queryset(), uuid=fulfillment_source_uuid + ) + if subsidy_fulfillment.is_revoked: + return Response( + status=HTTP_400_BAD_REQUEST, + data={'detail': 'Enrollment is already canceled.'} + ) + except ValidationError as exc: + return Response( + status=HTTP_404_NOT_FOUND, + data={'detail': exc.detail} + ) + + try: + username = subsidy_fulfillment.enterprise_course_enrollment.enterprise_customer_user.username + enrollment_api.update_enrollment( + username, + subsidy_fulfillment.enterprise_course_enrollment.course_id, + is_active=False, + ) + subsidy_fulfillment.revoke() + except Exception as exc: # pylint: disable=broad-except + msg = ( + f'Subsidized enrollment terminations error: unable to unenroll User {username} ' + f'from Course {subsidy_fulfillment.enterprise_course_enrollment.course_id} because: {str(exc)}' + ) + LOGGER.error(msg) + return Response(msg, status=HTTP_500_INTERNAL_SERVER_ERROR) + return Response(status=HTTP_200_OK) + + +class LicensedEnterpriseCourseEnrollmentViewSet(EnterpriseWrapperApiViewSet): + """ + API views for the ``licensed-enterprise-course-enrollment`` API endpoint. + """ + + queryset = models.LicensedEnterpriseCourseEnrollment.objects.all() + serializer_class = serializers.LicensedEnterpriseCourseEnrollmentReadOnlySerializer + REQ_EXP_LICENSE_UUIDS_PARAM = 'expired_license_uuids' + OPT_IGNORE_ENROLLMENTS_MODIFIED_AFTER_PARAM = 'ignore_enrollments_modified_after' + + class EnrollmentTerminationStatus: + """ + Defines statuses related to enrollment states during the course unenrollment process. + """ + COURSE_COMPLETED = 'course already completed' + MOVED_TO_AUDIT = 'moved to audit' + UNENROLLED = 'unenrolled' + UNENROLL_FAILED = 'unenroll_user_from_course returned false.' + + @staticmethod + def _validate_license_revoke_data(request_data): + """ + Ensures the request data contains the necessary information. + + Arguments: + request_data (dict): A dictionary of data passed to the request + """ + user_id = request_data.get('user_id') + enterprise_id = request_data.get('enterprise_id') + + if not user_id or not enterprise_id: + msg = 'user_id and enterprise_id must be provided.' + return Response(msg, status=status.HTTP_400_BAD_REQUEST) + + return None + + @staticmethod + def _has_user_completed_course_run(enterprise_enrollment, course_overview): + """ + Returns True if the user who is enrolled in the given course has already + completed this course, false otherwise. The course may be "completed" + if the user earned a certificate, or if the course run has ended. + + Args: + enterprise_enrollment (EnterpriseCourseEnrollment): The enrollment object for which we check + if the associated user has completed the given course. + course_overview (CourseOverview): The course overview of which we are checking completion. We need this + to check certificate status. It's a model defined in edx-platform. + """ + certificate_info = get_certificate_for_user( + enterprise_enrollment.enterprise_customer_user.username, + course_overview.get('id'), + ) or {} + course_run_status = get_course_run_status( + course_overview, + certificate_info, + enterprise_enrollment, + ) + + return course_run_status == CourseRunProgressStatuses.COMPLETED + + def _enrollments_by_course_for_licensed_user(self, enterprise_customer_user): + """ + Helper method to return a dictionary mapping course ids to EnterpriseCourseEnrollments + for each licensed enrollment associated with the given enterprise user. + + Args: + enterprise_customer_user (EnterpriseCustomerUser): The user for which we are fetching enrollments. + """ + licensed_enrollments = models.LicensedEnterpriseCourseEnrollment.enrollments_for_user( + enterprise_customer_user + ) + return { + enrollment.enterprise_course_enrollment.course_id: enrollment.enterprise_course_enrollment + for enrollment in licensed_enrollments + } + + def _terminate_enrollment(self, enterprise_enrollment, course_overview): + """ + Helper method that switches the given enrollment to audit track, or, if + no audit track exists for the given course, deletes the enrollment. + Will do nothing if the user has already "completed" the course run. + + Args: + enterprise_enrollment (EnterpriseCourseEnrollment): The enterprise enrollment which we attempt to revoke. + course_overview (CourseOverview): The course overview object associated with the enrollment. Used + to check for course completion. + """ + course_run_id = course_overview.get('id') + enterprise_customer_user = enterprise_enrollment.enterprise_customer_user + audit_mode = CourseMode.AUDIT + enterprise_id = enterprise_customer_user.enterprise_customer.uuid + + log_message_kwargs = { + 'user': enterprise_customer_user.username, + 'enterprise': enterprise_id, + 'course_id': course_run_id, + 'mode': audit_mode, + } + + if self._has_user_completed_course_run(enterprise_enrollment, course_overview): + LOGGER.info( + 'enrollment termination: not updating enrollment in {course_id} for User {user} ' + 'in Enterprise {enterprise}, course is already complete.'.format(**log_message_kwargs) + ) + return self.EnrollmentTerminationStatus.COURSE_COMPLETED + + if CourseMode.mode_for_course(course_run_id, audit_mode): + try: + enrollment_api.update_enrollment( + username=enterprise_customer_user.username, + course_id=course_run_id, + mode=audit_mode, + ) + LOGGER.info( + 'Enrollment termination: updated LMS enrollment for User {user} and Enterprise {enterprise} ' + 'in Course {course_id} to Course Mode {mode}.'.format(**log_message_kwargs) + ) + return self.EnrollmentTerminationStatus.MOVED_TO_AUDIT + except Exception as exc: + msg = ( + 'Enrollment termination: unable to update LMS enrollment for User {user} and ' + 'Enterprise {enterprise} in Course {course_id} to Course Mode {mode} because: {reason}'.format( + reason=str(exc), + **log_message_kwargs + ) + ) + LOGGER.error('{msg}: {exc}'.format(msg=msg, exc=exc)) + raise EnrollmentModificationException(msg) from exc + else: + try: + enrollment_api.update_enrollment( + username=enterprise_customer_user.username, + course_id=course_run_id, + is_active=False + ) + LOGGER.info( + 'Enrollment termination: successfully unenrolled User {user}, in Enterprise {enterprise} ' + 'from Course {course_id} that contains no audit mode.'.format(**log_message_kwargs) + ) + return self.EnrollmentTerminationStatus.UNENROLLED + except Exception as exc: + msg = ( + 'Enrollment termination: unable to unenroll User {user} in Enterprise {enterprise} ' + 'from Course {course_id} because: {reason}'.format( + reason=str(exc), + **log_message_kwargs + ) + ) + LOGGER.error('{msg}: {exc}'.format(msg=msg, exc=exc)) + raise EnrollmentModificationException(msg) from exc + + def _course_enrollment_modified_at_by_user_and_course_id(self, licensed_enrollments): + """ + Returns a dict containing the last time a course enrollment was modified. + The keys are in the form of f'{user_id}{course_id}'. + """ + enterprise_course_enrollments = [ + licensed_enrollment.enterprise_course_enrollment for licensed_enrollment in licensed_enrollments + ] + user_ids = [str(ece.enterprise_customer_user.user_id) for ece in enterprise_course_enrollments] + course_ids = [str(ece.course_id) for ece in enterprise_course_enrollments] + course_enrollment_histories = CourseEnrollment.history.filter( + user_id__in=user_ids, + course_id__in=course_ids + ).order_by('-history_date') + + result = {} + + for history in course_enrollment_histories: + user_id = history.user_id + course_id = str(history.course_id) + key = f'{user_id}{course_id}' + if key not in result: + result[key] = history.history_date + + return result + + @action(methods=['post'], detail=False) + @permission_required('enterprise.can_access_admin_dashboard', fn=lambda request: request.data.get('enterprise_id')) + def license_revoke(self, request, *args, **kwargs): + """ + Changes the mode for a user's licensed enterprise course enrollments to the "audit" course mode, + or unenroll the user if no audit mode exists for a given course. + + Will return a response with status 200 if no errors were encountered while modifying the course enrollment, + or a 422 if any errors were encountered. The content of the response is of the form:: + + { + 'course-v1:puppies': {'success': true, 'message': 'unenrolled'}, + 'course-v1:birds': {'success': true, 'message': 'moved to audit'}, + 'course-v1:kittens': {'success': true, 'message': 'course already completed'}, + 'course-v1:snakes': {'success': false, 'message': 'unenroll_user_from_course returned false'}, + 'course-v1:lizards': {'success': false, 'message': 'Some other exception'}, + } + + The first four messages are the values of constants that a client may expect to receive and parse accordingly. + """ + dependencies = [ + CourseMode, get_certificate_for_user, get_course_overviews, enrollment_api + ] + if not all(dependencies): + raise NotConnectedToOpenEdX( + _('To use this endpoint, this package must be ' + 'installed in an Open edX environment.') + ) + + request_data = request.data.copy() + invalid_response = self._validate_license_revoke_data(request_data) + if invalid_response: + return invalid_response + + user_id = request_data.get('user_id') + enterprise_id = request_data.get('enterprise_id') + + enterprise_customer_user = get_object_or_404( + models.EnterpriseCustomerUser, + user_id=user_id, + enterprise_customer=enterprise_id, + ) + enrollments_by_course_id = self._enrollments_by_course_for_licensed_user(enterprise_customer_user) + + revocation_results = {} + any_failures = False + for course_overview in get_course_overviews(list(enrollments_by_course_id.keys())): + course_id = str(course_overview.get('id')) + enterprise_enrollment = enrollments_by_course_id.get(course_id) + try: + revocation_status = self._terminate_enrollment(enterprise_enrollment, course_overview) + revocation_results[course_id] = {'success': True, 'message': revocation_status} + if revocation_status != self.EnrollmentTerminationStatus.COURSE_COMPLETED: + enterprise_enrollment.license.revoke() + except EnrollmentModificationException as exc: + revocation_results[course_id] = {'success': False, 'message': str(exc)} + any_failures = True + + status_code = status.HTTP_200_OK if not any_failures else status.HTTP_422_UNPROCESSABLE_ENTITY + return Response(revocation_results, status=status_code) + + @action(methods=['post'], detail=False) + @permission_required('enterprise.can_enroll_learners') + def bulk_licensed_enrollments_expiration(self, request): + """ + Changes the mode for licensed enterprise course enrollments to the "audit" course mode, + or unenroll the user if no audit mode exists for each expired license uuid + + Args: + expired_license_uuids: The expired license uuids. + ignore_enrollments_modified_after: All course enrollments modified past this given date will be ignored, + i.e. the enterprise subscription plan expiration date. + """ + + dependencies = [ + CourseEnrollment, CourseMode, get_certificate_for_user, get_course_overviews, enrollment_api + ] + if not all(dependencies): + raise NotConnectedToOpenEdX( + _('To use this endpoint, this package must be ' + 'installed in an Open edX environment.') + ) + + expired_license_uuids = get_request_value(request, self.REQ_EXP_LICENSE_UUIDS_PARAM, '') + ignore_enrollments_modified_after = get_request_value( + request, + self.OPT_IGNORE_ENROLLMENTS_MODIFIED_AFTER_PARAM, + None + ) + + if not expired_license_uuids: + return Response( + 'Parameter {} must be provided'.format(self.REQ_EXP_LICENSE_UUIDS_PARAM), + status=status.HTTP_400_BAD_REQUEST + ) + + if ignore_enrollments_modified_after: + ignore_enrollments_modified_after = parse_datetime(ignore_enrollments_modified_after) + if not ignore_enrollments_modified_after: + return Response( + 'Parameter {} is malformed, please provide a date in ISO-8601 format'.format( + self.OPT_IGNORE_ENROLLMENTS_MODIFIED_AFTER_PARAM + ), + status=status.HTTP_400_BAD_REQUEST + ) + + licensed_enrollments = models.LicensedEnterpriseCourseEnrollment.objects.filter( + license_uuid__in=expired_license_uuids + ).select_related('enterprise_course_enrollment') + + course_overviews = get_course_overviews( + list(licensed_enrollments.values_list('enterprise_course_enrollment__course_id', flat=True)) + ) + indexed_overviews = {overview.get('id'): overview for overview in course_overviews} + + course_enrollment_modified_at_by_user_and_course_id = \ + self._course_enrollment_modified_at_by_user_and_course_id( + licensed_enrollments + ) if ignore_enrollments_modified_after else {} + + any_failures = False + + for licensed_enrollment in licensed_enrollments: + enterprise_course_enrollment = licensed_enrollment.enterprise_course_enrollment + user_id = enterprise_course_enrollment.enterprise_customer_user.user_id + course_id = enterprise_course_enrollment.course_id + course_overview = indexed_overviews.get(course_id) + + if licensed_enrollment.is_revoked: + LOGGER.info( + 'Enrollment termination: not updating enrollment in {} for User {} ' + 'licensed enterprise enrollment has already been revoked in the past.'.format( + course_id, + user_id + ) + ) + continue + + if ignore_enrollments_modified_after: + key = f'{user_id}{course_id}' + course_enrollment_modified_at = course_enrollment_modified_at_by_user_and_course_id[key] + if course_enrollment_modified_at >= ignore_enrollments_modified_after: + LOGGER.info( + 'Enrollment termination: not updating enrollment in {} for User {} ' + 'course enrollment has been modified past {}.'.format( + course_id, + user_id, + ignore_enrollments_modified_after + ) + ) + continue + + try: + termination_status = self._terminate_enrollment(enterprise_course_enrollment, course_overview) + license_uuid = enterprise_course_enrollment.license.license_uuid + LOGGER.info( + f"EnterpriseCourseEnrollment record with enterprise license {license_uuid} " + f"unenrolled to status {termination_status}." + ) + if termination_status != self.EnrollmentTerminationStatus.COURSE_COMPLETED: + enterprise_course_enrollment.license.revoke() + except EnrollmentModificationException as exc: + LOGGER.error( + f"Failed to unenroll EnterpriseCourseEnrollment record for enterprise license " + f"{enterprise_course_enrollment.license.license_uuid}. error message {str(exc)}." + ) + any_failures = True + + status_code = status.HTTP_200_OK if not any_failures else status.HTTP_422_UNPROCESSABLE_ENTITY + return Response(status=status_code) diff --git a/enterprise/api/v1/views/notifications.py b/enterprise/api/v1/views/notifications.py new file mode 100644 index 0000000000..7cacbfb0d8 --- /dev/null +++ b/enterprise/api/v1/views/notifications.py @@ -0,0 +1,109 @@ +""" +Views for the Admin Notification API. +""" + + +from edx_rbac.decorators import permission_required +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from rest_framework import permissions +from rest_framework.authentication import SessionAuthentication +from rest_framework.response import Response +from rest_framework.status import HTTP_200_OK, HTTP_400_BAD_REQUEST, HTTP_500_INTERNAL_SERVER_ERROR +from rest_framework.views import APIView + +from enterprise import models +from enterprise.api.throttles import ServiceUserThrottle +from enterprise.errors import AdminNotificationAPIRequestError +from enterprise.logging import getEnterpriseLogger +from enterprise.utils import get_request_value + +LOGGER = getEnterpriseLogger(__name__) + + +class NotificationReadView(APIView): + """ + API to mark notifications as read. + """ + permission_classes = (permissions.IsAuthenticated,) + authentication_classes = (JwtAuthentication, SessionAuthentication,) + throttle_classes = (ServiceUserThrottle,) + + REQUIRED_PARAM_NOTIFICATION_ID = 'notification_id' + REQUIRED_PARAM_ENTERPRISE_SLUG = 'enterprise_slug' + + MISSING_REQUIRED_PARAMS_MSG = 'Some required parameter(s) missing: {}' + + def get_required_query_params(self, request): + """ + Gets ``notification_id`` and ``enterprise_slug``. + which are the relevant parameters for this API endpoint. + + :param request: The request to this endpoint. + :return: The ``notification_id`` and ``enterprise_slug`` from the request. + """ + enterprise_slug = get_request_value(request, self.REQUIRED_PARAM_ENTERPRISE_SLUG, '') + notification_id = get_request_value(request, self.REQUIRED_PARAM_NOTIFICATION_ID, '') + if not (notification_id and enterprise_slug): + raise AdminNotificationAPIRequestError( + self.get_missing_params_message([ + (self.REQUIRED_PARAM_NOTIFICATION_ID, bool(notification_id)), + (self.REQUIRED_PARAM_ENTERPRISE_SLUG, bool(enterprise_slug)), + ]) + ) + return notification_id, enterprise_slug + + def get_missing_params_message(self, parameter_state): + """ + Get a user-friendly message indicating a missing parameter for the API endpoint. + """ + params = ', '.join(name for name, present in parameter_state if not present) + return self.MISSING_REQUIRED_PARAMS_MSG.format(params) + + @permission_required('enterprise.can_access_admin_dashboard') + def post(self, request): + """ + POST /enterprise/api/v1/read_notification + + Requires a JSON object of the following format:: + + { + 'notification_id': 1, + 'enterprise_slug': 'enterprise_slug', + } + + Keys: + notification_id: Notification ID which is read by Current User. + enterprise_slug: The slug of the enterprise. + """ + try: + notification_id, enterprise_slug = self.get_required_query_params(request) + except AdminNotificationAPIRequestError as invalid_request: + return Response({'error': str(invalid_request)}, status=HTTP_400_BAD_REQUEST) + + try: + data = { + self.REQUIRED_PARAM_NOTIFICATION_ID: notification_id, + self.REQUIRED_PARAM_ENTERPRISE_SLUG: enterprise_slug, + } + enterprise_customer_user = models.EnterpriseCustomerUser.objects.get( + enterprise_customer__slug=enterprise_slug, user_id=request.user.id + ) + notification_read, _ = models.AdminNotificationRead.objects.get_or_create( + enterprise_customer_user=enterprise_customer_user, + admin_notification_id=notification_id, + is_read=True + ) + LOGGER.info( + '[Admin Notification API] Notification read request successful. AdminNotificationRead ID' + ' {}.'.format(notification_read.id) + ) + return Response(data, status=HTTP_200_OK) + except Exception as exc: # pylint: disable=broad-except + LOGGER.error( + '[Admin Notification API] Notification read request failed, AdminNotification ID:{},Enterprise Slug:{}' + ' User ID:{}, Exception:{}.'.format(notification_id, enterprise_slug, request.user.id, exc) + ) + return Response( + {'error': 'Notification read request failed'}, + status=HTTP_500_INTERNAL_SERVER_ERROR + ) diff --git a/enterprise/api/v1/views/pending_enterprise_customer_user.py b/enterprise/api/v1/views/pending_enterprise_customer_user.py new file mode 100644 index 0000000000..7b56ca9acb --- /dev/null +++ b/enterprise/api/v1/views/pending_enterprise_customer_user.py @@ -0,0 +1,103 @@ +""" +Views for the ``pending-enterprise-customer-user`` API endpoint. +""" + +from django_filters.rest_framework import DjangoFilterBackend +from edx_rbac.decorators import permission_required +from rest_framework import filters, permissions, status +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.status import HTTP_400_BAD_REQUEST + +from enterprise import models +from enterprise.api.v1 import serializers +from enterprise.api.v1.views.base_views import EnterpriseReadWriteModelViewSet +from enterprise.logging import getEnterpriseLogger + +LOGGER = getEnterpriseLogger(__name__) + + +class PendingEnterpriseCustomerUserViewSet(EnterpriseReadWriteModelViewSet): + """ + API views for the ``pending-enterprise-learner`` API endpoint. + Requires staff permissions + """ + queryset = models.PendingEnterpriseCustomerUser.objects.all() + filter_backends = (filters.OrderingFilter, DjangoFilterBackend) + serializer_class = serializers.PendingEnterpriseCustomerUserSerializer + permission_classes = (permissions.IsAuthenticated, permissions.IsAdminUser) + + FIELDS = ( + 'enterprise_customer', 'user_email', + ) + filterset_fields = FIELDS + ordering_fields = FIELDS + + UNIQUE = 'unique' + USER_EXISTS_ERROR = 'EnterpriseCustomerUser record already exists' + + def _get_return_status(self, serializer, many): + """ + Run serializer validation and get return status + """ + return_status = None + serializer.is_valid(raise_exception=True) + if not many: + _, created = serializer.save() + return_status = status.HTTP_201_CREATED if created else status.HTTP_204_NO_CONTENT + return return_status + + data_list = serializer.save() + for _, created in data_list: + if created: + return status.HTTP_201_CREATED + return status.HTTP_204_NO_CONTENT + + def create(self, request, *args, **kwargs): + """ + Creates a PendingEnterpriseCustomerUser if no EnterpriseCustomerUser for the given (customer, email) + combination(s) exists. + Can accept one user or a list of users. + + Returns 201 if any users were created, 204 if no users were created. + """ + serializer = self.get_serializer(data=request.data, many=isinstance(request.data, list)) + return_status = self._get_return_status(serializer, many=isinstance(request.data, list)) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=return_status, headers=headers) + + +class PendingEnterpriseCustomerUserEnterpriseAdminViewSet(PendingEnterpriseCustomerUserViewSet): + """ + Viewset for allowing enterprise admins to create linked learners + Endpoint url: link_pending_enterprise_users/(?P[A-Za-z0-9-]+)/?$ + Admin must be an administrator for the enterprise in question + """ + permission_classes = (permissions.IsAuthenticated,) + serializer_class = serializers.LinkLearnersSerializer + + @action(methods=['post'], detail=False) + @permission_required('enterprise.can_access_admin_dashboard', fn=lambda request, enterprise_uuid: enterprise_uuid) + def link_learners(self, request, enterprise_uuid): + """ + Creates a PendingEnterpriseCustomerUser if no EnterpriseCustomerUser for the given (customer, email) + combination(s) exists. + Can accept one user or a list of users. + + Returns 201 if any users were created, 204 if no users were created. + """ + if not request.data: + LOGGER.error('Empty user email payload in link_learners for enterprise: %s', enterprise_uuid) + return Response( + 'At least one user email is required.', + status=HTTP_400_BAD_REQUEST, + ) + context = {'enterprise_customer__uuid': enterprise_uuid} + serializer = self.get_serializer( + data=request.data, + many=isinstance(request.data, list), + context=context, + ) + return_status = self._get_return_status(serializer, many=isinstance(request.data, list)) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=return_status, headers=headers) diff --git a/enterprise/api/v1/views/plotly_auth.py b/enterprise/api/v1/views/plotly_auth.py new file mode 100644 index 0000000000..123f223fa6 --- /dev/null +++ b/enterprise/api/v1/views/plotly_auth.py @@ -0,0 +1,48 @@ +""" +Views for Plotly auth. +""" + +from time import time + +import jwt +from edx_rbac.decorators import permission_required +from rest_framework import generics +from rest_framework.permissions import IsAuthenticated + +from django.conf import settings +from django.http import JsonResponse + + +class PlotlyAuthView(generics.GenericAPIView): + """ + API to generate a signed token for an enterprise admin to use Plotly analytics. + """ + permission_classes = (IsAuthenticated,) + + @permission_required( + 'enterprise.can_access_admin_dashboard', + fn=lambda request, enterprise_uuid: enterprise_uuid + ) + def get(self, request, enterprise_uuid): + """ + Generate auth token for plotly. + """ + # This is a new secret key and will be only shared between LMS and our Plotly server. + secret_key = settings.ENTERPRISE_PLOTLY_SECRET + + now = int(time()) + expires_in = 3600 # time in seconds after which token will be expired + exp = now + expires_in + + CLAIMS = { + "exp": exp, + "iat": now + } + + jwt_payload = dict({ + 'enterprise_uuid': enterprise_uuid, + }, **CLAIMS) + + token = jwt.encode(jwt_payload, secret_key, algorithm='HS512') + json_payload = {'token': token} + return JsonResponse(json_payload) diff --git a/enterprise/management/commands/update_config_last_errored_at.py b/enterprise/management/commands/update_config_last_errored_at.py new file mode 100644 index 0000000000..898377beb5 --- /dev/null +++ b/enterprise/management/commands/update_config_last_errored_at.py @@ -0,0 +1,125 @@ +""" +Backfill missing audit record foreign keys. +""" +import logging +from datetime import datetime, timedelta + +from django.core.management.base import BaseCommand +from django.utils.translation import gettext as _ + +from integrated_channels.blackboard.models import ( + BlackboardEnterpriseCustomerConfiguration, + BlackboardLearnerAssessmentDataTransmissionAudit, + BlackboardLearnerDataTransmissionAudit, +) +from integrated_channels.canvas.models import ( + CanvasEnterpriseCustomerConfiguration, + CanvasLearnerAssessmentDataTransmissionAudit, + CanvasLearnerDataTransmissionAudit, +) +from integrated_channels.cornerstone.models import ( + CornerstoneEnterpriseCustomerConfiguration, + CornerstoneLearnerDataTransmissionAudit, +) +from integrated_channels.degreed2.models import ( + Degreed2EnterpriseCustomerConfiguration, + Degreed2LearnerDataTransmissionAudit, +) +from integrated_channels.degreed.models import ( + DegreedEnterpriseCustomerConfiguration, + DegreedLearnerDataTransmissionAudit, +) +from integrated_channels.integrated_channel.management.commands import IntegratedChannelCommandMixin +from integrated_channels.integrated_channel.models import ( + ContentMetadataItemTransmission, + GenericEnterpriseCustomerPluginConfiguration, + GenericLearnerDataTransmissionAudit, +) +from integrated_channels.moodle.models import MoodleEnterpriseCustomerConfiguration, MoodleLearnerDataTransmissionAudit +from integrated_channels.sap_success_factors.models import ( + SAPSuccessFactorsEnterpriseCustomerConfiguration, + SapSuccessFactorsLearnerDataTransmissionAudit, +) + +MODELS = { + 'MOODLE': [MoodleEnterpriseCustomerConfiguration, MoodleLearnerDataTransmissionAudit], + 'CSOD': [CornerstoneEnterpriseCustomerConfiguration, CornerstoneLearnerDataTransmissionAudit], + 'BLACKBOARD': [BlackboardEnterpriseCustomerConfiguration, BlackboardLearnerDataTransmissionAudit], + 'BLACKBOARD_ASMT': [BlackboardEnterpriseCustomerConfiguration, BlackboardLearnerAssessmentDataTransmissionAudit], + 'CANVAS': [CanvasEnterpriseCustomerConfiguration, CanvasLearnerDataTransmissionAudit], + 'CANVAS_ASMT': [CanvasEnterpriseCustomerConfiguration, CanvasLearnerAssessmentDataTransmissionAudit], + 'DEGREED': [DegreedEnterpriseCustomerConfiguration, DegreedLearnerDataTransmissionAudit], + 'DEGREED2': [Degreed2EnterpriseCustomerConfiguration, Degreed2LearnerDataTransmissionAudit], + 'GENERIC': [GenericEnterpriseCustomerPluginConfiguration, GenericLearnerDataTransmissionAudit], + 'SAP': [SAPSuccessFactorsEnterpriseCustomerConfiguration, SapSuccessFactorsLearnerDataTransmissionAudit], +} + +LOGGER = logging.getLogger(__name__) + + +class Command(IntegratedChannelCommandMixin, BaseCommand): + """ + Management command which backfills missing audit record foreign keys. + """ + help = _(''' + Set error state for configurations. + ''') + + def update_config_last_errored_at(self): + """ + For each audit record kind (learner and content), find all the records in batch, and lookup + if they've had recent sync errors in the last day. If not, clear out the last_content_sync_errored_at + value associated with the configuration. + """ + try: + has_learner_errors, has_content_errors = True, True + yesterday = datetime.utcnow() - timedelta(days=1) + for channel_code, (ConfigModel, LearnerAuditModel) in MODELS.items(): + configs = ConfigModel.objects.all() + for config in configs: + if config.last_sync_errored_at is None: + continue + customer = config.enterprise_customer + plugin_id = config.id + # learner audits + errored_learner_audits = LearnerAuditModel.objects.filter( + created__date__gt=yesterday, + status__gt=299, + enterprise_customer_uuid=customer.uuid, + plugin_configuration_id=plugin_id, + ) + if not errored_learner_audits: + config.last_learner_sync_errored_at = None + has_learner_errors = False + # content metadata audits + errored_content_audits = ContentMetadataItemTransmission.objects.filter( + remote_created_at__gt=yesterday, + enterprise_customer=customer, + integrated_channel_code=channel_code, + plugin_configuration_id=plugin_id, + api_response_status_code__gt=299 + ) + if not errored_content_audits: + config.last_content_sync_errored_at = None + has_content_errors = False + if not has_learner_errors and not has_content_errors: + config.last_sync_errored_at = None + if not has_learner_errors or not has_content_errors: + LOGGER.info( + 'Config with id {}, channel code {}, enterprise customer {}' + ' error information has been updated'.format( + config.id, channel_code, config.enterprise_customer.uuid + ) + ) + config.save() + except Exception as exc: + LOGGER.exception('update_config_last_errored_at', exc_info=exc) + raise exc + + def handle(self, *args, **options): + """ + Set error state for configurations. + """ + LOGGER.info('Begin nulling out outdated last_sync_errored_at in configs') + self.update_config_last_errored_at() + LOGGER.info('Finished nulling out outdated last_sync_errored_at in configs') diff --git a/enterprise/signals.py b/enterprise/signals.py index dd3c8016d2..f3e80ca8c7 100644 --- a/enterprise/signals.py +++ b/enterprise/signals.py @@ -19,6 +19,14 @@ unset_enterprise_learner_language, unset_language_of_all_enterprise_learners, ) +from integrated_channels.blackboard.models import BlackboardEnterpriseCustomerConfiguration +from integrated_channels.canvas.models import CanvasEnterpriseCustomerConfiguration +from integrated_channels.cornerstone.models import CornerstoneEnterpriseCustomerConfiguration +from integrated_channels.degreed2.models import Degreed2EnterpriseCustomerConfiguration +from integrated_channels.degreed.models import DegreedEnterpriseCustomerConfiguration +from integrated_channels.integrated_channel.tasks import mark_orphaned_content_metadata_audit +from integrated_channels.moodle.models import MoodleEnterpriseCustomerConfiguration +from integrated_channels.sap_success_factors.models import SAPSuccessFactorsEnterpriseCustomerConfiguration try: from common.djangoapps.student.models import CourseEnrollment @@ -30,6 +38,15 @@ logger = getLogger(__name__) _UNSAVED_FILEFIELD = 'unsaved_filefield' +INTEGRATED_CHANNELS = [ + BlackboardEnterpriseCustomerConfiguration, + CanvasEnterpriseCustomerConfiguration, + CornerstoneEnterpriseCustomerConfiguration, + DegreedEnterpriseCustomerConfiguration, + Degreed2EnterpriseCustomerConfiguration, + MoodleEnterpriseCustomerConfiguration, + SAPSuccessFactorsEnterpriseCustomerConfiguration, +] @disable_for_loaddata @@ -315,6 +332,16 @@ def delete_enterprise_catalog_data(sender, instance, **kwargs): # pylint: di exc_info=exc ) + customer = instance.enterprise_customer + for channel in INTEGRATED_CHANNELS: + if channel.objects.filter(enterprise_customer=customer, active=True).exists(): + logger.info( + f"Catalog {catalog_uuid} deletion is linked to an active integrated channels config, running the mark" + f"orphan content audits task" + ) + mark_orphaned_content_metadata_audit.delay() + break + def enterprise_unenrollment_receiver(sender, **kwargs): # pylint: disable=unused-argument """ diff --git a/enterprise_learner_portal/api/v1/serializers.py b/enterprise_learner_portal/api/v1/serializers.py index affe668139..ac4f18a57e 100644 --- a/enterprise_learner_portal/api/v1/serializers.py +++ b/enterprise_learner_portal/api/v1/serializers.py @@ -21,6 +21,11 @@ get_course_run_url = None get_emails_enabled = None +try: + from federated_content_connector.models import CourseDetails +except ImportError: + CourseDetails = None + class EnterpriseCourseEnrollmentSerializer(serializers.Serializer): # pylint: disable=abstract-method """ @@ -73,6 +78,15 @@ def to_representation(self, instance): representation['is_enrollment_active'] = instance.is_active representation['mode'] = instance.mode + if CourseDetails: + course_details = CourseDetails.objects.filter(id=course_run_id).first() + if course_details: + representation['course_type'] = course_details.course_type + representation['product_source'] = course_details.product_source + representation['start_date'] = course_details.start_date or representation['start_date'] + representation['end_date'] = course_details.end_date or representation['end_date'] + representation['enroll_by'] = course_details.enroll_by + return representation def _get_course_overview(self, course_run_id): diff --git a/integrated_channels/blackboard/admin/__init__.py b/integrated_channels/blackboard/admin/__init__.py index e781cb7a22..d95502a346 100644 --- a/integrated_channels/blackboard/admin/__init__.py +++ b/integrated_channels/blackboard/admin/__init__.py @@ -2,8 +2,11 @@ Admin integration for configuring Blackboard app to communicate with Blackboard systems. """ from config_models.admin import ConfigurationModelAdmin +from django_object_actions import DjangoObjectActions -from django.contrib import admin +from django.contrib import admin, messages +from django.core.exceptions import ValidationError +from django.http import HttpResponseRedirect from django.utils.html import format_html from integrated_channels.blackboard.models import ( @@ -29,7 +32,7 @@ class Meta: @admin.register(BlackboardEnterpriseCustomerConfiguration) -class BlackboardEnterpriseCustomerConfigurationAdmin(admin.ModelAdmin): +class BlackboardEnterpriseCustomerConfigurationAdmin(DjangoObjectActions, admin.ModelAdmin): """ Django admin model for BlackEnterpriseCustomerConfiguration. """ @@ -51,6 +54,7 @@ class BlackboardEnterpriseCustomerConfigurationAdmin(admin.ModelAdmin): ) search_fields = ("enterprise_customer_name",) + change_actions = ("force_content_metadata_transmission",) class Meta: model = BlackboardEnterpriseCustomerConfiguration @@ -78,6 +82,34 @@ def customer_oauth_authorization_url(self, obj): else: return None + def force_content_metadata_transmission(self, request, obj): + """ + Updates the modified time of the customer record to retransmit courses metadata + and redirects to configuration view with success or error message. + """ + try: + obj.enterprise_customer.save() + messages.success( + request, + f'''The blackboard enterprise customer content metadata + “” was updated successfully.''', + ) + except ValidationError: + messages.error( + request, + f'''The blackboard enterprise customer content metadata + “” was not updated successfully.''', + ) + return HttpResponseRedirect( + "/admin/blackboard/blackboardenterprisecustomerconfiguration" + ) + force_content_metadata_transmission.label = "Force content metadata transmission" + force_content_metadata_transmission.short_description = ( + "Force content metadata transmission for this Enterprise Customer" + ) + @admin.register(BlackboardLearnerDataTransmissionAudit) class BlackboardLearnerDataTransmissionAuditAdmin(BaseLearnerDataTransmissionAuditAdmin): diff --git a/integrated_channels/canvas/admin/__init__.py b/integrated_channels/canvas/admin/__init__.py index 8d70397da9..5952bef492 100644 --- a/integrated_channels/canvas/admin/__init__.py +++ b/integrated_channels/canvas/admin/__init__.py @@ -1,8 +1,11 @@ """ Admin integration for configuring Canvas app to communicate with Canvas systems. """ +from django_object_actions import DjangoObjectActions -from django.contrib import admin +from django.contrib import admin, messages +from django.core.exceptions import ValidationError +from django.http import HttpResponseRedirect from django.utils.html import format_html from integrated_channels.canvas.models import CanvasEnterpriseCustomerConfiguration, CanvasLearnerDataTransmissionAudit @@ -10,7 +13,7 @@ @admin.register(CanvasEnterpriseCustomerConfiguration) -class CanvasEnterpriseCustomerConfigurationAdmin(admin.ModelAdmin): +class CanvasEnterpriseCustomerConfigurationAdmin(DjangoObjectActions, admin.ModelAdmin): """ Django admin model for CanvasEnterpriseCustomerConfiguration. """ @@ -35,6 +38,7 @@ class CanvasEnterpriseCustomerConfigurationAdmin(admin.ModelAdmin): ) search_fields = ("enterprise_customer_name",) + change_actions = ("force_content_metadata_transmission",) class Meta: model = CanvasEnterpriseCustomerConfiguration @@ -62,6 +66,34 @@ def customer_oauth_authorization_url(self, obj): else: return None + def force_content_metadata_transmission(self, request, obj): + """ + Updates the modified time of the customer record to retransmit courses metadata + and redirects to configuration view with success or error message. + """ + try: + obj.enterprise_customer.save() + messages.success( + request, + f'''The canvas enterprise customer content metadata + “” was updated successfully.''', + ) + except ValidationError: + messages.error( + request, + f'''The canvas enterprise customer content metadata + “” was not updated successfully.''', + ) + return HttpResponseRedirect( + "/admin/canvas/canvasenterprisecustomerconfiguration" + ) + force_content_metadata_transmission.label = "Force content metadata transmission" + force_content_metadata_transmission.short_description = ( + "Force content metadata transmission for this Enterprise Customer" + ) + @admin.register(CanvasLearnerDataTransmissionAudit) class CanvasLearnerDataTransmissionAuditAdmin(BaseLearnerDataTransmissionAuditAdmin): diff --git a/integrated_channels/cornerstone/admin/__init__.py b/integrated_channels/cornerstone/admin/__init__.py index 36971f183b..33a7e11729 100644 --- a/integrated_channels/cornerstone/admin/__init__.py +++ b/integrated_channels/cornerstone/admin/__init__.py @@ -3,8 +3,11 @@ """ from config_models.admin import ConfigurationModelAdmin +from django_object_actions import DjangoObjectActions -from django.contrib import admin +from django.contrib import admin, messages +from django.core.exceptions import ValidationError +from django.http import HttpResponseRedirect from integrated_channels.cornerstone.models import ( CornerstoneEnterpriseCustomerConfiguration, @@ -31,7 +34,7 @@ class Meta: @admin.register(CornerstoneEnterpriseCustomerConfiguration) -class CornerstoneEnterpriseCustomerConfigurationAdmin(admin.ModelAdmin): +class CornerstoneEnterpriseCustomerConfigurationAdmin(DjangoObjectActions, admin.ModelAdmin): """ Django admin model for CornerstoneEnterpriseCustomerConfiguration. """ @@ -53,8 +56,8 @@ class CornerstoneEnterpriseCustomerConfigurationAdmin(admin.ModelAdmin): ) list_filter = ("active",) - search_fields = ("enterprise_customer_name",) + change_actions = ("force_content_metadata_transmission",) class Meta: model = CornerstoneEnterpriseCustomerConfiguration @@ -69,6 +72,34 @@ def enterprise_customer_name(self, obj): """ return obj.enterprise_customer.name + def force_content_metadata_transmission(self, request, obj): + """ + Updates the modified time of the customer record to retransmit courses metadata + and redirects to configuration view with success or error message. + """ + try: + obj.enterprise_customer.save() + messages.success( + request, + f'''The cornerstone enterprise customer content metadata + “” was updated successfully.''', + ) + except ValidationError: + messages.error( + request, + f'''The cornerstone enterprise customer content metadata + “” was not updated successfully.''', + ) + return HttpResponseRedirect( + "/admin/cornerstone/cornerstoneenterprisecustomerconfiguration" + ) + force_content_metadata_transmission.label = "Force content metadata transmission" + force_content_metadata_transmission.short_description = ( + "Force content metadata transmission for this Enterprise Customer" + ) + @admin.register(CornerstoneLearnerDataTransmissionAudit) class CornerstoneLearnerDataTransmissionAuditAdmin(BaseLearnerDataTransmissionAuditAdmin): diff --git a/integrated_channels/degreed/admin/__init__.py b/integrated_channels/degreed/admin/__init__.py index ad93c864ab..305f2b9781 100644 --- a/integrated_channels/degreed/admin/__init__.py +++ b/integrated_channels/degreed/admin/__init__.py @@ -3,8 +3,11 @@ """ from config_models.admin import ConfigurationModelAdmin +from django_object_actions import DjangoObjectActions -from django.contrib import admin +from django.contrib import admin, messages +from django.core.exceptions import ValidationError +from django.http import HttpResponseRedirect from integrated_channels.degreed.models import ( DegreedEnterpriseCustomerConfiguration, @@ -31,7 +34,7 @@ class Meta: @admin.register(DegreedEnterpriseCustomerConfiguration) -class DegreedEnterpriseCustomerConfigurationAdmin(admin.ModelAdmin): +class DegreedEnterpriseCustomerConfigurationAdmin(DjangoObjectActions, admin.ModelAdmin): """ Django admin model for DegreedEnterpriseCustomerConfiguration. """ @@ -58,6 +61,7 @@ class DegreedEnterpriseCustomerConfigurationAdmin(admin.ModelAdmin): list_filter = ("active",) search_fields = ("enterprise_customer_name",) + change_actions = ("force_content_metadata_transmission",) class Meta: model = DegreedEnterpriseCustomerConfiguration @@ -72,6 +76,35 @@ def enterprise_customer_name(self, obj): """ return obj.enterprise_customer.name + def force_content_metadata_transmission(self, request, obj): + """ + Updates the modified time of the customer record to retransmit courses metadata + and redirects to configuration view with success or error message. + """ + try: + obj.enterprise_customer.save() + messages.success( + request, + f'''The degreed enterprise customer content metadata + “” was updated successfully.''', + ) + except ValidationError: + messages.error( + request, + f'''The degreed enterprise customer content metadata + “” was not updated successfully.''', + ) + return HttpResponseRedirect( + "/admin/degreed/degreedenterprisecustomerconfiguration" + ) + + force_content_metadata_transmission.label = "Force content metadata transmission" + force_content_metadata_transmission.short_description = ( + "Force content metadata transmission for this Enterprise Customer" + ) + @admin.register(DegreedLearnerDataTransmissionAudit) class DegreedLearnerDataTransmissionAuditAdmin(BaseLearnerDataTransmissionAuditAdmin): diff --git a/integrated_channels/degreed2/admin/__init__.py b/integrated_channels/degreed2/admin/__init__.py index 94ea128f46..54c4d13d5d 100644 --- a/integrated_channels/degreed2/admin/__init__.py +++ b/integrated_channels/degreed2/admin/__init__.py @@ -3,7 +3,11 @@ Django admin integration for configuring degreed app to communicate with Degreed systems. """ -from django.contrib import admin +from django_object_actions import DjangoObjectActions + +from django.contrib import admin, messages +from django.core.exceptions import ValidationError +from django.http import HttpResponseRedirect from integrated_channels.degreed2.models import ( Degreed2EnterpriseCustomerConfiguration, @@ -13,7 +17,7 @@ @admin.register(Degreed2EnterpriseCustomerConfiguration) -class Degreed2EnterpriseCustomerConfigurationAdmin(admin.ModelAdmin): +class Degreed2EnterpriseCustomerConfigurationAdmin(DjangoObjectActions, admin.ModelAdmin): """ Django admin model for Degreed2EnterpriseCustomerConfiguration. """ @@ -37,6 +41,7 @@ class Degreed2EnterpriseCustomerConfigurationAdmin(admin.ModelAdmin): list_filter = ("active",) search_fields = ("enterprise_customer_name",) + change_actions = ("force_content_metadata_transmission",) class Meta: model = Degreed2EnterpriseCustomerConfiguration @@ -51,6 +56,32 @@ def enterprise_customer_name(self, obj): """ return obj.enterprise_customer.name + def force_content_metadata_transmission(self, request, obj): + """ + Updates the modified time of the customer record to retransmit courses metadata + and redirects to configuration view with success or error message. + """ + try: + obj.enterprise_customer.save() + messages.success( + request, + f'''The degreed2 enterprise customer content metadata + “” was updated successfully.''', + ) + except ValidationError: + messages.error( + request, + f'''The degreed2 enterprise customer content metadata + “” was not updated successfully.''', + ) + return HttpResponseRedirect('/admin/degreed2/degreed2enterprisecustomerconfiguration') + force_content_metadata_transmission.label = "Force content metadata transmission" + force_content_metadata_transmission.short_description = ( + "Force content metadata transmission for this Enterprise Customer" + ) + @admin.register(Degreed2LearnerDataTransmissionAudit) class Degreed2LearnerDataTransmissionAuditAdmin(BaseLearnerDataTransmissionAuditAdmin): diff --git a/integrated_channels/integrated_channel/exporters/content_metadata.py b/integrated_channels/integrated_channel/exporters/content_metadata.py index 89a953b58c..d156c74caa 100644 --- a/integrated_channels/integrated_channel/exporters/content_metadata.py +++ b/integrated_channels/integrated_channel/exporters/content_metadata.py @@ -318,11 +318,11 @@ def _get_catalog_diff( # 2) swap the catalog uuid of the transmission audit associated with the orphaned record, and 3) mark the # orphaned record resolved if orphaned_content: - ContentMetadataTransmissionAudit = apps.get_model( + ContentMetadataItemTransmission = apps.get_model( 'integrated_channel', - 'ContentMetadataTransmissionAudit' + 'ContentMetadataItemTransmission', ) - ContentMetadataTransmissionAudit.objects.filter( + ContentMetadataItemTransmission.objects.filter( integrated_channel_code=self.enterprise_configuration.channel_code(), plugin_configuration_id=self.enterprise_configuration.id, content_id=content_key @@ -436,8 +436,16 @@ def _get_customer_config_orphaned_content(self, max_set_count, content_key=None) ) & content_query # Grab orphaned content metadata items for the customer, ordered by oldest to newest - return OrphanedContentTransmissions.objects.filter(base_query).order_by('created')[:max_set_count] + orphaned_content = OrphanedContentTransmissions.objects.filter(base_query) + num_records = len(orphaned_content) + self._log_info( + f'Found {num_records} orphaned content records for customer: ' + f'{self.enterprise_customer.uuid}. Returning {min(max_set_count, num_records)} records.' + ) + ordered_and_chunked_orphaned_content = orphaned_content.order_by('created')[:max_set_count] + return ordered_and_chunked_orphaned_content + # pylint: disable=too-many-statements def export(self, **kwargs): """ Export transformed content metadata if there has been an update to the consumer's catalogs @@ -529,8 +537,20 @@ def export(self, **kwargs): delete_payload[key] = item # If we're not at the max payload count, we can check for orphaned content and shove it in the delete payload - current_payload_count = len(items_to_create) + len(items_to_update) + len(items_to_delete) + current_payload_count = len(create_payload) + len(update_payload) + len(delete_payload) + + self._log_info( + f'Exporter finished iterating over catalogs for customer: {self.enterprise_customer.uuid},' + f'with a current payload count of: {current_payload_count}. Is there room for orphaned content' + f'in the exporter payload?: {current_payload_count < max_payload_count}' + ) + if current_payload_count < max_payload_count: + self._log_info( + f'Exporter has {max_payload_count - current_payload_count} slots left in the payload for customer: ' + f'{self.enterprise_customer.uuid}, searching for orphaned content to append' + ) + space_left_in_payload = max_payload_count - current_payload_count orphaned_content_to_delete = self._get_customer_config_orphaned_content( max_set_count=space_left_in_payload, diff --git a/integrated_channels/moodle/admin/__init__.py b/integrated_channels/moodle/admin/__init__.py index 837da2923c..d10ecbc1ed 100644 --- a/integrated_channels/moodle/admin/__init__.py +++ b/integrated_channels/moodle/admin/__init__.py @@ -2,9 +2,12 @@ Django admin integration for configuring moodle app to communicate with Moodle systems. """ +from django_object_actions import DjangoObjectActions + from django import forms -from django.contrib import admin +from django.contrib import admin, messages from django.core.exceptions import ValidationError +from django.http import HttpResponseRedirect from django.utils.translation import gettext_lazy as _ from integrated_channels.integrated_channel.admin import BaseLearnerDataTransmissionAuditAdmin @@ -31,7 +34,7 @@ def clean(self): @admin.register(MoodleEnterpriseCustomerConfiguration) -class MoodleEnterpriseCustomerConfigurationAdmin(admin.ModelAdmin): +class MoodleEnterpriseCustomerConfigurationAdmin(DjangoObjectActions, admin.ModelAdmin): """ Django admin model for MoodleEnterpriseCustomerConfiguration. """ @@ -41,6 +44,35 @@ class MoodleEnterpriseCustomerConfigurationAdmin(admin.ModelAdmin): ) form = MoodleEnterpriseCustomerConfigurationForm + change_actions = ('force_content_metadata_transmission',) + + def force_content_metadata_transmission(self, request, obj): + """ + Updates the modified time of the customer record to retransmit courses metadata + and redirects to configuration view with success or error message. + """ + try: + obj.enterprise_customer.save() + messages.success( + request, + f'''The moodle enterprise customer content metadata + “” was updated successfully.''', + ) + except ValidationError: + messages.error( + request, + f'''The moodle enterprise customer content metadata + “” was not updated successfully.''', + ) + return HttpResponseRedirect( + "/admin/moodle/moodleenterprisecustomerconfiguration" + ) + force_content_metadata_transmission.label = "Force content metadata transmission" + force_content_metadata_transmission.short_description = ( + "Force content metadata transmission for this Enterprise Customer" + ) @admin.register(MoodleLearnerDataTransmissionAudit) diff --git a/integrated_channels/sap_success_factors/admin/__init__.py b/integrated_channels/sap_success_factors/admin/__init__.py index abfb6fb45c..c4d80ecb23 100644 --- a/integrated_channels/sap_success_factors/admin/__init__.py +++ b/integrated_channels/sap_success_factors/admin/__init__.py @@ -3,9 +3,12 @@ """ from config_models.admin import ConfigurationModelAdmin +from django_object_actions import DjangoObjectActions from requests import RequestException -from django.contrib import admin +from django.contrib import admin, messages +from django.core.exceptions import ValidationError +from django.http import HttpResponseRedirect from integrated_channels.exceptions import ClientError from integrated_channels.integrated_channel.admin import BaseLearnerDataTransmissionAuditAdmin @@ -35,49 +38,46 @@ class Meta: @admin.register(SAPSuccessFactorsEnterpriseCustomerConfiguration) -class SAPSuccessFactorsEnterpriseCustomerConfigurationAdmin(admin.ModelAdmin): +class SAPSuccessFactorsEnterpriseCustomerConfigurationAdmin(DjangoObjectActions, admin.ModelAdmin): """ Django admin model for SAPSuccessFactorsEnterpriseCustomerConfiguration. """ fields = ( - 'enterprise_customer', - 'idp_id', - 'active', - 'sapsf_base_url', - 'sapsf_company_id', - 'key', - 'secret', - 'sapsf_user_id', - 'user_type', - 'has_access_token', - 'prevent_self_submit_grades', - 'show_course_price', - 'disable_learner_data_transmissions', - 'transmit_total_hours', - 'transmission_chunk_size', - 'additional_locales', - 'catalogs_to_transmit', - 'display_name', + "enterprise_customer", + "idp_id", + "active", + "sapsf_base_url", + "sapsf_company_id", + "key", + "secret", + "sapsf_user_id", + "user_type", + "has_access_token", + "prevent_self_submit_grades", + "show_course_price", + "disable_learner_data_transmissions", + "transmit_total_hours", + "transmission_chunk_size", + "additional_locales", + "catalogs_to_transmit", + "display_name", ) list_display = ( - 'enterprise_customer_name', - 'active', - 'sapsf_base_url', - 'modified', + "enterprise_customer_name", + "active", + "sapsf_base_url", + "modified", ) - ordering = ('enterprise_customer__name',) + ordering = ("enterprise_customer__name",) - readonly_fields = ( - 'has_access_token', - ) + readonly_fields = ("has_access_token",) - raw_id_fields = ( - 'enterprise_customer', - ) + raw_id_fields = ("enterprise_customer",) - list_filter = ('active',) - search_fields = ('enterprise_customer__name',) + list_filter = ("active",) + search_fields = ("enterprise_customer__name",) + change_actions = ("force_content_metadata_transmission",) class Meta: model = SAPSuccessFactorsEnterpriseCustomerConfiguration @@ -117,14 +117,45 @@ def has_access_token(self, obj): return bool(access_token and expires_at) has_access_token.boolean = True - has_access_token.short_description = 'Has Access Token?' + has_access_token.short_description = "Has Access Token?" + + def force_content_metadata_transmission(self, request, obj): + """ + Updates the modified time of the customer record to retransmit courses metadata + and redirects to configuration view with success or error message. + """ + try: + obj.enterprise_customer.save() + messages.success( + request, + f'''The sap success factors enterprise customer content metadata + “” was updated successfully.''', + ) + except ValidationError: + messages.error( + request, + f'''The sap success factors enterprise customer content metadata + “” was not updated successfully.''', + ) + return HttpResponseRedirect( + "/admin/sap_success_factors/sapsuccessfactorsenterprisecustomerconfiguration" + ) + force_content_metadata_transmission.label = "Force content metadata transmission" + force_content_metadata_transmission.short_description = ( + "Force content metadata transmission for this Enterprise Customer" + ) @admin.register(SapSuccessFactorsLearnerDataTransmissionAudit) -class SapSuccessFactorsLearnerDataTransmissionAuditAdmin(BaseLearnerDataTransmissionAuditAdmin): +class SapSuccessFactorsLearnerDataTransmissionAuditAdmin( + BaseLearnerDataTransmissionAuditAdmin +): """ Django admin model for SapSuccessFactorsLearnerDataTransmissionAudit. """ + list_display = ( "enterprise_course_enrollment_id", "course_id", diff --git a/integrated_channels/xapi/admin/__init__.py b/integrated_channels/xapi/admin/__init__.py index f75e0e6902..23914537ca 100644 --- a/integrated_channels/xapi/admin/__init__.py +++ b/integrated_channels/xapi/admin/__init__.py @@ -2,39 +2,42 @@ Django admin integration for xAPI. """ -from django.contrib import admin +from django_object_actions import DjangoObjectActions + +from django.contrib import admin, messages +from django.core.exceptions import ValidationError +from django.http import HttpResponseRedirect from integrated_channels.xapi.models import XAPILRSConfiguration @admin.register(XAPILRSConfiguration) -class XAPILRSConfigurationAdmin(admin.ModelAdmin): +class XAPILRSConfigurationAdmin(DjangoObjectActions, admin.ModelAdmin): """ Django admin model for XAPILRSConfiguration. """ fields = ( - 'enterprise_customer', - 'active', - 'endpoint', - 'version', - 'key', - 'secret', + "enterprise_customer", + "active", + "endpoint", + "version", + "key", + "secret", ) list_display = ( - 'enterprise_customer_name', - 'active', - 'endpoint', - 'modified', + "enterprise_customer_name", + "active", + "endpoint", + "modified", ) - raw_id_fields = ( - 'enterprise_customer', - ) + raw_id_fields = ("enterprise_customer",) - ordering = ('enterprise_customer__name', ) - list_filter = ('active', ) - search_fields = ('enterprise_customer__name',) + ordering = ("enterprise_customer__name",) + list_filter = ("active",) + search_fields = ("enterprise_customer__name",) + change_actions = ("force_content_metadata_transmission",) class Meta: model = XAPILRSConfiguration @@ -48,3 +51,29 @@ def enterprise_customer_name(self, obj): being rendered with this admin form. """ return obj.enterprise_customer.name + + def force_content_metadata_transmission(self, request, obj): + """ + Updates the modified time of the customer record to retransmit courses metadata + and redirects to configuration view with success or error message. + """ + try: + obj.enterprise_customer.save() + messages.success( + request, + f'''The xapilrs enterprise customer content metadata + “” + was updated successfully.''', + ) + except ValidationError: + messages.error( + request, + f'''The xapilrs enterprise customer content metadata + “” + was not updated successfully.''', + ) + return HttpResponseRedirect("/admin/xapi/xapilrsconfiguration/") + force_content_metadata_transmission.label = "Force content metadata transmission" + force_content_metadata_transmission.short_description = ( + "Force content metadata transmission for this Enterprise Customer" + ) diff --git a/tests/test_enterprise/api/test_views.py b/tests/test_enterprise/api/test_views.py index b9665abdfa..10f287ef01 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -27,7 +27,7 @@ from django.utils import timezone from enterprise.api.v1 import serializers -from enterprise.api.v1.views import LicensedEnterpriseCourseEnrollmentViewSet +from enterprise.api.v1.views.enterprise_subsidy_fulfillment import LicensedEnterpriseCourseEnrollmentViewSet from enterprise.constants import ( ALL_ACCESS_CONTEXT, ENTERPRISE_ADMIN_ROLE, @@ -3581,7 +3581,7 @@ def test_unsupported_methods(self): ) assert update_response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED - @mock.patch("enterprise.api.v1.views.enrollment_api") + @mock.patch("enterprise.api.v1.views.enterprise_subsidy_fulfillment.enrollment_api") def test_successful_cancel_fulfillment(self, mock_enrollment_api): """ Test that we can successfully cancel both licensed and learner credit fulfillments. @@ -3657,7 +3657,7 @@ def test_cancel_fulfillment_belonging_to_different_enterprise(self): ) assert response.status_code == status.HTTP_404_NOT_FOUND - @mock.patch("enterprise.api.v1.views.enrollment_api") + @mock.patch("enterprise.api.v1.views.enterprise_subsidy_fulfillment.enrollment_api") def test_staff_can_cancel_fulfillments_not_belonging_to_them(self, mock_enrollment_api): """ Test that a staff user can cancel a fulfillment belonging to a different enterprise. @@ -3713,8 +3713,8 @@ def test_validate_license_revoke_data_invalid_data(self, request_data): CourseRunProgressStatuses.COMPLETED, CourseRunProgressStatuses.SAVED_FOR_LATER, ) - @mock.patch('enterprise.api.v1.views.get_certificate_for_user') - @mock.patch('enterprise.api.v1.views.get_course_run_status') + @mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.get_certificate_for_user') + @mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.get_course_run_status') def test_revoke_has_user_completed_course_run(self, progress_status, mock_course_run_status, mock_cert_for_user): enrollment = mock.Mock() course_overview = {'id': 'some-course'} @@ -3745,10 +3745,10 @@ def test_post_license_revoke_unplugged(self): ) def test_post_license_revoke_invalid_data(self): - with mock.patch('enterprise.api.v1.views.CourseMode'), \ - mock.patch('enterprise.api.v1.views.get_course_overviews'), \ - mock.patch('enterprise.api.v1.views.get_certificate_for_user'), \ - mock.patch('enterprise.api.v1.views.enrollment_api'): + with mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.CourseMode'), \ + mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.get_course_overviews'), \ + mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.get_certificate_for_user'), \ + mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.enrollment_api'): post_data = { 'user_id': 'bob', @@ -3760,10 +3760,10 @@ def test_post_license_revoke_invalid_data(self): self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) def test_post_license_revoke_403(self): - with mock.patch('enterprise.api.v1.views.CourseMode'), \ - mock.patch('enterprise.api.v1.views.get_certificate_for_user'), \ - mock.patch('enterprise.api.v1.views.get_course_overviews'), \ - mock.patch('enterprise.api.v1.views.enrollment_api'): + with mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.CourseMode'), \ + mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.get_certificate_for_user'), \ + mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.get_course_overviews'), \ + mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.enrollment_api'): enterprise_customer = factories.EnterpriseCustomerFactory() self.set_jwt_cookie(ENTERPRISE_LEARNER_ROLE, str(enterprise_customer.uuid)) @@ -3784,10 +3784,10 @@ def test_post_license_revoke_403(self): {'is_course_completed': True, 'has_audit_mode': False}, ) @ddt.unpack - @mock.patch('enterprise.api.v1.views.CourseMode') - @mock.patch('enterprise.api.v1.views.enrollment_api') - @mock.patch('enterprise.api.v1.views.get_certificate_for_user') - @mock.patch('enterprise.api.v1.views.get_course_overviews') + @mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.CourseMode') + @mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.enrollment_api') + @mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.get_certificate_for_user') + @mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.get_course_overviews') def test_post_license_revoke_all_successes( self, mock_get_overviews, @@ -3875,10 +3875,10 @@ def test_post_license_revoke_all_successes( {'has_audit_mode': False} ) @ddt.unpack - @mock.patch('enterprise.api.v1.views.CourseMode') - @mock.patch('enterprise.api.v1.views.enrollment_api') - @mock.patch('enterprise.api.v1.views.get_certificate_for_user') - @mock.patch('enterprise.api.v1.views.get_course_overviews') + @mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.CourseMode') + @mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.enrollment_api') + @mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.get_certificate_for_user') + @mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.get_course_overviews') def test_post_license_revoke_all_errors( self, mock_get_overviews, @@ -4299,8 +4299,8 @@ def tearDown(self): }, ) @ddt.unpack - @mock.patch('enterprise.api.v1.views.get_best_mode_from_course_key') - @mock.patch('enterprise.api.v1.views.track_enrollment') + @mock.patch('enterprise.api.v1.views.enterprise_customer.get_best_mode_from_course_key') + @mock.patch('enterprise.api.v1.views.enterprise_customer.track_enrollment') @mock.patch("enterprise.models.EnterpriseCustomer.notify_enrolled_learners") @mock.patch("enterprise.models.CourseEnrollmentAllowed") def test_bulk_enrollment_in_bulk_courses_pending_licenses( @@ -4361,8 +4361,8 @@ def test_bulk_enrollment_in_bulk_courses_pending_licenses( # no notifications to be sent unless 'notify' specifically asked for in payload mock_notify_task.assert_not_called() - @mock.patch('enterprise.api.v1.views.get_best_mode_from_course_key') - @mock.patch('enterprise.api.v1.views.track_enrollment') + @mock.patch('enterprise.api.v1.views.enterprise_customer.get_best_mode_from_course_key') + @mock.patch('enterprise.api.v1.views.enterprise_customer.track_enrollment') @mock.patch('enterprise.models.EnterpriseCustomer.notify_enrolled_learners') @mock.patch('enterprise.utils.lms_enroll_user_in_course') def test_bulk_enrollment_in_bulk_courses_existing_users( @@ -4448,8 +4448,8 @@ def test_bulk_enrollment_in_bulk_courses_existing_users( # no notifications to be sent unless 'notify' specifically asked for in payload mock_notify_task.assert_not_called() - @mock.patch('enterprise.api.v1.views.get_best_mode_from_course_key') - @mock.patch('enterprise.api.v1.views.track_enrollment') + @mock.patch('enterprise.api.v1.views.enterprise_customer.get_best_mode_from_course_key') + @mock.patch('enterprise.api.v1.views.enterprise_customer.track_enrollment') @mock.patch('enterprise.models.EnterpriseCustomer.notify_enrolled_learners') def test_bulk_enrollment_in_bulk_courses_nonexisting_user_id( self, @@ -4520,8 +4520,10 @@ def test_bulk_enrollment_in_bulk_courses_nonexisting_user_id( }, ) @ddt.unpack - @mock.patch("enterprise.api.v1.views.enrollment_api") - @mock.patch('enterprise.api.v1.views.get_best_mode_from_course_key') + @mock.patch("enterprise.api.v1.views.enterprise_subsidy_fulfillment.enrollment_api") + @mock.patch( + 'enterprise.api.v1.views.enterprise_customer.get_best_mode_from_course_key' + ) @mock.patch("enterprise.utils.lms_enroll_user_in_course") def test_bulk_enrollment_enroll_after_cancel( self, @@ -4573,7 +4575,7 @@ def test_bulk_enrollment_enroll_after_cancel( }, ] } - with mock.patch('enterprise.api.v1.views.track_enrollment'): + with mock.patch('enterprise.api.v1.views.enterprise_customer.track_enrollment'): with mock.patch("enterprise.models.EnterpriseCustomer.notify_enrolled_learners"): cancel_response = self.client.post(settings.TEST_SERVER + cancel_url) with LogCapture(level=logging.WARNING) as warn_logs: @@ -4639,7 +4641,7 @@ def test_bulk_enrollment_enroll_after_cancel( }, ) @ddt.unpack - @mock.patch('enterprise.api.v1.views.get_best_mode_from_course_key') + @mock.patch('enterprise.api.v1.views.enterprise_customer.get_best_mode_from_course_key') @mock.patch("enterprise.utils.lms_enroll_user_in_course") def test_bulk_enrollment_includes_fulfillment_source_uuid( self, @@ -4666,7 +4668,7 @@ def test_bulk_enrollment_includes_fulfillment_source_uuid( 'enterprise-customer-enroll-learners-in-courses', (str(enterprise_customer.uuid),) ) - with mock.patch('enterprise.api.v1.views.track_enrollment'): + with mock.patch('enterprise.api.v1.views.enterprise_customer.track_enrollment'): with mock.patch("enterprise.models.EnterpriseCustomer.notify_enrolled_learners"): response = self.client.post( settings.TEST_SERVER + enrollment_url, @@ -4749,8 +4751,8 @@ def test_bulk_enrollment_includes_fulfillment_source_uuid( }, ) @ddt.unpack - @mock.patch('enterprise.api.v1.views.get_best_mode_from_course_key') - @mock.patch('enterprise.api.v1.views.track_enrollment') + @mock.patch('enterprise.api.v1.views.enterprise_customer.get_best_mode_from_course_key') + @mock.patch('enterprise.api.v1.views.enterprise_customer.track_enrollment') @mock.patch("enterprise.models.EnterpriseCustomer.notify_enrolled_learners") def test_bulk_enrollment_with_notification( self, @@ -4823,8 +4825,8 @@ def _make_call(course_run, enrolled_learners): mock_notify_task.assert_has_calls(mock_calls, any_order=True) - @mock.patch('enterprise.api.v1.views.enroll_subsidy_users_in_courses') - @mock.patch('enterprise.api.v1.views.get_best_mode_from_course_key') + @mock.patch('enterprise.api.v1.views.enterprise_customer.enroll_subsidy_users_in_courses') + @mock.patch('enterprise.api.v1.views.enterprise_customer.get_best_mode_from_course_key') def test_enroll_learners_in_courses_partial_failure(self, mock_get_course_mode, mock_enroll_user): """ Tests that bulk users bulk enrollment endpoint properly handles partial failures. @@ -4913,11 +4915,11 @@ def test_unenroll_expired_licensed_enrollments_unplugged(self): {'is_course_completed': True, 'has_audit_mode': False}, ) @ddt.unpack - @mock.patch('enterprise.api.v1.views.CourseEnrollment') - @mock.patch('enterprise.api.v1.views.CourseMode') - @mock.patch('enterprise.api.v1.views.get_certificate_for_user') - @mock.patch('enterprise.api.v1.views.enrollment_api') - @mock.patch('enterprise.api.v1.views.get_course_overviews') + @mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.CourseEnrollment') + @mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.CourseMode') + @mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.get_certificate_for_user') + @mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.enrollment_api') + @mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.get_course_overviews') def test_unenroll_expired_licensed_enrollments( self, mock_get_overviews, @@ -4978,11 +4980,11 @@ def test_unenroll_expired_licensed_enrollments( assert not enterprise_course_enrollment.saved_for_later assert not licensed_course_enrollment.is_revoked - @mock.patch('enterprise.api.v1.views.CourseEnrollment') - @mock.patch('enterprise.api.v1.views.CourseMode') - @mock.patch('enterprise.api.v1.views.get_certificate_for_user') - @mock.patch('enterprise.api.v1.views.enrollment_api') - @mock.patch('enterprise.api.v1.views.get_course_overviews') + @mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.CourseEnrollment') + @mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.CourseMode') + @mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.get_certificate_for_user') + @mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.enrollment_api') + @mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.get_course_overviews') def test_unenroll_expired_licensed_enrollments_no_license_ids( self, *_ @@ -4999,11 +5001,11 @@ def test_unenroll_expired_licensed_enrollments_no_license_ids( assert response.status_code == status.HTTP_400_BAD_REQUEST - @mock.patch('enterprise.api.v1.views.CourseEnrollment') - @mock.patch('enterprise.api.v1.views.CourseMode') - @mock.patch('enterprise.api.v1.views.get_certificate_for_user') - @mock.patch('enterprise.api.v1.views.enrollment_api') - @mock.patch('enterprise.api.v1.views.get_course_overviews') + @mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.CourseEnrollment') + @mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.CourseMode') + @mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.get_certificate_for_user') + @mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.enrollment_api') + @mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.get_course_overviews') def test_unenroll_expired_licensed_enrollments_ignore_enrollments_modified_after( self, mock_get_overviews, @@ -5058,11 +5060,11 @@ def test_unenroll_expired_licensed_enrollments_ignore_enrollments_modified_after assert not licensed_course_enrollment.is_revoked assert mock_enrollment_api.update_enrollment.call_count == 0 - @mock.patch('enterprise.api.v1.views.CourseEnrollment') - @mock.patch('enterprise.api.v1.views.CourseMode') - @mock.patch('enterprise.api.v1.views.get_certificate_for_user') - @mock.patch('enterprise.api.v1.views.enrollment_api') - @mock.patch('enterprise.api.v1.views.get_course_overviews') + @mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.CourseEnrollment') + @mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.CourseMode') + @mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.get_certificate_for_user') + @mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.enrollment_api') + @mock.patch('enterprise.api.v1.views.enterprise_subsidy_fulfillment.get_course_overviews') def test_unenroll_expired_licensed_enrollments_bad_ignore_enrollments_modified_after( self, *_ diff --git a/tests/test_enterprise/test_signals.py b/tests/test_enterprise/test_signals.py index da3002368d..665a23a527 100644 --- a/tests/test_enterprise/test_signals.py +++ b/tests/test_enterprise/test_signals.py @@ -4,6 +4,7 @@ import unittest from collections import OrderedDict +from datetime import datetime from unittest import mock import ddt @@ -24,8 +25,10 @@ SystemWideEnterpriseUserRoleAssignment, ) from enterprise.signals import create_enterprise_enrollment_receiver, handle_user_post_save +from integrated_channels.integrated_channel.models import OrphanedContentTransmissions from test_utils import EmptyCacheMixin from test_utils.factories import ( + ContentMetadataItemTransmissionFactory, EnterpriseCatalogQueryFactory, EnterpriseCustomerCatalogFactory, EnterpriseCustomerFactory, @@ -33,6 +36,7 @@ PendingEnrollmentFactory, PendingEnterpriseCustomerAdminUserFactory, PendingEnterpriseCustomerUserFactory, + SAPSuccessFactorsEnterpriseCustomerConfigurationFactory, SystemWideEnterpriseUserRoleAssignmentFactory, UserFactory, ) @@ -877,6 +881,45 @@ def test_delete_catalog(self, api_client_mock): api_client_mock.return_value.delete_enterprise_catalog.assert_called_with(enterprise_catalog_uuid) self.assertFalse(EnterpriseCustomerCatalog.objects.exists()) + @mock.patch('enterprise.signals.EnterpriseCatalogApiClient') + def test_delete_catalog_cleaning_up_orphaned_content_transmission_items(self, api_client_mock): + """ + Tests that when an EnterpriseCustomerCatalog is deleted, any associated + Integrated Channels content metadata records are also marked as orphaned. + """ + api_client_mock.return_value.get_enterprise_catalog.return_value = True + customer = EnterpriseCustomerFactory() + enterprise_catalog_to_be_deleted = EnterpriseCustomerCatalogFactory(enterprise_customer=customer) + enterprise_catalog_to_be_kept = EnterpriseCustomerCatalogFactory(enterprise_customer=customer) + + customer.enterprise_customer_catalogs.set([enterprise_catalog_to_be_deleted, enterprise_catalog_to_be_kept]) + customer.save() + integrated_channels_config = SAPSuccessFactorsEnterpriseCustomerConfigurationFactory( + enterprise_customer=customer, + ) + # A record that would be orphaned if the test catalog is deleted + orphaned_content_transmission_item = ContentMetadataItemTransmissionFactory( + integrated_channel_code=integrated_channels_config.channel_code(), + enterprise_customer=customer, + plugin_configuration_id=integrated_channels_config.id, + enterprise_customer_catalog_uuid=enterprise_catalog_to_be_deleted.uuid, + remote_deleted_at=None, + remote_created_at=datetime.now(), + ) + # A normal record that is unaffected by the test catalog deletion + ContentMetadataItemTransmissionFactory( + integrated_channel_code=integrated_channels_config.channel_code(), + enterprise_customer=customer, + plugin_configuration_id=integrated_channels_config.id, + enterprise_customer_catalog_uuid=enterprise_catalog_to_be_kept.uuid, + remote_deleted_at=None, + remote_created_at=datetime.now(), + ) + enterprise_catalog_to_be_deleted.delete() + + assert OrphanedContentTransmissions.objects.all().count() == 1 + assert OrphanedContentTransmissions.objects.filter(id=orphaned_content_transmission_item.id).exists() + @mock.patch('enterprise.signals.EnterpriseCatalogApiClient') def test_create_catalog(self, api_client_mock): api_client_mock.return_value.get_enterprise_catalog.return_value = {} diff --git a/tests/test_integrated_channels/test_integrated_channel/test_exporters/test_content_metadata.py b/tests/test_integrated_channels/test_integrated_channel/test_exporters/test_content_metadata.py index 973b7f9691..74aa398c89 100644 --- a/tests/test_integrated_channels/test_integrated_channel/test_exporters/test_content_metadata.py +++ b/tests/test_integrated_channels/test_integrated_channel/test_exporters/test_content_metadata.py @@ -58,6 +58,42 @@ def setUp(self): } super().setUp() + @mock.patch('enterprise.api_client.enterprise_catalog.EnterpriseCatalogApiClient.get_catalog_diff') + def test_exporter_get_catalog_diff_works_with_orphaned_content( + self, + mock_get_catalog_diff, + ): + """ + Test that the exporter _get_catalog_diff function properly marks orphaned content that is requested to be + created by another, linked catalog as resolved. + """ + transmission_audit = factories.ContentMetadataItemTransmissionFactory( + enterprise_customer=self.config.enterprise_customer, + plugin_configuration_id=self.config.id, + integrated_channel_code=self.config.channel_code(), + remote_created_at=datetime.datetime.utcnow(), + enterprise_customer_catalog_uuid=self.enterprise_customer_catalog.uuid, + ) + orphaned_content = factories.OrphanedContentTransmissionsFactory( + integrated_channel_code=self.config.channel_code(), + plugin_configuration_id=self.config.id, + content_id=FAKE_COURSE_RUN['key'], + transmission=transmission_audit, + resolved=False, + ) + mock_get_catalog_diff.return_value = get_fake_catalog_diff_create() + + exporter = ContentMetadataExporter('fake-user', self.config) + # pylint: disable=protected-access + _, __, ___ = exporter._get_catalog_diff( + enterprise_catalog=self.enterprise_customer_catalog, + content_keys=FAKE_COURSE_RUN['key'], + force_retrieve_all_catalogs=False, + max_item_count=10000000, + ) + orphaned_content.refresh_from_db() + assert orphaned_content.resolved + @mock.patch('enterprise.api_client.enterprise_catalog.EnterpriseCatalogApiClient.get_content_metadata') @mock.patch('enterprise.api_client.enterprise_catalog.EnterpriseCatalogApiClient.get_catalog_diff') def test_exporter_considers_failed_updates_as_existing_content( diff --git a/tests/test_management.py b/tests/test_management.py index a878dd81c2..ec6ad6b365 100644 --- a/tests/test_management.py +++ b/tests/test_management.py @@ -11,6 +11,7 @@ import ddt import factory +import pytz import responses from faker import Factory as FakerFactory from freezegun import freeze_time @@ -1930,3 +1931,75 @@ def test_normal_run(self): call_command('mark_orphaned_content_metadata_audits') orphaned_content = OrphanedContentTransmissions.objects.first() assert orphaned_content.content_id == self.orphaned_content.content_id + + +@mark.django_db +@ddt.ddt +class TestUpdateConfigLastErroredAt(unittest.TestCase, EnterpriseMockMixin): + """ + Test the ``update_config_last_errored_at`` management command. + """ + + def setUp(self): + super().setUp() + self.enterprise_customer_1 = factories.EnterpriseCustomerFactory( + name='Wonka Factory', + ) + self.enterprise_customer_2 = factories.EnterpriseCustomerFactory( + name='Hershey LLC', + ) + + def test_valid_audits(self): + """ + Verify that non-error audits and audits outside of the time range do not clear + out the error states + """ + old_timestamp = datetime.now() - timedelta(days=5) + csod_config = factories.CornerstoneEnterpriseCustomerConfigurationFactory( + enterprise_customer=self.enterprise_customer_1, + last_sync_errored_at=old_timestamp, + last_content_sync_errored_at=old_timestamp, + last_learner_sync_errored_at=old_timestamp, + ) + factories.ContentMetadataItemTransmissionFactory( + enterprise_customer=self.enterprise_customer_1, + plugin_configuration_id=csod_config.id, + integrated_channel_code=csod_config.channel_code(), + api_response_status_code=400, + # this one is not in the time frame and should not be counted + remote_created_at=timezone.now().date() - timedelta(days=5) + ) + call_command( + 'update_config_last_errored_at', + ) + csod_config.refresh_from_db() + assert csod_config.last_sync_errored_at is None + assert csod_config.last_content_sync_errored_at is None + assert csod_config.last_learner_sync_errored_at is None + + def test_invalid_audits(self): + """ + Verify that the management command runs when all records have the same subdomain + """ + old_timestamp = datetime.now() - timedelta(days=5) + old_timestamp = old_timestamp.replace(tzinfo=pytz.UTC) + moodle_config = factories.MoodleEnterpriseCustomerConfigurationFactory( + enterprise_customer=self.enterprise_customer_2, + last_sync_errored_at=old_timestamp, + last_content_sync_errored_at=old_timestamp, + last_learner_sync_errored_at=old_timestamp, + ) + factories.ContentMetadataItemTransmissionFactory( + enterprise_customer=self.enterprise_customer_2, + plugin_configuration_id=moodle_config.id, + integrated_channel_code=moodle_config.channel_code(), + api_response_status_code=400, + remote_created_at=datetime.now() + ) + call_command( + 'update_config_last_errored_at', + ) + moodle_config.refresh_from_db() + assert moodle_config.last_sync_errored_at == old_timestamp + assert moodle_config.last_content_sync_errored_at == old_timestamp + assert moodle_config.last_learner_sync_errored_at is None