Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 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
101 changes: 18 additions & 83 deletions enterprise_access/apps/bffs/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,13 @@
HandlerContext for bffs app.
"""
import logging
from urllib.error import HTTPError

from rest_framework import status

from enterprise_access.apps.bffs import serializers
from enterprise_access.apps.bffs.api import (
get_and_cache_enterprise_customer_users,
get_and_cache_secured_algolia_search_keys,
transform_enterprise_customer_users_data,
transform_secured_algolia_api_key_response
transform_enterprise_customer_users_data
)

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -59,7 +56,7 @@ def __init__(self, request):
self.data = {} # Stores processed data for the response

# Initialize common context data
self._initialize_common_context_data()
self.load_common_context_data()

@property
def request(self):
Expand Down Expand Up @@ -117,14 +114,15 @@ def all_linked_enterprise_customer_users(self):
def should_update_active_enterprise_customer_user(self):
return self.data.get('should_update_active_enterprise_customer_user')

@property
def secured_algolia_api_key(self):
return self.data.get('secured_algolia_api_key')

@property
def catalog_uuids_to_catalog_query_uuids(self):
return self.data.get('catalog_uuids_to_catalog_query_uuids')

@property
def secured_algolia_api_key(self):
"""Get the secured Algolia API key."""
return self.data.get('secured_algolia_api_key')

@property
def is_request_user_linked_to_enterprise_customer(self):
"""
Expand All @@ -147,7 +145,7 @@ def set_status_code(self, status_code):
"""
self._status_code = status_code

def _initialize_common_context_data(self):
def load_common_context_data(self):
"""
Initializes common context data, like enterprise customer UUID and user ID.
"""
Expand All @@ -167,7 +165,7 @@ def _initialize_common_context_data(self):

# Initialize the enterprise customer users metadata derived from the LMS
try:
self._initialize_enterprise_customer_users()
self.load_enterprise_customer_users()
except Exception as exc: # pylint: disable=broad-except
logger.exception(
'Error initializing enterprise customer users for request user %s, '
Expand Down Expand Up @@ -208,55 +206,7 @@ def _initialize_common_context_data(self):
if not self.enterprise_customer_uuid:
self._enterprise_customer_uuid = self.enterprise_customer.get('uuid')

# Initialize the secured algolia api keys metadata derived from enterprise catalog
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[inform] Moved outside of context.py and into BaseLearnerPortalHandler.

try:
self._initialize_secured_algolia_api_keys()
except HTTPError as exc:
exception_response = exc.response.json()
exception_response_user_message = exception_response.get('user_message')
exception_response_developer_message = exception_response.get('developer_message')
logger.exception(
'HTTP Error initializing the secured algolia api keys for request user %s, '
'enterprise customer uuid %s',
self.lms_user_id,
enterprise_customer_uuid,
)
self.add_error(
user_message=exception_response_user_message or 'HTTP Error initializing the secured algolia api keys',
developer_message=exception_response_developer_message or
f'Could not initialize the secured algolia api keys. Error: {exc}',
)
except Exception as exc: # pylint: disable=broad-except
logger.exception(
'Error initializing the secured algolia api keys for request user %s, '
'enterprise customer uuid %s',
self.lms_user_id,
enterprise_customer_uuid,
)
self.add_error(
user_message='Error initializing the secured algolia api keys',
developer_message=f'Could not initialize the secured algolia api keys. Error: {exc}',
)

if not (self.secured_algolia_api_key and self.catalog_uuids_to_catalog_query_uuids):
logger.info(
'No secured algolia key found for request user %s, enterprise customer uuid %s, '
'and/or enterprise slug %s',
self.lms_user_id,
enterprise_customer_uuid,
enterprise_customer_slug,
)
self.add_error(
user_message='No secured algolia api key or catalog query mapping found',
developer_message=(
f'No secured algolia api key or catalog query mapping found for request '
f'user {self.lms_user_id} and enterprise uuid '
f'{enterprise_customer_uuid}, and/or enterprise slug {enterprise_customer_slug}'
),
)
return

def _initialize_enterprise_customer_users(self):
def load_enterprise_customer_users(self):
"""
Initializes the enterprise customer users for the request user.
"""
Expand Down Expand Up @@ -298,32 +248,17 @@ def _initialize_enterprise_customer_users(self):
)
})

def _initialize_secured_algolia_api_keys(self):
"""
Initializes the secured algolia api key for the request user.
def update_algolia_keys(self, api_key, catalog_mapping):
"""
secured_algolia_api_key_data = get_and_cache_secured_algolia_search_keys(
self.request,
self._enterprise_customer_uuid,
)
Updates the Algolia API keys in the context.

secured_algolia_api_key = None
catalog_uuids_to_catalog_query_uuids = {}
try:
secured_algolia_api_key, catalog_uuids_to_catalog_query_uuids = transform_secured_algolia_api_key_response(
secured_algolia_api_key_data
)
except Exception: # pylint: disable=broad-except
logger.exception(
'Error transforming secured algolia api key for request user %s,'
'enterprise customer uuid %s and/or slug %s',
self.lms_user_id,
self.enterprise_customer_uuid,
self.enterprise_customer_slug,
)
Args:
api_key: The secured Algolia API key
catalog_mapping: Dictionary mapping catalog UUIDs to query UUIDs
"""
self.data.update({
'secured_algolia_api_key': secured_algolia_api_key,
'catalog_uuids_to_catalog_query_uuids': catalog_uuids_to_catalog_query_uuids
'secured_algolia_api_key': api_key,
'catalog_uuids_to_catalog_query_uuids': catalog_mapping or {}
})

