utils.py 28.8 KB
Newer Older
1
# -*- coding: utf-8 -*-
2
"""Helper functions for working with Programs."""
3
import datetime
4
import logging
5
from collections import defaultdict
6
from copy import deepcopy
7
from itertools import chain
8
from urlparse import urljoin
9

10
from dateutil.parser import parse
11
from django.conf import settings
12
from django.contrib.auth import get_user_model
13
from django.core.cache import cache
14
from django.core.urlresolvers import reverse
15
from django.utils.functional import cached_property
16
from edx_rest_api_client.exceptions import SlumberBaseException
17
from opaque_keys.edx.keys import CourseKey
18
from pytz import utc
19
from requests.exceptions import ConnectionError, Timeout
20

21
from course_modes.models import CourseMode
22
from lms.djangoapps.certificates import api as certificate_api
23
from lms.djangoapps.commerce.utils import EcommerceService
Hasnain committed
24
from lms.djangoapps.courseware.access import has_access
25 26
from lms.djangoapps.courseware.courses import get_course_with_access
from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory
27
from openedx.core.djangoapps.catalog.utils import get_programs
28
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client
29
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
30
from openedx.core.djangoapps.credentials.utils import get_credentials
31
from student.models import CourseEnrollment
32
from util.date_utils import strftime_localized
33
from xmodule.modulestore.django import modulestore
34

35
# The datetime module's strftime() methods require a year >= 1900.
36
DEFAULT_ENROLLMENT_START_DATE = datetime.datetime(1900, 1, 1, tzinfo=utc)
37

38 39
log = logging.getLogger(__name__)

40

41
def get_program_marketing_url(programs_config):
42
    """Build a URL used to link to programs on the marketing site."""
43 44 45 46 47 48 49 50 51 52 53 54 55 56 57
    return urljoin(settings.MKTG_URLS.get('ROOT'), programs_config.marketing_path).rstrip('/')


def attach_program_detail_url(programs):
    """Extend program representations by attaching a URL to be used when linking to program details.

    Facilitates the building of context to be passed to templates containing program data.

    Arguments:
        programs (list): Containing dicts representing programs.

    Returns:
        list, containing extended program dicts
    """
    for program in programs:
58
        program['detail_url'] = reverse('program_details_view', kwargs={'program_uuid': program['uuid']})
59 60

    return programs
61 62


63 64 65 66 67
class ProgramProgressMeter(object):
    """Utility for gauging a user's progress towards program completion.

    Arguments:
        user (User): The user for which to find programs.
68 69 70

    Keyword Arguments:
        enrollments (list): List of the user's enrollments.
71 72 73
        uuid (str): UUID identifying a specific program. If provided, the meter
            will only inspect this one program, not all programs the user may be
            engaged with.
74
    """
75 76
    def __init__(self, site, user, enrollments=None, uuid=None):
        self.site = site
77 78
        self.user = user

79 80
        self.enrollments = enrollments or list(CourseEnrollment.enrollments_for_user(self.user))
        self.enrollments.sort(key=lambda e: e.created, reverse=True)
81

82 83 84 85 86
        self.enrolled_run_modes = {}
        self.course_run_ids = []
        for enrollment in self.enrollments:
            # enrollment.course_id is really a CourseKey (╯ಠ_ಠ)╯︵ ┻━┻
            enrollment_id = unicode(enrollment.course_id)
87 88 89 90
            mode = enrollment.mode
            if mode == CourseMode.NO_ID_PROFESSIONAL_MODE:
                mode = CourseMode.PROFESSIONAL
            self.enrolled_run_modes[enrollment_id] = mode
91 92
            # We can't use dict.keys() for this because the course run ids need to be ordered
            self.course_run_ids.append(enrollment_id)
93

94 95
        self.course_grade_factory = CourseGradeFactory()

96
        if uuid:
97
            self.programs = [get_programs(self.site, uuid=uuid)]
98
        else:
99
            self.programs = attach_program_detail_url(get_programs(self.site))
100 101 102 103 104 105 106

    def invert_programs(self):
        """Intersect programs and enrollments.

        Builds a dictionary of program dict lists keyed by course run ID. The
        resulting dictionary is suitable in applications where programs must be
        filtered by the course runs they contain (e.g., the student dashboard).
107 108

        Returns:
109
            defaultdict, programs keyed by course run ID
110
        """
