Commit 9efa7770 by Clinton Blackburn Committed by Afzal Wali

Resolved multi-tenant program issues

A site must be passed so the system knows which URL to use to contact
the Discovery Service.
parent fd224672
......@@ -739,7 +739,7 @@ def dashboard(request):
# Find programs associated with course runs being displayed. This information
# is passed in the template context to allow rendering of program-related
# information on the dashboard.
meter = ProgramProgressMeter(user, enrollments=course_enrollments)
meter = ProgramProgressMeter(request.site, user, enrollments=course_enrollments)
inverted_programs = meter.invert_programs()
# Construct a dictionary of course mode information
......
......@@ -838,7 +838,7 @@ def program_marketing(request, program_uuid):
"""
Display the program marketing page.
"""
program_data = get_programs(uuid=program_uuid)
program_data = get_programs(request.site, uuid=program_uuid)
if not program_data:
raise Http404
......
......@@ -25,7 +25,7 @@ def program_listing(request):
if not programs_config.enabled:
raise Http404
meter = ProgramProgressMeter(request.user)
meter = ProgramProgressMeter(request.site, request.user)
context = {
'disable_courseware_js': True,
......@@ -48,7 +48,7 @@ def program_details(request, program_uuid):
if not programs_config.enabled:
raise Http404
meter = ProgramProgressMeter(request.user, uuid=program_uuid)
meter = ProgramProgressMeter(request.site, request.user, uuid=program_uuid)
program_data = meter.programs[0]
if not program_data:
......
"""Tests covering utilities for integrating with the catalog service."""
# pylint: disable=missing-docstring
import copy
import uuid
import ddt
import mock
from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.test import TestCase, override_settings
from student.tests.factories import UserFactory
from openedx.core.djangoapps.catalog.cache import PROGRAM_CACHE_KEY_TPL, PROGRAM_UUIDS_CACHE_KEY
from openedx.core.djangoapps.catalog.models import CatalogIntegration
......@@ -19,8 +19,8 @@ from openedx.core.djangoapps.catalog.utils import (
get_programs,
get_programs_with_type
)
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms
from student.tests.factories import UserFactory
UTILS_MODULE = 'openedx.core.djangoapps.catalog.utils'
User = get_user_model() # pylint: disable=invalid-name
......@@ -32,6 +32,10 @@ User = get_user_model() # pylint: disable=invalid-name
class TestGetPrograms(CacheIsolationTestCase):
ENABLED_CACHES = ['default']
def setUp(self):
super(TestGetPrograms, self).setUp()
self.site = SiteFactory()
def test_get_many(self, mock_warning, mock_info):
programs = ProgramFactory.create_batch(3)
......@@ -43,7 +47,7 @@ class TestGetPrograms(CacheIsolationTestCase):
# When called before UUIDs are cached, the function should return an
# empty list and log a warning.
self.assertEqual(get_programs(), [])
self.assertEqual(get_programs(self.site), [])
mock_warning.assert_called_once_with('Failed to get program UUIDs from the cache.')
mock_warning.reset_mock()
......@@ -54,7 +58,7 @@ class TestGetPrograms(CacheIsolationTestCase):
None
)
actual_programs = get_programs()
actual_programs = get_programs(self.site)
# The 2 cached programs should be returned while info and warning
# messages should be logged for the missing one.
......@@ -82,7 +86,7 @@ class TestGetPrograms(CacheIsolationTestCase):
}
cache.set_many(all_programs, None)
actual_programs = get_programs()
actual_programs = get_programs(self.site)
# All 3 programs should be returned.
self.assertEqual(
......@@ -116,7 +120,7 @@ class TestGetPrograms(CacheIsolationTestCase):
mock_cache.get.return_value = [program['uuid'] for program in programs]
mock_cache.get_many.side_effect = fake_get_many
actual_programs = get_programs()
actual_programs = get_programs(self.site)
# All 3 cached programs should be returned. An info message should be
# logged about the one that was initially missing, but the code should
......@@ -136,7 +140,7 @@ class TestGetPrograms(CacheIsolationTestCase):
expected_program = ProgramFactory()
expected_uuid = expected_program['uuid']
self.assertEqual(get_programs(uuid=expected_uuid), None)
self.assertEqual(get_programs(self.site, uuid=expected_uuid), None)
mock_warning.assert_called_once_with(
'Failed to get details for program {uuid} from the cache.'.format(uuid=expected_uuid)
)
......@@ -148,7 +152,7 @@ class TestGetPrograms(CacheIsolationTestCase):
None
)
actual_program = get_programs(uuid=expected_uuid)
actual_program = get_programs(self.site, uuid=expected_uuid)
self.assertEqual(actual_program, expected_program)
self.assertFalse(mock_warning.called)
......@@ -156,6 +160,9 @@ class TestGetPrograms(CacheIsolationTestCase):
@skip_unless_lms
@ddt.ddt
class TestGetProgramsWithType(TestCase):
def setUp(self):
super(TestGetProgramsWithType, self).setUp()
self.site = SiteFactory()
@mock.patch(UTILS_MODULE + '.get_programs')
@mock.patch(UTILS_MODULE + '.get_program_types')
......@@ -176,7 +183,7 @@ class TestGetProgramsWithType(TestCase):
mock_get_programs.return_value = programs
mock_get_program_types.return_value = program_types
actual = get_programs_with_type()
actual = get_programs_with_type(self.site)
self.assertEqual(actual, programs_with_program_type)
@ddt.data(False, True)
......@@ -202,7 +209,7 @@ class TestGetProgramsWithType(TestCase):
mock_get_programs.return_value = programs
mock_get_program_types.return_value = program_types
actual = get_programs_with_type(include_hidden=include_hidden)
actual = get_programs_with_type(self.site, include_hidden=include_hidden)
self.assertEqual(actual, programs_with_program_type)
......
......@@ -14,7 +14,6 @@ from openedx.core.djangoapps.catalog.cache import (
SITE_PROGRAM_UUIDS_CACHE_KEY_TPL
)
from openedx.core.djangoapps.catalog.models import CatalogIntegration
from openedx.core.djangoapps.theming.helpers import get_current_site
from openedx.core.lib.edx_api_utils import get_edx_api_data
from openedx.core.lib.token_utils import JwtBuilder
......@@ -35,11 +34,14 @@ def create_catalog_api_client(user, site=None):
return EdxRestApiClient(url, jwt=jwt)
def get_programs(uuid=None):
def get_programs(site, uuid=None):
"""Read programs from the cache.
The cache is populated by a management command, cache_programs.
Arguments:
site (Site): django.contrib.sites.models object
Keyword Arguments:
uuid (string): UUID identifying a specific program to read from the cache.
......@@ -56,7 +58,7 @@ def get_programs(uuid=None):
return program
if waffle.switch_is_active('get-multitenant-programs'):
uuids = cache.get(SITE_PROGRAM_UUIDS_CACHE_KEY_TPL.format(domain=get_current_site().domain), [])
uuids = cache.get(SITE_PROGRAM_UUIDS_CACHE_KEY_TPL.format(domain=site.domain), [])
else:
uuids = cache.get(PROGRAM_UUIDS_CACHE_KEY, [])
if not uuids:
......@@ -121,13 +123,16 @@ def get_program_types(name=None):
return []
def get_programs_with_type(include_hidden=True):
def get_programs_with_type(site, include_hidden=True):
"""
Return the list of programs. You can filter the types of programs returned by using the optional
include_hidden parameter. By default hidden programs will be included.
The program dict is updated with the fully serialized program type.
Arguments:
site (Site): django.contrib.sites.models object
Keyword Arguments:
include_hidden (bool): whether to include hidden programs
......@@ -135,7 +140,7 @@ def get_programs_with_type(include_hidden=True):
list of dict, representing the active programs.
"""
programs_with_type = []
programs = get_programs()
programs = get_programs(site)
if programs:
program_types = {program_type['name']: program_type for program_type in get_program_types()}
......
"""Management command for backpopulating missing program credentials."""
from collections import namedtuple
import logging
from collections import namedtuple
from django.contrib.auth.models import User
from django.contrib.sites.models import Site
from django.core.management import BaseCommand
from django.db.models import Q
from opaque_keys.edx.keys import CourseKey
from certificates.models import GeneratedCertificate, CertificateStatuses # pylint: disable=import-error
from certificates.models import CertificateStatuses, GeneratedCertificate # pylint: disable=import-error
from course_modes.models import CourseMode
from openedx.core.djangoapps.catalog.utils import get_programs
from openedx.core.djangoapps.programs.tasks.v1.tasks import award_program_certificates
# TODO: Log to console, even with debug mode disabled?
logger = logging.getLogger(__name__) # pylint: disable=invalid-name
CourseRun = namedtuple('CourseRun', ['key', 'type'])
......@@ -73,7 +72,11 @@ class Command(BaseCommand):
def _load_course_runs(self):
"""Find all course runs which are part of a program."""
programs = get_programs()
programs = []
for site in Site.objects.all():
logger.info('Loading programs from the catalog for site %s.', site.domain)
programs.extend(get_programs(site))
self.course_runs = self._flatten(programs)
def _flatten(self, programs):
......
......@@ -5,6 +5,7 @@ from celery import task
from celery.utils.log import get_task_logger # pylint: disable=no-name-in-module, import-error
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.sites.models import Site
from django.core.exceptions import ImproperlyConfigured
from edx_rest_api_client import exceptions
from edx_rest_api_client.client import EdxRestApiClient
......@@ -55,18 +56,19 @@ def get_api_client(api_config, student):
return EdxRestApiClient(api_config.internal_api_url, jwt=jwt)
def get_completed_programs(student):
def get_completed_programs(site, student):
"""
Given a set of completed courses, determine which programs are completed.
Args:
site (Site): Site for which data should be retrieved.
student (User): Representing the student whose completed programs to check for.
Returns:
list of program UUIDs
"""
meter = ProgramProgressMeter(student)
meter = ProgramProgressMeter(site, student)
return meter.completed_programs
......@@ -80,7 +82,7 @@ def get_certified_programs(student):
User object representing the student
Returns:
UUIDs of the programs for which the student has been awarded a certificate
str[]: UUIDs of the programs for which the student has been awarded a certificate
"""
certified_programs = []
......@@ -129,8 +131,7 @@ def award_program_certificates(self, username):
student.
Args:
username:
The username of the student
username (str): The username of the student
Returns:
None
......@@ -158,16 +159,16 @@ def award_program_certificates(self, username):
LOGGER.exception('Task award_program_certificates was called with invalid username %s', username)
# Don't retry for this case - just conclude the task.
return
program_uuids = get_completed_programs(student)
program_uuids = []
for site in Site.objects.all():
program_uuids.extend(get_completed_programs(site, student))
if not program_uuids:
# No reason to continue beyond this point unless/until this
# task gets updated to support revocation of program certs.
LOGGER.info('Task award_program_certificates was called for user %s with no completed programs', username)
return
# Determine which program certificates the user has already been
# awarded, if any.
# Determine which program certificates the user has already been awarded, if any.
existing_program_uuids = get_certified_programs(student)
except Exception as exc: # pylint: disable=broad-except
......
......@@ -16,6 +16,7 @@ from edx_rest_api_client.client import EdxRestApiClient
from openedx.core.djangoapps.catalog.tests.mixins import CatalogIntegrationMixin
from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfigMixin
from openedx.core.djangoapps.programs.tasks.v1 import tasks
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
from openedx.core.djangolib.testing.utils import skip_unless_lms
from student.tests.factories import UserFactory
......@@ -130,6 +131,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
super(AwardProgramCertificatesTestCase, self).setUp()
self.create_credentials_config()
self.student = UserFactory.create(username='test-student')
self.site = SiteFactory()
self.catalog_integration = self.create_catalog_integration()
ClientFactory.create(name='credentials')
......@@ -146,7 +148,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
programs.
"""
tasks.award_program_certificates.delay(self.student.username).get()
mock_get_completed_programs.assert_called_once_with(self.student)
mock_get_completed_programs.assert_called(self.site, self.student)
@ddt.data(
([1], [2, 3]),
......@@ -282,7 +284,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
"""
mock_get_completed_programs.side_effect = self._make_side_effect([Exception('boom'), None])
tasks.award_program_certificates.delay(self.student.username).get()
self.assertEqual(mock_get_completed_programs.call_count, 2)
self.assertEqual(mock_get_completed_programs.call_count, 3)
def test_retry_on_credentials_api_errors(
self,
......
......@@ -10,8 +10,8 @@ from student.tests.factories import UserFactory
from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED
from openedx.core.djangoapps.programs.signals import handle_course_cert_awarded
from openedx.core.djangolib.testing.utils import skip_unless_lms
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
from openedx.core.djangolib.testing.utils import skip_unless_lms, get_mock_request
TEST_USERNAME = 'test-user'
......
......@@ -33,6 +33,7 @@ from openedx.core.djangoapps.programs.utils import (
ProgramMarketingDataExtender,
get_certificates,
)
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
from openedx.core.djangolib.testing.utils import skip_unless_lms
from student.tests.factories import AnonymousUserFactory, UserFactory, CourseEnrollmentFactory
from util.date_utils import strftime_localized
......@@ -55,6 +56,7 @@ class TestProgramProgressMeter(TestCase):
super(TestProgramProgressMeter, self).setUp()
self.user = UserFactory()
self.site = SiteFactory()
def _create_enrollments(self, *course_run_ids):
"""Variadic helper used to create course run enrollments."""
......@@ -92,7 +94,7 @@ class TestProgramProgressMeter(TestCase):
data = [ProgramFactory()]
mock_get_programs.return_value = data
meter = ProgramProgressMeter(self.user)
meter = ProgramProgressMeter(self.site, self.user)
self.assertEqual(meter.engaged_programs, [])
self._assert_progress(meter)
......@@ -104,7 +106,7 @@ class TestProgramProgressMeter(TestCase):
course_run_id = generate_course_run_key()
self._create_enrollments(course_run_id)
meter = ProgramProgressMeter(self.user)
meter = ProgramProgressMeter(self.site, self.user)
self.assertEqual(meter.engaged_programs, [])
self._assert_progress(meter)
......@@ -129,7 +131,7 @@ class TestProgramProgressMeter(TestCase):
mock_get_programs.return_value = data
self._create_enrollments(course_run_key)
meter = ProgramProgressMeter(self.user)
meter = ProgramProgressMeter(self.site, self.user)
self._attach_detail_url(data)
program = data[0]
......@@ -159,7 +161,7 @@ class TestProgramProgressMeter(TestCase):
self._create_enrollments(course_run_key)
meter = ProgramProgressMeter(self.user)
meter = ProgramProgressMeter(self.site, self.user)
program = data[0]
expected = [
......@@ -195,7 +197,7 @@ class TestProgramProgressMeter(TestCase):
mode=CourseMode.NO_ID_PROFESSIONAL_MODE
)
meter = ProgramProgressMeter(self.user)
meter = ProgramProgressMeter(self.site, self.user)
program = data[0]
expected = [
......@@ -236,7 +238,7 @@ class TestProgramProgressMeter(TestCase):
CourseEnrollmentFactory(user=self.user, course_id=course_run_key, mode='audit')
meter = ProgramProgressMeter(self.user)
meter = ProgramProgressMeter(self.site, self.user)
program = data[0]
expected = [
......@@ -278,7 +280,7 @@ class TestProgramProgressMeter(TestCase):
# The creation time of the enrollments matters to the test. We want
# the first_course_run_key to represent the newest enrollment.
self._create_enrollments(older_course_run_key, newer_course_run_key)
meter = ProgramProgressMeter(self.user)
meter = ProgramProgressMeter(self.site, self.user)
self._attach_detail_url(data)
programs = data[:2]
......@@ -323,7 +325,7 @@ class TestProgramProgressMeter(TestCase):
# Enrollment for the shared course run created last (most recently).
self._create_enrollments(solo_course_run_key, shared_course_run_key)
meter = ProgramProgressMeter(self.user)
meter = ProgramProgressMeter(self.site, self.user)
self._attach_detail_url(data)
programs = data[:3]
......@@ -354,13 +356,13 @@ class TestProgramProgressMeter(TestCase):
mock_get_programs.return_value = data
# No enrollments, no programs in progress.
meter = ProgramProgressMeter(self.user)
meter = ProgramProgressMeter(self.site, self.user)
self._assert_progress(meter)
self.assertEqual(meter.completed_programs, [])
# One enrollment, one program in progress.
self._create_enrollments(first_course_run_key)
meter = ProgramProgressMeter(self.user)
meter = ProgramProgressMeter(self.site, self.user)
program, program_uuid = data[0], data[0]['uuid']
self._assert_progress(
meter,
......@@ -370,7 +372,7 @@ class TestProgramProgressMeter(TestCase):
# Two enrollments, all courses in progress.
self._create_enrollments(second_course_run_key)
meter = ProgramProgressMeter(self.user)
meter = ProgramProgressMeter(self.site, self.user)
self._assert_progress(
meter,
ProgressFactory(uuid=program_uuid, in_progress=2)
......@@ -381,7 +383,7 @@ class TestProgramProgressMeter(TestCase):
mock_completed_course_runs.return_value = [
{'course_run_id': first_course_run_key, 'type': MODES.verified},
]
meter = ProgramProgressMeter(self.user)
meter = ProgramProgressMeter(self.site, self.user)
self._assert_progress(
meter,
ProgressFactory(uuid=program_uuid, completed=1, in_progress=1)
......@@ -393,7 +395,7 @@ class TestProgramProgressMeter(TestCase):
{'course_run_id': first_course_run_key, 'type': MODES.verified},
{'course_run_id': second_course_run_key, 'type': MODES.honor},
]
meter = ProgramProgressMeter(self.user)
meter = ProgramProgressMeter(self.site, self.user)
self._assert_progress(
meter,
ProgressFactory(uuid=program_uuid, completed=1, in_progress=1)
......@@ -405,7 +407,7 @@ class TestProgramProgressMeter(TestCase):
{'course_run_id': first_course_run_key, 'type': MODES.verified},
{'course_run_id': second_course_run_key, 'type': MODES.verified},
]
meter = ProgramProgressMeter(self.user)
meter = ProgramProgressMeter(self.site, self.user)
self._assert_progress(
meter,
ProgressFactory(uuid=program_uuid, completed=2)
......@@ -436,7 +438,7 @@ class TestProgramProgressMeter(TestCase):
mock_completed_course_runs.return_value = [
{'course_run_id': course_run_key, 'type': MODES.honor},
]
meter = ProgramProgressMeter(self.user)
meter = ProgramProgressMeter(self.site, self.user)
program, program_uuid = data[0], data[0]['uuid']
self._assert_progress(
......@@ -449,7 +451,7 @@ class TestProgramProgressMeter(TestCase):
"""Verify that programs with no courses do not count as completed."""
program = ProgramFactory()
program['courses'] = []
meter = ProgramProgressMeter(self.user)
meter = ProgramProgressMeter(self.site, self.user)
program_complete = meter._is_program_complete(program)
self.assertFalse(program_complete)
......@@ -469,7 +471,7 @@ class TestProgramProgressMeter(TestCase):
course_run_keys.append(course_run['key'])
# Verify that no programs are complete.
meter = ProgramProgressMeter(self.user)
meter = ProgramProgressMeter(self.site, self.user)
self.assertEqual(meter.completed_programs, [])
# Complete all programs.
......@@ -480,7 +482,7 @@ class TestProgramProgressMeter(TestCase):
]
# Verify that all programs are complete.
meter = ProgramProgressMeter(self.user)
meter = ProgramProgressMeter(self.site, self.user)
self.assertEqual(meter.completed_programs, program_uuids)
@mock.patch(UTILS_MODULE + '.certificate_api.get_certificates_for_user')
......@@ -494,7 +496,7 @@ class TestProgramProgressMeter(TestCase):
self._make_certificate_result(status='unknown', course_key='unknown-course'),
]
meter = ProgramProgressMeter(self.user)
meter = ProgramProgressMeter(self.site, self.user)
self.assertEqual(
meter.completed_course_runs,
[
......@@ -517,7 +519,7 @@ class TestProgramProgressMeter(TestCase):
mock_get_programs.return_value = [program]
# Verify that the test program is not complete.
meter = ProgramProgressMeter(self.user)
meter = ProgramProgressMeter(self.site, self.user)
self.assertEqual(meter.completed_programs, [])
# Grant a 'no-id-professional' certificate for one of the course runs,
......@@ -527,7 +529,7 @@ class TestProgramProgressMeter(TestCase):
]
# Verify that the program is complete.
meter = ProgramProgressMeter(self.user)
meter = ProgramProgressMeter(self.site, self.user)
self.assertEqual(meter.completed_programs, [program['uuid']])
@mock.patch(UTILS_MODULE + '.ProgramProgressMeter.completed_course_runs', new_callable=mock.PropertyMock)
......@@ -543,7 +545,7 @@ class TestProgramProgressMeter(TestCase):
program = ProgramFactory(courses=[course])
mock_get_programs.return_value = [program]
self._create_enrollments(course_run_key)
meter = ProgramProgressMeter(self.user)
meter = ProgramProgressMeter(self.site, self.user)
mock_completed_course_runs.return_value = [{'course_run_id': course_run_key, 'type': 'verified'}]
self.assertEqual(meter._is_course_complete(course), True)
......
......@@ -70,7 +70,8 @@ class ProgramProgressMeter(object):
will only inspect this one program, not all programs the user may be
engaged with.
"""
def __init__(self, user, enrollments=None, uuid=None):
def __init__(self, site, user, enrollments=None, uuid=None):
self.site = site
self.user = user
self.enrollments = enrollments or list(CourseEnrollment.enrollments_for_user(self.user))
......@@ -89,9 +90,9 @@ class ProgramProgressMeter(object):
self.course_run_ids.append(enrollment_id)
if uuid:
self.programs = [get_programs(uuid=uuid)]
self.programs = [get_programs(self.site, uuid=uuid)]
else:
self.programs = attach_program_detail_url(get_programs())
self.programs = attach_program_detail_url(get_programs(self.site))
def invert_programs(self):
"""Intersect programs and enrollments.
......
......@@ -26,5 +26,7 @@ class SiteFactory(DjangoModelFactory):
model = Site
django_get_or_create = ('domain',)
# TODO These should be generated. Otherwise, code that creates multiple Site
# objects will only end up with a single Site since domain has a unique constraint.
domain = 'testserver.fake'
name = 'testserver.fake'
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment