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: allow enrolling learners on invitation only courses #20

Open
wants to merge 13 commits into
base: opencraft-release/redwood.1
Choose a base branch
from
Open
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
1 change: 0 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ on:
push:
branches: [master]
pull_request:
branches: [master]

concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/mysql8-migrations.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ jobs:
pip uninstall -y mysqlclient
pip install --no-binary mysqlclient mysqlclient
pip uninstall -y xmlsec
pip install --no-binary xmlsec xmlsec
pip install --no-binary xmlsec xmlsec==1.3.13
pip install backports.zoneinfo
- name: Initiate Services
run: |
Expand Down
2 changes: 1 addition & 1 deletion enterprise/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ class EnterpriseCustomerAdmin(DjangoObjectActions, SimpleHistoryAdmin):
('Integration and learning platform settings', {
'fields': ('enable_portal_lms_configurations_screen', 'enable_portal_saml_configuration_screen',
'enable_slug_login', 'replace_sensitive_sso_username', 'hide_course_original_price',
'enable_generation_of_api_credentials')
'enable_generation_of_api_credentials', 'allow_enrollment_in_invite_only_courses')
}),
('Recommended default settings for all enterprise customers', {
'fields': ('site', 'customer_type', 'enable_learner_portal',
Expand Down
1 change: 1 addition & 0 deletions enterprise/admin/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,7 @@ class Meta:
"enable_audit_data_reporting",
"replace_sensitive_sso_username",
"hide_course_original_price",
"allow_enrollment_in_invite_only_courses",
"enable_portal_code_management_screen",
"enable_portal_subscription_management_screen",
"enable_learner_portal",
Expand Down
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 @@ -442,3 +446,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)
26 changes: 26 additions & 0 deletions enterprise/api_client/lms.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import time
from urllib.parse import urljoin

import requests
from opaque_keys.edx.keys import CourseKey
from requests.exceptions import ( # pylint: disable=redefined-builtin
ConnectionError,
Expand Down Expand Up @@ -274,6 +275,31 @@ def get_enrolled_courses(self, username):
response.raise_for_status()
return response.json()

def allow_enrollment(self, email: str, course_id: str, auto_enroll: bool = False):
"""
Call the enrollment API to allow enrollment for the given email and course_id.
Args:
email (str): The email address of the user to be allowed to enroll in the course.
course_id (str): The string value of the course's unique identifier.
auto_enroll (bool): Whether to auto-enroll the user in the course upon registration / activation.
Returns:
dict: A dictionary containing details of the created CourseEnrollmentAllowed object.
"""
api_url = self.get_api_url("enrollment_allowed")
response = self.client.post(
f"{api_url}/",
json={
'email': email,
'course_id': course_id,
'auto_enroll': auto_enroll,
}
)
if response.status_code == requests.codes.conflict:
LOGGER.info(response.json()["message"])
else:
response.raise_for_status()
return response.json()


class CourseApiClient(NoAuthAPIClient):
"""
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 4.2.13 on 2024-08-16 07:23

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('enterprise', '0206_auto_20240408_1344'),
]

operations = [
migrations.AddField(
model_name='enterprisecustomer',
name='hide_course_price_when_zero',
field=models.BooleanField(default=False, help_text='Specify whether course cost should be hidden in the landing page when the final price is zero.'),
),
migrations.AddField(
model_name='historicalenterprisecustomer',
name='hide_course_price_when_zero',
field=models.BooleanField(default=False, help_text='Specify whether course cost should be hidden in the landing page when the final price is zero.'),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 4.2.13 on 2024-08-16 07:26

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('enterprise', '0207_enterprisecustomer_hide_course_price_when_zero_and_more'),
]

operations = [
migrations.AddField(
model_name='enterprisecustomer',
name='allow_enrollment_in_invite_only_courses',
field=models.BooleanField(default=False, help_text="Specifies if learners are allowed to enroll into courses marked as 'invitation-only', when they attempt to enroll from the landing page."),
),
migrations.AddField(
model_name='historicalenterprisecustomer',
name='allow_enrollment_in_invite_only_courses',
field=models.BooleanField(default=False, help_text="Specifies if learners are allowed to enroll into courses marked as 'invitation-only', when they attempt to enroll from the landing page."),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Generated by Django 4.2.13 on 2024-08-16 07:28

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('enterprise', '0208_enterprisecustomer_allow_enrollment_in_invite_only_courses_and_more'),
]

operations = [
migrations.RemoveField(
model_name='enterprisecustomer',
name='hide_course_price_when_zero',
),
migrations.RemoveField(
model_name='historicalenterprisecustomer',
name='hide_course_price_when_zero',
),
]
8 changes: 8 additions & 0 deletions enterprise/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,14 @@ class Meta:
help_text=_("Display Demo data from analyitcs and learner progress report for demo customer.")
)

allow_enrollment_in_invite_only_courses = models.BooleanField(
default=False,
help_text=_(
"Specifies if learners are allowed to enroll into courses marked as 'invitation-only', "
"when they attempt to enroll from the landing page."
)
)

contact_email = models.EmailField(
verbose_name="Customer admin contact email:",
null=True,
Expand Down
2 changes: 2 additions & 0 deletions enterprise/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,8 @@ def root(*args):
'status': 'published'
}

ENTERPRISE_ALGOLIA_SEARCH_API_KEY = 'test'

SNOWFLAKE_SERVICE_USER = '[email protected]'
SNOWFLAKE_SERVICE_USER_PASSWORD = 'secret'

Expand Down
21 changes: 21 additions & 0 deletions enterprise/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2028,6 +2028,8 @@ def enroll_subsidy_users_in_courses(enterprise_customer, subsidy_users_info, dis
[{ 'user_id': <lms_user_id>, 'email': <email>, 'course_run_key': <key> } ... ]
}
"""
from enterprise.api_client.lms import EnrollmentApiClient # pylint: disable=import-outside-toplevel
enrollment_api_client = EnrollmentApiClient()
Agrendalath marked this conversation as resolved.
Show resolved Hide resolved
results = {
'successes': [],
'pending': [],
Expand All @@ -2042,6 +2044,7 @@ def enroll_subsidy_users_in_courses(enterprise_customer, subsidy_users_info, dis
transaction_id = subsidy_user_info.get('transaction_id')
activation_link = subsidy_user_info.get('activation_link')
force_enrollment = subsidy_user_info.get('force_enrollment', False)
invitation_only = subsidy_user_info.get('invitation_only')

if user_id and user_email:
user = User.objects.filter(id=subsidy_user_info['user_id']).first()
Expand All @@ -2064,6 +2067,8 @@ def enroll_subsidy_users_in_courses(enterprise_customer, subsidy_users_info, dis
enrollment_source = enterprise_enrollment_source_model().get_source(
enterprise_enrollment_source_model().CUSTOMER_ADMIN
)
if invitation_only and enterprise_customer.allow_enrollment_in_invite_only_courses:
ensure_course_enrollment_is_allowed(course_run_key, user.email, enrollment_api_client)
succeeded, created, source_uuid = customer_admin_enroll_user_with_status(
enterprise_customer,
user,
Expand Down Expand Up @@ -2102,6 +2107,8 @@ def enroll_subsidy_users_in_courses(enterprise_customer, subsidy_users_info, dis
discount=discount,
license_uuid=license_uuid
)
if invitation_only and enterprise_customer.allow_enrollment_in_invite_only_courses:
ensure_course_enrollment_is_allowed(course_run_key, user_email, enrollment_api_client)
results['pending'].append({
'user': pending_user,
'email': user_email,
Expand Down Expand Up @@ -2455,3 +2462,17 @@ def get_integrations_for_customers(customer_uuid):
if choice.objects.filter(enterprise_customer__uuid=customer_uuid, active=True):
unique_integrations.append(code)
return unique_integrations


def ensure_course_enrollment_is_allowed(course_id: str, email: str, enrollment_api_client):
"""
Calls the enrollment API to create a CourseEnrollmentAllowed object for
invitation-only courses.
Arguments:
course_id (str): ID of the course to allow enrollment
email (str): email of the user whose enrollment should be allowed
enrollment_api_client (:class:`enterprise.api_client.lms.EnrollmentApiClient`): Enrollment API Client
"""
course_details = enrollment_api_client.get_course_details(course_id)
if course_details["invite_only"]:
enrollment_api_client.allow_enrollment(email, course_id)
9 changes: 9 additions & 0 deletions enterprise/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
CourseEnrollmentPermissionError,
NotConnectedToOpenEdX,
clean_html_for_template_rendering,
ensure_course_enrollment_is_allowed,
filter_audit_course_modes,
format_price,
get_active_course_runs,
Expand Down Expand Up @@ -689,6 +690,14 @@ def _enroll_learner_in_course(
course_modes=course_mode
)
)
if enterprise_customer.allow_enrollment_in_invite_only_courses:
ensure_course_enrollment_is_allowed(course_id, request.user.email, enrollment_api_client)
LOGGER.info(
'User {user} is allowed to enroll in Course {course_id}.'.format(
user=request.user.username,
course_id=course_id
)
)
try:
enrollment_api_client.enroll_user_in_course(
request.user.username,
Expand Down
1 change: 1 addition & 0 deletions requirements/base.in
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# This file contains the dependencies explicitly needed for this library.
#
# Packages directly used by this library that we do not need pinned to a specific version.
algoliasearch
bleach
celery
code-annotations
Expand Down
6 changes: 6 additions & 0 deletions requirements/dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ alabaster==0.7.13
# via
# -r requirements/doc.txt
# sphinx
algoliasearch==2.6.3
# via
# -r requirements/doc.txt
# -r requirements/test-master.txt
# -r requirements/test.txt
amqp==5.2.0
# via
# -r requirements/doc.txt
Expand Down Expand Up @@ -755,6 +760,7 @@ requests==2.31.0
# -r requirements/doc.txt
# -r requirements/test-master.txt
# -r requirements/test.txt
# algoliasearch
# coreapi
# django-oauth-toolkit
# edx-drf-extensions
Expand Down
3 changes: 3 additions & 0 deletions requirements/doc.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ aiosignal==1.3.1
# aiohttp
alabaster==0.7.13
# via sphinx
algoliasearch==2.6.3
# via -r requirements/test-master.txt
amqp==5.2.0
# via
# -r requirements/test-master.txt
Expand Down Expand Up @@ -426,6 +428,7 @@ readme-renderer==43.0
requests==2.31.0
# via
# -r requirements/test-master.txt
# algoliasearch
# coreapi
# django-oauth-toolkit
# edx-drf-extensions
Expand Down
5 changes: 5 additions & 0 deletions requirements/test-master.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ aiosignal==1.3.1
# via
# -c requirements/edx-platform-constraints.txt
# aiohttp
algoliasearch==2.6.3
# via
# -c requirements/edx-platform-constraints.txt
# -r requirements/base.in
amqp==5.2.0
# via kombu
aniso8601==9.0.1
Expand Down Expand Up @@ -410,6 +414,7 @@ requests==2.31.0
# via
# -c requirements/edx-platform-constraints.txt
# -r requirements/base.in
# algoliasearch
# coreapi
# django-oauth-toolkit
# edx-drf-extensions
Expand Down
3 changes: 3 additions & 0 deletions requirements/test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ aiosignal==1.3.1
# via
# -r requirements/test-master.txt
# kombu
algoliasearch==2.6.3
# via -r requirements/test-master.txt
aniso8601==9.0.1
# via
# -r requirements/test-master.txt
Expand Down Expand Up @@ -403,6 +405,7 @@ pyyaml==6.0.1
requests==2.31.0
# via
# -r requirements/test-master.txt
# algoliasearch
# coreapi
# django-oauth-toolkit
# edx-drf-extensions
Expand Down
Loading
Loading