111 112 113 114 115 116 117 118 119 120
        inverted_programs = defaultdict(list)

        for program in self.programs:
            for course in program['courses']:
                for course_run in course['course_runs']:
                    course_run_id = course_run['key']
                    if course_run_id in self.course_run_ids:
                        program_list = inverted_programs[course_run_id]
                        if program not in program_list:
                            program_list.append(program)
121

122 123 124
        # Sort programs by title for consistent presentation.
        for program_list in inverted_programs.itervalues():
            program_list.sort(key=lambda p: p['title'])
125

126 127 128 129 130 131 132 133 134 135
        return inverted_programs

    @cached_property
    def engaged_programs(self):
        """Derive a list of programs in which the given user is engaged.

        Returns:
            list of program dicts, ordered by most recent enrollment
        """
        inverted_programs = self.invert_programs()
136

137
        programs = []
138 139 140 141 142 143 144 145
        # Remember that these course run ids are derived from a list of
        # enrollments sorted from most recent to least recent. Iterating
        # over the values in inverted_programs alone won't yield a program
        # ordering consistent with the user's enrollments.
        for course_run_id in self.course_run_ids:
            for program in inverted_programs[course_run_id]:
                # Dicts aren't a hashable type, so we can't use a set. Sets also
                # aren't ordered, which is important here.
146 147
                if program not in programs:
                    programs.append(program)
148

149
        return programs
150

151 152 153 154 155 156 157 158 159 160 161 162 163 164
    def _is_course_in_progress(self, now, course):
        """Check if course qualifies as in progress as part of the program.

        A course is considered to be in progress if a user is enrolled in a run
        of the correct mode or a run of the correct mode is still available for enrollment.

        Arguments:
            now (datetime): datetime for now
            course (dict): Containing nested course runs.

        Returns:
            bool, indicating whether the course is in progress.
        """
        enrolled_runs = [run for run in course['course_runs'] if run['key'] in self.course_run_ids]
165 166

        # Check if the user is enrolled in a required run and mode/seat.
167 168 169 170
        runs_with_required_mode = [
            run for run in enrolled_runs
            if run['type'] == self.enrolled_run_modes[run['key']]
        ]
171

172 173 174 175
        if runs_with_required_mode:
            not_failed_runs = [run for run in runs_with_required_mode if run not in self.failed_course_runs]
            if not_failed_runs:
                return True
176 177

        # Check if seats required for course completion are still available.
178 179 180 181 182 183
        upgrade_deadlines = []
        for run in enrolled_runs:
            for seat in run['seats']:
                if seat['type'] == run['type'] and run['type'] != self.enrolled_run_modes[run['key']]:
                    upgrade_deadlines.append(seat['upgrade_deadline'])

184 185
        # An upgrade deadline of None means the course is always upgradeable.
        return any(not deadline or deadline and parse(deadline) > now for deadline in upgrade_deadlines)
186

187
    def progress(self, programs=None, count_only=True):
188 189
        """Gauge a user's progress towards program completion.

190 191 192 193 194 195 196 197
        Keyword Arguments:
            programs (list): Specific list of programs to check the user's progress
                against. If left unspecified, self.engaged_programs will be used.

            count_only (bool): Whether or not to return counts of completed, in
                progress, and unstarted courses instead of serialized representations
                of the courses.

198 199 200 201
        Returns:
            list of dict, each containing information about a user's progress
                towards completing a program.
        """
202 203
        now = datetime.datetime.now(utc)

204
        progress = []
205 206
        programs = programs or self.engaged_programs
        for program in programs:
207
            program_copy = deepcopy(program)
208
            completed, in_progress, not_started = [], [], []
209

210
            for course in program_copy['courses']:
211
                if self._is_course_complete(course):
212
                    completed.append(course)
213 214 215 216 217 218 219
                elif self._is_course_enrolled(course):
                    course_in_progress = self._is_course_in_progress(now, course)
                    if course_in_progress:
                        in_progress.append(course)
                    else:
                        course['expired'] = not course_in_progress
                        not_started.append(course)
220
                else:
221
                    not_started.append(course)
222

223 224 225 226 227
            grades = {}
            for run in self.course_run_ids:
                grade = self.course_grade_factory.read(self.user, course_key=CourseKey.from_string(run))
                grades[run] = grade.percent

228
            progress.append({
229
                'uuid': program_copy['uuid'],
230 231 232
                'completed': len(completed) if count_only else completed,
                'in_progress': len(in_progress) if count_only else in_progress,
                'not_started': len(not_started) if count_only else not_started,
233
                'grades': grades,
234 235 236 237
            })

        return progress

238 239 240 241 242
    @property
    def completed_programs(self):
        """Identify programs completed by the student.

        Returns:
243
            list of UUIDs, each identifying a completed program.
244
        """
245
        return [program['uuid'] for program in self.programs if self._is_program_complete(program)]
246 247 248 249

    def _is_program_complete(self, program):
        """Check if a user has completed a program.

250
        A program is completed if the user has completed all nested courses.
251 252 253 254 255

        Arguments:
            program (dict): Representing the program whose completion to assess.

        Returns:
256
            bool, indicating whether the program is complete.
257
        """
258 259
        return all(self._is_course_complete(course) for course in program['courses']) \
            and len(program['courses']) > 0
260

261 262
    def _is_course_complete(self, course):
        """Check if a user has completed a course.
263

264 265
        A course is completed if the user has earned a certificate for any of
        the nested course runs.
266 267

        Arguments:
268
            course (dict): Containing nested course runs.
269 270

        Returns:
271
            bool, indicating whether the course is complete.
272 273
        """

274 275 276 277 278 279 280 281 282 283 284 285 286 287
        def reshape(course_run):
            """
            Modify the structure of a course run dict to facilitate comparison
            with course run certificates.
            """
            return {
                'course_run_id': course_run['key'],
                # A course run's type is assumed to indicate which mode must be
                # completed in order for the run to count towards program completion.
                # This supports the same flexible program construction allowed by the
                # old programs service (e.g., completion of an old honor-only run may
                # count towards completion of a course in a program). This may change
                # in the future to make use of the more rigid set of "applicable seat
                # types" associated with each program type in the catalog.
288 289 290 291 292 293

                # Runs of type 'credit' are counted as 'verified' since verified
                # certificates are earned when credit runs are completed. LEARNER-1274
                # tracks a cleaner way to do this using the discovery service's
                # applicable_seat_types field.
                'type': 'verified' if course_run['type'] == 'credit' else course_run['type'],
294 295 296
            }

        return any(reshape(course_run) in self.completed_course_runs for course_run in course['course_runs'])
297

298
    @cached_property
299 300 301
    def completed_course_runs(self):
        """
        Determine which course runs have been completed by the user.
302 303

        Returns:
304
            list of dicts, each representing a course run certificate
305
        """
306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325
        return self.course_runs_with_state['completed']

    @cached_property
    def failed_course_runs(self):
        """
        Determine which course runs have been failed by the user.

        Returns:
            list of dicts, each a course run ID
        """
        return [run['course_run_id'] for run in self.course_runs_with_state['failed']]

    @cached_property
    def course_runs_with_state(self):
        """
        Determine which course runs have been completed and failed by the user.

        Returns:
            dict with a list of completed and failed runs
        """
326
        course_run_certificates = certificate_api.get_certificates_for_user(self.user.username)
327

328 329
        completed_runs, failed_runs = [], []
        for certificate in course_run_certificates:
330 331 332 333 334 335
            certificate_type = certificate['type']

            # Treat "no-id-professional" certificates as "professional" certificates
            if certificate_type == CourseMode.NO_ID_PROFESSIONAL_MODE:
                certificate_type = CourseMode.PROFESSIONAL

336 337
            course_data = {
                'course_run_id': unicode(certificate['course_key']),
338
                'type': certificate_type,
339
            }
340

341 342 343 344
            if certificate_api.is_passing_status(certificate['status']):
                completed_runs.append(course_data)
            else:
                failed_runs.append(course_data)
345

346
        return {'completed': completed_runs, 'failed': failed_runs}
347

348 349
    def _is_course_enrolled(self, course):
        """Check if a user is enrolled in a course.
350

351
        A user is considered to be enrolled in a course if
352
        they're enrolled in any of the nested course runs.
353 354

        Arguments:
355
            course (dict): Containing nested course runs.
356 357

        Returns:
358
            bool, indicating whether the course is in progress.
359
        """
360
        return any(course_run['key'] in self.course_run_ids for course_run in course['course_runs'])
361 362


363 364
# pylint: disable=missing-docstring
class ProgramDataExtender(object):
365
    """
366 367
    Utility for extending program data meant for the program detail page with
    user-specific (e.g., CourseEnrollment) data.
368 369

    Arguments:
370
        program_data (dict): Representation of a program.
371 372
        user (User): The user whose enrollments to inspect.
    """
373 374 375
    def __init__(self, program_data, user):
        self.data = program_data
        self.user = user
