Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: cherry-pick edx-enterprise customizations to Quince [BB-8368] #1976

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ Change Log

Unreleased
----------
[4.6.1]
---------
This version is based on v4.6.0 and contains some backports needed to OpenCraft's clients. There is no "official" v4.6.0.
See this PR for more details: https://github.com/open-craft/edx-enterprise/pull/13

[4.6.0]
-------
feat: Added enable_source_demo_data_for_analytics_and_lpr field to EnterpriseCustomer.
Expand Down
2 changes: 1 addition & 1 deletion enterprise/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
Your project description goes here.
"""

__version__ = "4.6.0"
__version__ = "4.6.1"
8 changes: 8 additions & 0 deletions enterprise/admin/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ class ManageLearnersForm(forms.Form):
label=_("Enroll these learners in this course"), required=False,
help_text=_("To enroll learners in a course, enter a course ID."),
)
force_enrollment = forms.BooleanField(
label=_("Force Enrollment"),
help_text=_("The selected course is 'Invite Only'. Only staff can enroll learners to this course."),
required=False,
)
course_mode = forms.ChoiceField(
label=_("Course enrollment track"), required=False,
choices=BLANK_CHOICE_DASH + [
Expand Down Expand Up @@ -130,6 +135,7 @@ class Fields:
REASON = "reason"
SALES_FORCE_ID = "sales_force_id"
DISCOUNT = "discount"
FORCE_ENROLLMENT = "force_enrollment"

class CsvColumns:
"""
Expand Down Expand Up @@ -395,6 +401,8 @@ class Meta:
"enable_audit_data_reporting",
"replace_sensitive_sso_username",
"hide_course_original_price",
"hide_course_price_when_zero",
"allow_enrollment_in_invite_only_courses",
"enable_portal_code_management_screen",
"enable_portal_subscription_management_screen",
"enable_learner_portal",
Expand Down
12 changes: 9 additions & 3 deletions enterprise/admin/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -678,7 +678,8 @@ def _enroll_users(
notify=True,
enrollment_reason=None,
sales_force_id=None,
discount=0.0
discount=0.0,
force_enrollment=False,
):
"""
Enroll the users with the given email addresses to the course.
Expand All @@ -691,6 +692,7 @@ def _enroll_users(
mode: The enrollment mode the users will be enrolled in the course with
course_id: The ID of the course in which we want to enroll
notify: Whether to notify (by email) the users that have been enrolled
force_enrollment: Force enrollment into "Invite Only" courses
"""
pending_messages = []
paid_modes = ['verified', 'professional']
Expand All @@ -704,6 +706,7 @@ def _enroll_users(
enrollment_reason=enrollment_reason,
discount=discount,
sales_force_id=sales_force_id,
force_enrollment=force_enrollment,
)
all_successes = succeeded + pending
if notify:
Expand Down Expand Up @@ -820,6 +823,7 @@ def post(self, request, customer_uuid):
sales_force_id = manage_learners_form.cleaned_data.get(ManageLearnersForm.Fields.SALES_FORCE_ID)
course_mode = manage_learners_form.cleaned_data.get(ManageLearnersForm.Fields.COURSE_MODE)
course_id = None
force_enrollment = manage_learners_form.cleaned_data.get(ManageLearnersForm.Fields.FORCE_ENROLLMENT)

if not course_id_with_emails:
course_details = manage_learners_form.cleaned_data.get(ManageLearnersForm.Fields.COURSE) or {}
Expand All @@ -834,7 +838,8 @@ def post(self, request, customer_uuid):
notify=notify,
enrollment_reason=manual_enrollment_reason,
sales_force_id=sales_force_id,
discount=discount
discount=discount,
force_enrollment=force_enrollment,
)
else:
for course_id, emails in course_id_with_emails.items():
Expand All @@ -849,7 +854,8 @@ def post(self, request, customer_uuid):
notify=notify,
enrollment_reason=manual_enrollment_reason,
sales_force_id=sales_force_id,
discount=discount
discount=discount,
force_enrollment=force_enrollment,
)

# Redirect to GET if everything went smooth.
Expand Down
32 changes: 31 additions & 1 deletion enterprise/api/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from django.contrib import auth

from enterprise.models import EnterpriseCustomerUser, SystemWideEnterpriseUserRoleAssignment
from enterprise.models import EnterpriseCustomer, EnterpriseCustomerUser, SystemWideEnterpriseUserRoleAssignment

User = auth.get_user_model()

Expand All @@ -33,6 +33,36 @@ def filter_queryset(self, request, queryset, view):
return queryset


class EnterpriseCourseEnrollmentFilterBackend(filters.BaseFilterBackend):
"""
Filter backend to return enrollments under the user's enterprise(s) only.

* Staff users will bypass this filter.
* Non-staff users will receive enrollments under their linked enterprises,
only if they have the `enterprise.can_enroll_learners` permission.
* Non-staff users without the `enterprise.can_enroll_learners` permission
will receive only their own enrollments.
"""

def filter_queryset(self, request, queryset, view):
"""
Filter out enrollments if learner is not linked
"""

if request.user.is_staff:
return queryset

if request.user.has_perm('enterprise.can_enroll_learners'):
enterprise_customers = EnterpriseCustomer.objects.filter(enterprise_customer_users__user_id=request.user.id)
return queryset.filter(enterprise_customer_user__enterprise_customer__in=enterprise_customers)

filter_kwargs = {
view.USER_ID_FILTER: request.user.id,
}

return queryset.filter(**filter_kwargs)


class EnterpriseCustomerUserFilterBackend(filters.BaseFilterBackend):
"""
Allow filtering on the enterprise customer user api endpoint.
Expand Down
26 changes: 26 additions & 0 deletions enterprise/api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,32 @@ class Meta:
)


class EnterpriseCourseEnrollmentWithAdditionalFieldsReadOnlySerializer(EnterpriseCourseEnrollmentReadOnlySerializer):
"""
Serializer for EnterpriseCourseEnrollment model with additional fields.
"""

class Meta:
model = models.EnterpriseCourseEnrollment
fields = (
'enterprise_customer_user',
'course_id',
'created',
'unenrolled_at',
'enrollment_date',
'enrollment_track',
'user_email',
'course_start',
'course_end',
)

enrollment_track = serializers.CharField()
enrollment_date = serializers.DateTimeField()
user_email = serializers.EmailField()
course_start = serializers.DateTimeField()
course_end = serializers.DateTimeField()


class EnterpriseCourseEnrollmentWriteSerializer(serializers.ModelSerializer):
"""
Serializer for writing to the EnterpriseCourseEnrollment model.
Expand Down
63 changes: 61 additions & 2 deletions enterprise/api/v1/views/enterprise_course_enrollment.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,68 @@
"""
Views for the ``enterprise-course-enrollment`` API endpoint.
"""
from django_filters.rest_framework import DjangoFilterBackend
from edx_rest_framework_extensions.paginators import DefaultPagination
from rest_framework import filters

from django.core.paginator import Paginator
from django.utils.functional import cached_property

from enterprise import models
from enterprise.api.filters import EnterpriseCourseEnrollmentFilterBackend
from enterprise.api.v1 import serializers
from enterprise.api.v1.views.base_views import EnterpriseReadWriteModelViewSet

try:
from common.djangoapps.util.query import read_replica_or_default
except ImportError:
def read_replica_or_default():
return None


class PaginatorWithOptimizedCount(Paginator):
"""
Django < 4.2 ORM doesn't strip unused annotations from count queries.

For example, if we execute this query:

Book.objects.annotate(Count('chapters')).count()

it will generate SQL like this:

SELECT COUNT(*) FROM (SELECT COUNT(...), ... FROM ...) subquery

This isn't optimal on its own, but it's not a big deal. However, this
becomes problematic when annotations use subqueries, because it's terribly
inefficient to execute the subquery for every row in the outer query.

This class overrides the count() method of Django's Paginator to strip
unused annotations from the query by requesting only the primary key
instead of all fields.

This is a temporary workaround until Django is updated to 4.2, which will
include a fix for this issue.

See https://code.djangoproject.com/ticket/32169 for more details.

TODO: remove this class once Django is updated to 4.2 or higher.
"""
@cached_property
def count(self):
return self.object_list.values("pk").count()


class EnterpriseCourseEnrollmentPagination(DefaultPagination):
django_paginator_class = PaginatorWithOptimizedCount


class EnterpriseCourseEnrollmentViewSet(EnterpriseReadWriteModelViewSet):
"""
API views for the ``enterprise-course-enrollment`` API endpoint.
"""

queryset = models.EnterpriseCourseEnrollment.objects.all()
queryset = models.EnterpriseCourseEnrollment.with_additional_fields.all()
filter_backends = (filters.OrderingFilter, DjangoFilterBackend, EnterpriseCourseEnrollmentFilterBackend)

