Skip to content

Commit

Permalink
Merge branch 'master' into 0x29a/enable-ci-for-pull-requests-against-…
Browse files Browse the repository at this point in the history
…non-master-branches
  • Loading branch information
0x29a authored Apr 19, 2024
2 parents ccc68ae + 5648d58 commit 13af747
Show file tree
Hide file tree
Showing 19 changed files with 800 additions and 221 deletions.
1 change: 1 addition & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,6 @@
- Each step in the release build has a condition flag that checks if the rest of the steps are done and if so will deploy to PyPi.
(so basically once your build finishes, after maybe a minute you should see the new version in PyPi automatically (on refresh))
- [ ] PR created in [edx-platform](https://github.com/openedx/edx-platform) to upgrade dependencies (including edx-enterprise)
- Trigger the '[Upgrade one Python dependency](https://github.com/openedx/edx-platform/actions/workflows/upgrade-one-python-dependency.yml)' action against master in edx-platform with new version number to generate version bump PR
- This **must** be done after the version is visible in PyPi as `make upgrade` in edx-platform will look for the latest version in PyPi.
- Note: the edx-enterprise constraint in edx-platform **must** also be bumped to the latest version in PyPi.
27 changes: 26 additions & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,34 @@ Change Log
Unreleased
----------
[4.15.9]
--------
* fix: return a 404 response for inactive CSOD customers while fetching courses


[4.15.8]
--------
* fix: SSO self-serve tool invalid entityId parsing

[4.15.7]
--------
* feat: add send group membership invite and removal braze emails

[4.15.6]
--------
* perf: update user preferences inside an async task to void request timeout

[4.15.5]
--------
* fix: Improved the query to only fetch active configs for CSOD customers.

[4.15.4]
--------
* fix: allowing for existing pecus to be added to enterprise groups

[4.15.3]
--------
* feat: replacing non encrypted fields of degreed config model with encrypted ones
* feat: replacing non encrypted fields of degreed config model with encrypted ones

[4.15.2]
--------
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.15.3"
__version__ = "4.15.9"
12 changes: 12 additions & 0 deletions enterprise/api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1692,6 +1692,17 @@ class Meta:
updated = serializers.DateTimeField(required=False, read_only=True)


class EnterpriseGroupRequestDataSerializer(serializers.Serializer):
"""
Serializer for the Enterprise Group Assign Learners endpoint query params
"""
catalog_uuid = serializers.UUIDField(required=False, allow_null=True)
act_by_date = serializers.DateTimeField(required=False, allow_null=True)
learner_emails = serializers.ListField(
child=serializers.EmailField(required=True),
allow_empty=False)


class EnterpriseGroupLearnersRequestQuerySerializer(serializers.Serializer):
"""
Serializer for the Enterprise Group Learners endpoint query filter
Expand All @@ -1705,3 +1716,4 @@ class EnterpriseGroupLearnersRequestQuerySerializer(serializers.Serializer):
],
required=False,
)
pending_users_only = serializers.BooleanField(required=False, default=False)
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Views for the ``enterprise-customer-sso-configuration`` API endpoint.
"""

import re
from xml.etree.ElementTree import fromstring

import requests
Expand All @@ -25,6 +26,7 @@
from enterprise.api.utils import get_enterprise_customer_from_user_id
from enterprise.api.v1 import serializers
from enterprise.api_client.sso_orchestrator import SsoOrchestratorClientError
from enterprise.constants import ENTITY_ID_REGEX
from enterprise.logging import getEnterpriseLogger
from enterprise.models import EnterpriseCustomer, EnterpriseCustomerSsoConfiguration, EnterpriseCustomerUser
from enterprise.tasks import send_sso_configured_email
Expand Down Expand Up @@ -101,8 +103,13 @@ def fetch_entity_id_from_metadata_xml(metadata_xml):
root = fromstring(metadata_xml)
if entity_id := root.get('entityID'):
return entity_id
if entity_descriptor_child := root.find('EntityDescriptor'):
elif entity_descriptor_child := root.find('EntityDescriptor'):
return entity_descriptor_child.get('entityID')
else:
# find <EntityDescriptor entityId=''> and parse URL from it
match = re.search(ENTITY_ID_REGEX, metadata_xml, re.DOTALL)
if match:
return match.group(2)
raise EntityIdNotFoundError('Could not find entity ID in metadata xml')


Expand Down
268 changes: 157 additions & 111 deletions enterprise/api/v1/views/enterprise_group.py

Large diffs are not rendered by default.

86 changes: 74 additions & 12 deletions enterprise/api_client/braze.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,88 @@

logger = logging.getLogger(__name__)

ENTERPRISE_BRAZE_ALIAS_LABEL = 'Enterprise' # Do Not change this, this is consistent with other uses across edX repos.

class BrazeAPIClient:

class BrazeAPIClient(BrazeClient):
"""
API client for calls to Braze.
"""
@classmethod
def get_braze_client(cls):
""" Returns a Braze client. """
if not BrazeClient:
return None

# fetching them from edx-platform settings
def __init__(self):
braze_api_key = getattr(settings, 'EDX_BRAZE_API_KEY', None)
braze_api_url = getattr(settings, 'EDX_BRAZE_API_SERVER', None)
required_settings = ['EDX_BRAZE_API_KEY', 'EDX_BRAZE_API_SERVER']
for setting in required_settings:
if not getattr(settings, setting, None):
msg = f'Missing {setting} in settings required for Braze API Client.'
logger.error(msg)
raise ValueError(msg)

if not braze_api_key or not braze_api_url:
return None

return BrazeClient(
super().__init__(
api_key=braze_api_key,
api_url=braze_api_url,
app_id='',
)

def generate_mailto_link(self, emails):
"""
Generate a mailto link for the given emails.
"""
if emails:
return f'mailto:{",".join(emails)}'

return None

def create_recipient(
self,
user_email,
lms_user_id,
trigger_properties=None,
):
"""
Create a recipient object using the given user_email and lms_user_id.
Identifies the given email address with any existing Braze alias records
via the provided ``lms_user_id``.
"""

user_alias = {
'alias_label': ENTERPRISE_BRAZE_ALIAS_LABEL,
'alias_name': user_email,
}

# Identify the user alias in case it already exists. This is necessary so
# we don't accidentally create a duplicate Braze profile.
self.identify_users([{
'external_id': lms_user_id,
'user_alias': user_alias
}])

attributes = {
"user_alias": user_alias,
"email": user_email,
"is_enterprise_learner": True,
"_update_existing_only": False,
}

return {
'external_user_id': lms_user_id,
'attributes': attributes,
# If a profile does not already exist, Braze will create a new profile before sending a message.
'send_to_existing_only': False,
'trigger_properties': trigger_properties or {},
}

def create_recipient_no_external_id(self, user_email):
"""
Create a Braze recipient dict identified only by an alias based on their email.
"""
return {
'attributes': {
'email': user_email,
'is_enterprise_learner': True,
},
'user_alias': {
'alias_label': ENTERPRISE_BRAZE_ALIAS_LABEL,
'alias_name': user_email,
},
}
2 changes: 2 additions & 0 deletions enterprise/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,3 +250,5 @@ class FulfillmentTypes:
(GROUP_MEMBERSHIP_ACCEPTED_STATUS, 'Accepted'),
(GROUP_MEMBERSHIP_PENDING_STATUS, 'Pending'),
)

ENTITY_ID_REGEX = r"<(\w+:)?EntityDescriptor.*?entityID=['\"](.*?)['\"].*?>"
37 changes: 24 additions & 13 deletions enterprise/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4292,29 +4292,33 @@ def _get_filtered_ecu_ids(self, user_query):
ecus = EnterpriseCustomerUser.objects.raw(sql_string, (customer_id, var_q))
return [ecu.id for ecu in ecus]

def _get_implicit_group_members(self, user_query=None):
def _get_implicit_group_members(self, user_query=None, pending_users_only=False):
"""
Fetches all implicit members of a group, indicated by a (pending) enterprise customer user records.
"""
members = []
customer_users = []

# Regardless of user_query, we will need all pecus related to the group's customer
pending_customer_users = PendingEnterpriseCustomerUser.objects.filter(
enterprise_customer=self.enterprise_customer,
)

if user_query:
# Get all ecus relevant to the user query
customer_users = EnterpriseCustomerUser.objects.filter(
id__in=self._get_filtered_ecu_ids(user_query)
)
if not pending_users_only:
customer_users = EnterpriseCustomerUser.objects.filter(
id__in=self._get_filtered_ecu_ids(user_query)
)
# pecu has user_email as a field, so we can filter directly
pending_customer_users = pending_customer_users.filter(user_email__icontains=user_query)
else:
# No filtering query so get all ecus related to the group's customer
customer_users = EnterpriseCustomerUser.objects.filter(
enterprise_customer=self.enterprise_customer,
active=True,
)
if not pending_users_only:
# No filtering query so get all ecus related to the group's customer
customer_users = EnterpriseCustomerUser.objects.filter(
enterprise_customer=self.enterprise_customer,
active=True,
)
# Build an in memory array of all the implicit memberships
for ent_user in customer_users:
members.append(EnterpriseGroupMembership(
Expand All @@ -4330,7 +4334,7 @@ def _get_implicit_group_members(self, user_query=None):
))
return members

def _get_explicit_group_members(self, user_query=None, fetch_removed=False):
def _get_explicit_group_members(self, user_query=None, fetch_removed=False, pending_users_only=False,):
"""
Fetch explicitly defined members of a group, indicated by an existing membership record
"""
Expand All @@ -4345,9 +4349,16 @@ def _get_explicit_group_members(self, user_query=None, fetch_removed=False):
# pecu has user_email as a field, so we can filter directly through the ORM with the user_query
pecu_filter = Q(pending_enterprise_customer_user__user_email__icontains=user_query)
members = members.filter(ecu_filter | pecu_filter)
if pending_users_only:
members = members.filter(is_removed=False, enterprise_customer_user_id__isnull=True)
return members

def get_all_learners(self, user_query=None, sort_by=None, desc_order=False, fetch_removed=False):
def get_all_learners(self,
user_query=None,
sort_by=None,
desc_order=False,
fetch_removed=False,
pending_users_only=False):
"""
Returns all users associated with the group, whether the group specifies the entire org else all associated
membership records.
Expand All @@ -4359,9 +4370,9 @@ def get_all_learners(self, user_query=None, sort_by=None, desc_order=False, fetc
beginning of the sorting value ie `-memberStatus`.
"""
if self.applies_to_all_contexts:
members = self._get_implicit_group_members(user_query)
members = self._get_implicit_group_members(user_query, pending_users_only)
else:
members = self._get_explicit_group_members(user_query, fetch_removed)
members = self._get_explicit_group_members(user_query, fetch_removed, pending_users_only)
if sort_by:
lambda_keys = {
'member_details': lambda t: t.member_email,
Expand Down
5 changes: 5 additions & 0 deletions enterprise/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,3 +364,8 @@ def root(*args):
ENTERPRISE_SSO_ORCHESTRATOR_BASE_URL = 'https://foobar.com'
ENTERPRISE_SSO_ORCHESTRATOR_CONFIGURE_PATH = 'configure'
ENTERPRISE_SSO_ORCHESTRATOR_CONFIGURE_EDX_OAUTH_PATH = 'configure-edx-oauth'

EDX_BRAZE_API_KEY='test-api-key'
EDX_BRAZE_API_SERVER='test-api-server'
BRAZE_GROUPS_INVITATION_EMAIL_CAMPAIGN_ID = 'test-invitation-campaign-id'
BRAZE_GROUPS_REMOVAL_EMAIL_CAMPAIGN_ID = 'test-removal-campaign-id'
8 changes: 4 additions & 4 deletions enterprise/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,12 @@
from enterprise.api import activate_admin_permissions
from enterprise.api_client.enterprise_catalog import EnterpriseCatalogApiClient
from enterprise.decorators import disable_for_loaddata
from enterprise.tasks import create_enterprise_enrollment
from enterprise.tasks import create_enterprise_enrollment, update_enterprise_learners_user_preference
from enterprise.utils import (
NotConnectedToOpenEdX,
get_default_catalog_content_filter,
localized_utcnow,
unset_enterprise_learner_language,
unset_language_of_all_enterprise_learners,
)
from integrated_channels.blackboard.models import BlackboardEnterpriseCustomerConfiguration
from integrated_channels.canvas.models import CanvasEnterpriseCustomerConfiguration
Expand Down Expand Up @@ -104,10 +103,11 @@ def update_lang_pref_of_all_learners(sender, instance, **kwargs): # pylint: dis
# The middleware in the enterprise will handle the cases for setting a proper language for the learner.
if instance.default_language:
prev_state = models.EnterpriseCustomer.objects.filter(uuid=instance.uuid).first()
if prev_state is None or prev_state.default_language != instance.default_language:
if prev_state and prev_state.default_language != instance.default_language:
# Unset the language preference of all the learners linked with the enterprise customer.
# The middleware in the enterprise will handle the cases for setting a proper language for the learner.
unset_language_of_all_enterprise_learners(instance)
logger.info('Task triggered to update user preference for learners. Enterprise: [%s]', instance.uuid)
update_enterprise_learners_user_preference.delay(instance.uuid)


@receiver(pre_save, sender=models.EnterpriseCustomerBrandingConfiguration)
Expand Down
Loading

0 comments on commit 13af747

Please sign in to comment.