376 377

        self.course_run_key = None
378 379 380
        self.course_overview = None
        self.enrollment_start = None

Hasnain committed
381
    def extend(self):
382
        """Execute extension handlers, returning the extended data."""
Hasnain committed
383
        self._execute('_extend')
384
        self._collect_one_click_purchase_eligibility_data()
385 386 387 388 389 390 391 392 393 394 395
        return self.data

    def _execute(self, prefix, *args):
        """Call handlers whose name begins with the given prefix with the given arguments."""
        [getattr(self, handler)(*args) for handler in self._handlers(prefix)]  # pylint: disable=expression-not-assigned

    @classmethod
    def _handlers(cls, prefix):
        """Returns a generator yielding method names beginning with the given prefix."""
        return (name for name in cls.__dict__ if name.startswith(prefix))

396 397 398 399
    def _extend_course_runs(self):
        """Execute course run data handlers."""
        for course in self.data['courses']:
            for course_run in course['course_runs']:
400
                # State to be shared across handlers.
401 402
                self.course_run_key = CourseKey.from_string(course_run['key'])
                self.course_overview = CourseOverview.get_from_id(self.course_run_key)
403 404
                self.enrollment_start = self.course_overview.enrollment_start or DEFAULT_ENROLLMENT_START_DATE

405
                self._execute('_attach_course_run', course_run)
406

407 408
    def _attach_course_run_certificate_url(self, run_mode):
        certificate_data = certificate_api.certificate_downloadable_status(self.user, self.course_run_key)
409 410
        certificate_uuid = certificate_data.get('uuid')
        run_mode['certificate_url'] = certificate_api.get_certificate_url(
411 412
            user_id=self.user.id,  # Providing user_id allows us to fall back to PDF certificates
                                   # if web certificates are not configured for a given course.
413
            course_id=self.course_run_key,
414 415 416
            uuid=certificate_uuid,
        ) if certificate_uuid else None

417 418
    def _attach_course_run_course_url(self, run_mode):
        run_mode['course_url'] = reverse('course_root', args=[self.course_run_key])
419

420
    def _attach_course_run_enrollment_open_date(self, run_mode):
421
        run_mode['enrollment_open_date'] = strftime_localized(self.enrollment_start, 'SHORT_DATE')
422

423
    def _attach_course_run_is_course_ended(self, run_mode):
424 425
        end_date = self.course_overview.end or datetime.datetime.max.replace(tzinfo=utc)
        run_mode['is_course_ended'] = end_date < datetime.datetime.now(utc)
426

427 428
    def _attach_course_run_is_enrolled(self, run_mode):
        run_mode['is_enrolled'] = CourseEnrollment.is_enrolled(self.user, self.course_run_key)
429

430
    def _attach_course_run_is_enrollment_open(self, run_mode):
431 432
        enrollment_end = self.course_overview.enrollment_end or datetime.datetime.max.replace(tzinfo=utc)
        run_mode['is_enrollment_open'] = self.enrollment_start <= datetime.datetime.now(utc) < enrollment_end
433

434 435 436 437 438 439 440
    def _attach_course_run_advertised_start(self, run_mode):
        """
        The advertised_start is text a course author can provide to be displayed
        instead of their course's start date. For example, if a course run were
        to start on December 1, 2016, the author might provide 'Winter 2016' as
        the advertised start.
        """
441
        run_mode['advertised_start'] = self.course_overview.advertised_start
442

443 444 445
    def _attach_course_run_upgrade_url(self, run_mode):
        required_mode_slug = run_mode['type']
        enrolled_mode_slug, _ = CourseEnrollment.enrollment_mode_for_user(self.user, self.course_run_key)
446
        is_mode_mismatch = required_mode_slug != enrolled_mode_slug
447
        is_upgrade_required = is_mode_mismatch and CourseEnrollment.is_enrolled(self.user, self.course_run_key)
448

449
        if is_upgrade_required:
450
            # Requires that the ecommerce service be in use.
451
            required_mode = CourseMode.mode_for_course(self.course_run_key, required_mode_slug)
452 453
            ecommerce = EcommerceService()
            sku = getattr(required_mode, 'sku', None)
454
            if ecommerce.is_enabled(self.user) and sku:
455
                run_mode['upgrade_url'] = ecommerce.get_checkout_page_url(required_mode.sku)
456
            else:
457 458 459
                run_mode['upgrade_url'] = None
        else:
            run_mode['upgrade_url'] = None
460