def add_error(self, status_code=None, **kwargs):
Expand Down
146 changes: 102 additions & 44 deletions enterprise_access/apps/bffs/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
"""
import json
import logging
import time
from enum import Enum, auto

from enterprise_access.apps.api_client.constants import LicenseStatuses
from enterprise_access.apps.api_client.license_manager_client import LicenseManagerUserApiClient
Expand All @@ -15,12 +17,16 @@
invalidate_subscription_licenses_cache
)
from enterprise_access.apps.bffs.context import HandlerContext
from enterprise_access.apps.bffs.mixins import BaseLearnerDataMixin, LearnerDashboardDataMixin
from enterprise_access.apps.bffs.mixins import AlgoliaDataMixin, BaseLearnerDataMixin, LearnerDashboardDataMixin
from enterprise_access.apps.bffs.serializers import EnterpriseCustomerUserSubsidiesSerializer
from enterprise_access.apps.bffs.task_runner import ConcurrentTaskRunner

logger = logging.getLogger(__name__)


MOCK_TASK_DELAY = 5
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[inform] Temporary constant to use with time.sleep(MOCK_TASK_DELAY) to simulate longer requests.



class BaseHandler:
"""
A base handler class that provides shared core functionality for different BFF handlers.
Expand Down Expand Up @@ -64,14 +70,20 @@ def add_warning(self, user_message, developer_message):
)


class BaseLearnerPortalHandler(BaseHandler, BaseLearnerDataMixin):
class BaseLearnerPortalHandler(BaseHandler, AlgoliaDataMixin, BaseLearnerDataMixin):
"""
A base handler class for learner-focused routes.

The `BaseLearnerHandler` extends `BaseHandler` and provides shared core functionality
across all learner-focused page routes, such as the learner dashboard, search, and course routes.
"""

class CONCURRENCY_GROUPS(Enum):
"""
Group names for concurrent tasks.
"""
DEFAULT = auto()

def __init__(self, context):
"""
Initializes the BaseLearnerPortalHandler with a HandlerContext and API clients.
Expand All @@ -84,26 +96,69 @@ def __init__(self, context):
self.license_manager_user_api_client = LicenseManagerUserApiClient(self.context.request)
self.lms_api_client = LmsApiClient()

def _get_concurrent_tasks(self):
"""
Establishes the data structure for tasks and adds base tasks.
Subclasses may call this method via super() to extend the tasks
for any specific group.
"""
# Initialize groups
tasks = {
self.CONCURRENCY_GROUPS.DEFAULT: [],
}

# Add tasks to default group
tasks[self.CONCURRENCY_GROUPS.DEFAULT].extend([
self.load_and_process_subsidies,
self.load_secured_algolia_api_key,
self.load_and_process_default_enrollment_intentions,
])

return tasks

def load_secured_algolia_api_key(self):
"""
Temporary override to add delay.
"""
time.sleep(MOCK_TASK_DELAY)
super().load_secured_algolia_api_key()

def load_and_process_subsidies(self):
"""
Load and process subsidies for learners
"""
time.sleep(MOCK_TASK_DELAY)
empty_subsidies = {
'subscriptions': {
'customer_agreement': None,
},
}
self.context.data['enterprise_customer_user_subsidies'] =\
EnterpriseCustomerUserSubsidiesSerializer(empty_subsidies).data

# Retrieve and process subsidies
self.load_and_process_subscription_licenses()

def load_and_process_default_enrollment_intentions(self):
"""
Helper method to encapsulate the two-step enrollment process
into a single unit of work for the concurrent runner.
"""
time.sleep(MOCK_TASK_DELAY)
self.load_default_enterprise_enrollment_intentions()
self.enroll_in_redeemable_default_enterprise_enrollment_intentions()

def load_and_process(self):
"""
Loads and processes data. This is a basic implementation that can be overridden by subclasses.