USER_ID_FILTER = 'enterprise_customer_user__user_id'
FIELDS = (
Expand All @@ -20,10 +71,18 @@ class EnterpriseCourseEnrollmentViewSet(EnterpriseReadWriteModelViewSet):
filterset_fields = FIELDS
ordering_fields = FIELDS

pagination_class = EnterpriseCourseEnrollmentPagination

def get_queryset(self):
queryset = super().get_queryset()
if self.request.method == 'GET':
queryset = queryset.using(read_replica_or_default())
return queryset

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.EnterpriseCourseEnrollmentWithAdditionalFieldsReadOnlySerializer
return serializers.EnterpriseCourseEnrollmentWriteSerializer
44 changes: 43 additions & 1 deletion enterprise/api/v1/views/enterprise_customer.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@

from urllib.parse import quote_plus, unquote

from algoliasearch.search_client import SearchClient
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.exceptions import NotFound, ValidationError
from rest_framework.response import Response
from rest_framework.status import (
HTTP_200_OK,
Expand All @@ -17,12 +18,15 @@
HTTP_409_CONFLICT,
)

from django.conf import settings
from django.contrib import auth
from django.core import exceptions
from django.db import transaction
from django.db.models import Q
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils.decorators import method_decorator
from django.utils.translation import gettext_lazy as _

from enterprise import models
from enterprise.api.filters import EnterpriseLinkedUserFilterBackend
Expand Down Expand Up @@ -436,3 +440,41 @@ def unlink_users(self, request, pk=None): # pylint: disable=unused-argument
raise UnlinkUserFromEnterpriseError(msg) from exc

return Response(status=HTTP_200_OK)

@action(detail=False)
def algolia_key(self, request, *args, **kwargs):
"""
Returns an Algolia API key that is secured to only allow searching for
objects associated with enterprise customers that the user is linked to.

This endpoint is used with `frontend-app-learner-portal-enterprise` MFE
currently.
"""

if not (api_key := getattr(settings, "ENTERPRISE_ALGOLIA_SEARCH_API_KEY", "")):
LOGGER.warning("Algolia search API key is not configured. To enable this view, "
"set `ENTERPRISE_ALGOLIA_SEARCH_API_KEY` in settings.")
raise Http404

queryset = self.queryset.filter(
**{
self.USER_ID_FILTER: request.user.id,
"enterprise_customer_users__linked": True
}
).values_list("uuid", flat=True)

if len(queryset) == 0:
raise NotFound(_("User is not linked to any enterprise customers."))

secured_key = SearchClient.generate_secured_api_key(
api_key,
{
"filters": " OR ".join(
f"enterprise_customer_uuids:{enterprise_customer_uuid}"
for enterprise_customer_uuid
in queryset
),
}
)

return Response({"key": secured_key}, status=HTTP_200_OK)
14 changes: 12 additions & 2 deletions enterprise/api_client/lms.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,15 @@ def has_course_mode(self, course_run_id, mode):
course_modes = self.get_course_modes(course_run_id)
return any(course_mode for course_mode in course_modes if course_mode['slug'] == mode)

def enroll_user_in_course(self, username, course_id, mode, cohort=None, enterprise_uuid=None):
def enroll_user_in_course(
self,
username,
course_id,
mode,
cohort=None,
enterprise_uuid=None,
force_enrollment=False,
):
"""
Call the enrollment API to enroll the user in the course specified by course_id.

Expand All @@ -138,6 +146,7 @@ def enroll_user_in_course(self, username, course_id, mode, cohort=None, enterpri
mode (str): The enrollment mode which should be used for the enrollment
cohort (str): Add the user to this named cohort
enterprise_uuid (str): Add course enterprise uuid
force_enrollment (bool): Force the enrollment even if course is Invite Only

Returns:
dict: A dictionary containing details of the enrollment, including course details, mode, username, etc.
Expand All @@ -152,7 +161,8 @@ def enroll_user_in_course(self, username, course_id, mode, cohort=None, enterpri
'is_active': True,
'mode': mode,
'cohort': cohort,
'enterprise_uuid': str(enterprise_uuid)
'enterprise_uuid': str(enterprise_uuid),
'force_enrollment': force_enrollment,
}
)
response.raise_for_status()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 4.2 on 2023-12-29 17:03

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('enterprise', '0192_auto_20231009_1302'),
]

operations = [
migrations.AlterModelOptions(
name='enterprisecourseenrollment',
options={'ordering': ['id']},
),
]
Loading