461 462 463
    def _attach_course_run_may_certify(self, run_mode):
        run_mode['may_certify'] = self.course_overview.may_certify()

Albert St. Aubin committed
464 465 466 467 468 469 470 471 472
    def _check_enrollment_for_user(self, course_run):
        applicable_seat_types = self.data['applicable_seat_types']

        (enrollment_mode, active) = CourseEnrollment.enrollment_mode_for_user(
            self.user,
            CourseKey.from_string(course_run['key'])
        )

        is_paid_seat = False
473
        if enrollment_mode is not None and active is not None and active is True:
Albert St. Aubin committed
474 475 476 477 478 479
            # Check all the applicable seat types
            # this will also check for no-id-professional as professional
            is_paid_seat = any(seat_type in enrollment_mode for seat_type in applicable_seat_types)

        return is_paid_seat

480 481 482 483 484 485 486 487 488 489 490
    def _collect_one_click_purchase_eligibility_data(self):
        """
        Extend the program data with data about learner's eligibility for one click purchase,
        discount data of the program and SKUs of seats that should be added to basket.
        """
        applicable_seat_types = self.data['applicable_seat_types']
        is_learner_eligible_for_one_click_purchase = self.data['is_program_eligible_for_one_click_purchase']
        skus = []
        bundle_variant = 'full'
        if is_learner_eligible_for_one_click_purchase:
            for course in self.data['courses']:
491 492 493 494
                add_course_sku = True
                course_runs = course.get('course_runs', [])
                published_course_runs = filter(lambda run: run['status'] == 'published', course_runs)

495
                if len(published_course_runs) == 1:
496
                    for course_run in course_runs:
Albert St. Aubin committed
497
                        is_paid_seat = self._check_enrollment_for_user(course_run)
498

Albert St. Aubin committed
499
                        if is_paid_seat:
500 501
                            add_course_sku = False
                            break
502

503
                    if add_course_sku:
504 505 506 507
                        for seat in published_course_runs[0]['seats']:
                            if seat['type'] in applicable_seat_types and seat['sku']:
                                skus.append(seat['sku'])
                    else:
508
                        bundle_variant = 'partial'
509
                else:
510 511 512 513 514
                    # If a course in the program has more than 1 published course run
                    # learner won't be eligible for a one click purchase.
                    is_learner_eligible_for_one_click_purchase = False
                    skus = []
                    break
515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551

        if skus:
            try:
                api_user = self.user
                if not self.user.is_authenticated():
                    user = get_user_model()
                    service_user = user.objects.get(username=settings.ECOMMERCE_SERVICE_WORKER_USERNAME)
                    api_user = service_user

                api = ecommerce_api_client(api_user)

                # Make an API call to calculate the discounted price
                discount_data = api.baskets.calculate.get(sku=skus)

                program_discounted_price = discount_data['total_incl_tax']
                program_full_price = discount_data['total_incl_tax_excl_discounts']
                discount_data['is_discounted'] = program_discounted_price < program_full_price
                discount_data['discount_value'] = program_full_price - program_discounted_price

                self.data.update({
                    'discount_data': discount_data,
                    'full_program_price': discount_data['total_incl_tax'],
                    'variant': bundle_variant
                })
            except (ConnectionError, SlumberBaseException, Timeout):
                log.exception('Failed to get discount price for following product SKUs: %s ', ', '.join(skus))
                self.data.update({
                    'discount_data': {'is_discounted': False}
                })
        else:
            is_learner_eligible_for_one_click_purchase = False

        self.data.update({
            'is_learner_eligible_for_one_click_purchase': is_learner_eligible_for_one_click_purchase,
            'skus': skus,
        })

Hasnain committed
552

553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571
def get_certificates(user, extended_program):
    """
    Find certificates a user has earned related to a given program.

    Arguments:
        user (User): The user whose enrollments to inspect.
        extended_program (dict): The program for which to locate certificates.
            This is expected to be an "extended" program whose course runs already
            have certificate URLs attached.

    Returns:
        list: Contains dicts representing course run and program certificates the
            given user has earned which are associated with the given program.
    """
    certificates = []

    for course in extended_program['courses']:
        for course_run in course['course_runs']:
            url = course_run.get('certificate_url')
572
            if url and course_run.get('may_certify'):
573 574 575 576 577 578 579 580 581
                certificates.append({
                    'type': 'course',
                    'title': course_run['title'],
                    'url': url,
                })

                # We only want one certificate per course to be returned.
                break