The method in this class simply calls common learner logic to ensure the context is set up.
"""
try:
# Verify enterprise customer attrs have learner portal enabled
# Verify enterprise customer exists and has learner portal enabled
self.ensure_learner_portal_enabled()

# Transform enterprise customer data
self.transform_enterprise_customers()

# Retrieve and process subscription licenses. Handles activation and auto-apply logic.
self.load_and_process_subsidies()

# Retrieve default enterprise courses and enroll in the redeemable ones
self.load_default_enterprise_enrollment_intentions()
self.enroll_in_redeemable_default_enterprise_enrollment_intentions()
except Exception as exc: # pylint: disable=broad-exception-caught
except Exception as exc: # pylint: disable=broad-except
logger.exception(
"Error loading/processing learner portal handler for request user %s and enterprise customer %s",
self.context.lms_user_id,
Expand All @@ -113,6 +168,26 @@ def load_and_process(self):
user_message="Could not load and/or process common data",
developer_message=f"Unable to load and/or process common learner portal data: {exc}",
)
return

all_tasks_to_run = self._get_concurrent_tasks()
with ConcurrentTaskRunner(task_definitions=all_tasks_to_run) as runner:
task_results = runner.run_group(self.CONCURRENCY_GROUPS.DEFAULT)
def handle_task_error(task_name, error_message):
logger.error(
"Error running concurrent task '%s' for request user %s and enterprise customer %s: %s",
task_name,
self.context.lms_user_id,
self.context.enterprise_customer_uuid,
error_message,
)
self.add_error(
user_message="Could not load and/or process a concurrent task",
developer_message=(
f"Unable to load and/or process concurrent task '{task_name}': {error_message}"
),
)
runner.handle_failed_tasks(task_results, handle_task_error)

def ensure_learner_portal_enabled(self):
"""
Expand Down Expand Up @@ -166,19 +241,6 @@ def transform_enterprise_customers(self):
f"No linked enterprise customer users found in the context for request user {self.context.lms_user_id}"
)

def load_and_process_subsidies(self):
"""
Load and process subsidies for learners
"""
empty_subsidies = {
'subscriptions': {
'customer_agreement': None,
},
}
self.context.data['enterprise_customer_user_subsidies'] =\
EnterpriseCustomerUserSubsidiesSerializer(empty_subsidies).data
self.load_and_process_subscription_licenses()

def transform_enterprise_customer_user(self, enterprise_customer_user):
"""
Transform the enterprise customer user data.
Expand Down Expand Up @@ -698,27 +760,23 @@ class DashboardHandler(LearnerDashboardDataMixin, BaseLearnerPortalHandler):
of data specific to the learner dashboard.
"""

def load_and_process(self):
def _get_concurrent_tasks(self):
"""
Loads and processes data for the learner dashboard route.

This method overrides the `load_and_process` method in `BaseLearnerPortalHandler`.
This is the key method. It extends the tasks from its parent.
"""
super().load_and_process()
tasks = super()._get_concurrent_tasks()
tasks[self.CONCURRENCY_GROUPS.DEFAULT].extend([
self.load_enterprise_course_enrollments,
])
return tasks

try:
# Load data specific to the dashboard route
self.load_enterprise_course_enrollments()
except Exception as e: # pylint: disable=broad-exception-caught
logger.exception(
"Error loading and/or processing dashboard data for user %s and enterprise customer %s",
self.context.lms_user_id,
self.context.enterprise_customer_uuid,
)
self.add_error(
user_message="Could not load and/or processing the learner dashboard.",
developer_message=f"Failed to load and/or processing the learner dashboard data: {e}",
)
def load_enterprise_course_enrollments(self):
"""
Temporary override to add delay.
"""
time.sleep(MOCK_TASK_DELAY)
# raise Exception('Failed to load enterprise course enrollments?!')
return super().load_enterprise_course_enrollments()


class SearchHandler(BaseLearnerPortalHandler):
Expand Down
Loading
Loading