582
    program_credentials = get_credentials(user, program_uuid=extended_program['uuid'])
583 584
    # only include a program certificate if a certificate is available for every course
    if program_credentials and (len(certificates) == len(extended_program['courses'])):
585 586 587 588 589
        certificates.append({
            'type': 'program',
            'title': extended_program['title'],
            'url': program_credentials[0]['certificate_url'],
        })
590 591 592 593

    return certificates


Hasnain committed
594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611
# pylint: disable=missing-docstring
class ProgramMarketingDataExtender(ProgramDataExtender):
    """
    Utility for extending program data meant for the program marketing page which lives in the
    edx-platform git repository with user-specific (e.g., CourseEnrollment) data, pricing data,
    and program instructor data.

    Arguments:
        program_data (dict): Representation of a program.
        user (User): The user whose enrollments to inspect.
    """
    def __init__(self, program_data, user):
        super(ProgramMarketingDataExtender, self).__init__(program_data, user)

        # Aggregate dict of instructors for the program keyed by name
        self.instructors = {}

        # Values for programs' price calculation.
612
        self.data['avg_price_per_course'] = 0.0
Hasnain committed
613
        self.data['number_of_courses'] = 0
614
        self.data['full_program_price'] = 0.0
Hasnain committed
615 616 617

    def _extend_program(self):
        """Aggregates data from the program data structure."""
618 619 620 621
        cache_key = 'program.instructors.{uuid}'.format(
            uuid=self.data['uuid']
        )
        program_instructors = cache.get(cache_key)
Hasnain committed
622 623 624 625

        for course in self.data['courses']:
            self._execute('_collect_course', course)
            if not program_instructors:
626
                for course_run in course['course_runs']:
Hasnain committed
627 628 629 630 631
                    self._execute('_collect_instructors', course_run)

        if not program_instructors:
            # We cache the program instructors list to avoid repeated modulestore queries
            program_instructors = self.instructors.values()
632 633
            cache.set(cache_key, program_instructors, 3600)

634 635 636 637 638 639
        self.data['instructors'] = program_instructors

    def extend(self):
        """Execute extension handlers, returning the extended data."""
        self.data.update(super(ProgramMarketingDataExtender, self).extend())
        return self.data
Hasnain committed
640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691

    @classmethod
    def _handlers(cls, prefix):
        """Returns a generator yielding method names beginning with the given prefix."""
        # We use a set comprehension here to deduplicate the list of
        # function names given the fact that the subclass overrides
        # some functions on the parent class.
        return {name for name in chain(cls.__dict__, ProgramDataExtender.__dict__) if name.startswith(prefix)}

    def _attach_course_run_can_enroll(self, run_mode):
        run_mode['can_enroll'] = bool(has_access(self.user, 'enroll', self.course_overview))

    def _attach_course_run_certificate_url(self, run_mode):
        """
        We override this function here and stub it out because
        the superclass (ProgramDataExtender) requires a non-anonymous
        User which we may or may not have when rendering marketing
        pages. The certificate URL is not needed when rendering
        the program marketing page.
        """
        pass

    def _attach_course_run_upgrade_url(self, run_mode):
        if not self.user.is_anonymous():
            super(ProgramMarketingDataExtender, self)._attach_course_run_upgrade_url(run_mode)
        else:
            run_mode['upgrade_url'] = None

    def _collect_course_pricing(self, course):
        self.data['number_of_courses'] += 1
        course_runs = course['course_runs']
        if course_runs:
            seats = course_runs[0]['seats']
            if seats:
                self.data['full_program_price'] += float(seats[0]['price'])
            self.data['avg_price_per_course'] = self.data['full_program_price'] / self.data['number_of_courses']

    def _collect_instructors(self, course_run):
        """
        Extend the program data with instructor data. The instructor data added here is persisted
        on each course in modulestore and can be edited in Studio. Once the course metadata publisher tool
        supports the authoring of course instructor data, we will be able to migrate course
        instructor data into the catalog, retrieve it via the catalog API, and remove this code.
        """
        module_store = modulestore()
        course_run_key = CourseKey.from_string(course_run['key'])
        course_descriptor = module_store.get_course(course_run_key)
        if course_descriptor:
            course_instructors = getattr(course_descriptor, 'instructor_info', {})

            # Deduplicate program instructors using instructor name
            self.instructors.update(
692
                {instructor.get('name', '').strip(): instructor for instructor in course_instructors.get('instructors', [])}
Hasnain committed
693
            )