models.py 29.8 KB
Newer Older
1 2 3
"""
Add and create new modes for running courses on this particular LMS
"""
4
from collections import defaultdict, namedtuple
5
from datetime import datetime, timedelta
6

7
import pytz
8
from config_models.models import ConfigurationModel
9
from django.conf import settings
10
from django.core.exceptions import ValidationError
11
from django.db import models
12
from django.db.models import Q
13
from django.dispatch import receiver
14
from django.utils.translation import ugettext_lazy as _
15
from django.utils.encoding import force_text
16
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
17

18
from opaque_keys.edx.keys import CourseKey
19 20
from openedx.core.djangoapps.xmodule_django.models import CourseKeyField
from request_cache.middleware import RequestCache, ns_request_cached
21

22 23 24 25 26 27 28 29
Mode = namedtuple('Mode',
                  [
                      'slug',
                      'name',
                      'min_price',
                      'suggested_prices',
                      'currency',
                      'expiration_datetime',
Diana Huang committed
30 31
                      'description',
                      'sku',
32
                      'bulk_sku',
33 34
                  ])

35 36 37 38 39 40

class CourseMode(models.Model):
    """
    We would like to offer a course in a variety of modes.

    """
41 42 43
    class Meta(object):
        app_label = "course_modes"

44 45 46 47 48 49
    course = models.ForeignKey(
        CourseOverview,
        db_constraint=False,
        db_index=True,
        related_name='modes',
    )
50 51 52 53 54 55 56 57 58 59 60 61 62

    # Django sets the `course_id` property in __init__ with the value from the database
    # This pair of properties converts that into a proper CourseKey
    @property
    def course_id(self):
        return self._course_id

    @course_id.setter
    def course_id(self, value):
        if isinstance(value, basestring):
            self._course_id = CourseKey.from_string(value)
        else:
            self._course_id = value
63 64 65

    # the reference to this mode that can be used by Enrollments to generate
    # similar behavior for the same slug across courses
66
    mode_slug = models.CharField(max_length=100, verbose_name=_("Mode"))
67 68

    # The 'pretty' name that can be translated and displayed
69
    mode_display_name = models.CharField(max_length=255, verbose_name=_("Display Name"))
70

71 72 73 74 75
    # The price in USD that we would like to charge for this mode of the course
    # Historical note: We used to allow users to choose from several prices, but later
    # switched to using a single price.  Although this field is called `min_price`, it is
    # really just the price of the course.
    min_price = models.IntegerField(default=0, verbose_name=_("Price"))
76

77 78 79
    # the currency these prices are in, using lower case ISO currency codes
    currency = models.CharField(default="usd", max_length=8)

80 81 82 83 84
    # The datetime at which the course mode will expire.
    # This is used to implement "upgrade" deadlines.
    # For example, if there is a verified mode that expires on 1/1/2015,
    # then users will be able to upgrade into the verified mode before that date.
    # Once the date passes, users will no longer be able to enroll as verified.
85
    _expiration_datetime = models.DateTimeField(
86 87 88 89 90 91
        default=None, null=True, blank=True,
        verbose_name=_(u"Upgrade Deadline"),
        help_text=_(
            u"OPTIONAL: After this date/time, users will no longer be able to enroll in this mode. "
            u"Leave this blank if users can enroll in this mode until enrollment closes for the course."
        ),
92
        db_column='expiration_datetime',
93 94
    )

95 96 97
    # The system prefers to set this automatically based on default settings. But
    # if the field is set manually we want a way to indicate that so we don't
    # overwrite the manual setting of the field.
98
    expiration_datetime_is_explicit = models.BooleanField(default=False)
99

100
    # DEPRECATED: the `expiration_date` field has been replaced by `expiration_datetime`
101
    expiration_date = models.DateField(default=None, null=True, blank=True)
102

103 104 105 106
    # DEPRECATED: the suggested prices for this mode
    # We used to allow users to choose from a set of prices, but we now allow only
    # a single price.  This field has been deprecated by `min_price`
    suggested_prices = models.CommaSeparatedIntegerField(max_length=255, blank=True, default='')
107

108 109 110 111
    # optional description override
    # WARNING: will not be localized
    description = models.TextField(null=True, blank=True)

112
    # Optional SKU for integration with the ecommerce service
Diana Huang committed
113 114 115 116
    sku = models.CharField(
        max_length=255,
        null=True,
        blank=True,
117
        verbose_name="SKU",
118 119 120 121
        help_text=_(
            u"OPTIONAL: This is the SKU (stock keeping unit) of this mode in the external ecommerce service.  "
            u"Leave this blank if the course has not yet been migrated to the ecommerce service."
        )
Diana Huang committed
122 123
    )

124 125 126 127 128
    # Optional bulk order SKU for integration with the ecommerce service
    bulk_sku = models.CharField(
        max_length=255,
        null=True,
        blank=True,
129
        default=None,  # Need this in order to set DEFAULT NULL on the database column
130 131 132 133 134 135
        verbose_name="Bulk SKU",
        help_text=_(
            u"This is the bulk SKU (stock keeping unit) of this mode in the external ecommerce service."
        )
    )

136 137 138 139 140
    HONOR = 'honor'
    PROFESSIONAL = 'professional'
    VERIFIED = "verified"
    AUDIT = "audit"
    NO_ID_PROFESSIONAL_MODE = "no-id-professional"
141
    CREDIT_MODE = "credit"
142

143 144 145 146 147 148 149 150 151 152 153 154
    DEFAULT_MODE = Mode(
        settings.COURSE_MODE_DEFAULTS['slug'],
        settings.COURSE_MODE_DEFAULTS['name'],
        settings.COURSE_MODE_DEFAULTS['min_price'],
        settings.COURSE_MODE_DEFAULTS['suggested_prices'],
        settings.COURSE_MODE_DEFAULTS['currency'],
        settings.COURSE_MODE_DEFAULTS['expiration_datetime'],
        settings.COURSE_MODE_DEFAULTS['description'],
        settings.COURSE_MODE_DEFAULTS['sku'],
        settings.COURSE_MODE_DEFAULTS['bulk_sku'],
    )
    DEFAULT_MODE_SLUG = settings.COURSE_MODE_DEFAULTS['slug']
155

156 157
    ALL_MODES = [AUDIT, CREDIT_MODE, HONOR, NO_ID_PROFESSIONAL_MODE, PROFESSIONAL, VERIFIED, ]

158 159 160
    # Modes utilized for audit/free enrollments
    AUDIT_MODES = [AUDIT, HONOR]

Will Daly committed
161
    # Modes that allow a student to pursue a verified certificate
162
    VERIFIED_MODES = [VERIFIED, PROFESSIONAL]
Will Daly committed
163

164 165 166
    # Modes that allow a student to pursue a non-verified certificate
    NON_VERIFIED_MODES = [HONOR, AUDIT, NO_ID_PROFESSIONAL_MODE]

167 168 169
    # Modes that allow a student to earn credit with a university partner
    CREDIT_MODES = [CREDIT_MODE]

170 171 172
    # Modes that are eligible to purchase credit
    CREDIT_ELIGIBLE_MODES = [VERIFIED, PROFESSIONAL, NO_ID_PROFESSIONAL_MODE]

173
    # Modes that are allowed to upsell
174
    UPSELL_TO_VERIFIED_MODES = [HONOR, AUDIT]
175

176 177 178 179 180
    # Courses purchased through the shoppingcart
    # should be "honor". Since we've changed the DEFAULT_MODE_SLUG from
    # "honor" to "audit", we still need to have the shoppingcart
    # use "honor"
    DEFAULT_SHOPPINGCART_MODE_SLUG = HONOR
181
    DEFAULT_SHOPPINGCART_MODE = Mode(HONOR, _('Honor'), 0, '', 'usd', None, None, None, None)
182

183 184
    CACHE_NAMESPACE = u"course_modes.CourseMode.cache."

185
    class Meta(object):
186
        unique_together = ('course', 'mode_slug', 'currency')
187

188 189 190 191 192 193 194 195 196
    def clean(self):
        """
        Object-level validation - implemented in this method so DRF serializers
        catch errors in advance of a save() attempt.
        """
        if self.is_professional_slug(self.mode_slug) and self.expiration_datetime is not None:
            raise ValidationError(
                _(u"Professional education modes are not allowed to have expiration_datetime set.")
            )
197 198
        if self.is_verified_slug(self.mode_slug) and self.min_price <= 0:
            raise ValidationError(_(u"Verified modes cannot be free."))
199

200 201
    def save(self, force_insert=False, force_update=False, using=None):
        # Ensure currency is always lowercase.
202
        self.clean()  # ensure object-level validation is performed before we save.
203 204 205
        self.currency = self.currency.lower()
        super(CourseMode, self).save(force_insert, force_update, using)

206 207 208 209 210 211 212 213 214 215
    @property
    def slug(self):
        """
        Returns mode_slug

        NOTE (CCB): This is a silly hack needed because all of the class methods use tuples
        with a property named slug instead of mode_slug.
        """
        return self.mode_slug

216 217 218 219 220 221 222 223
    @property
    def expiration_datetime(self):
        """ Return _expiration_datetime. """
        return self._expiration_datetime

    @expiration_datetime.setter
    def expiration_datetime(self, new_datetime):
        """ Saves datetime to _expiration_datetime and sets the explicit flag. """
224 225 226
        # Only set explicit flag if we are setting an actual date.
        if new_datetime is not None:
            self.expiration_datetime_is_explicit = True
227 228
        self._expiration_datetime = new_datetime

229
    @classmethod
230 231 232 233 234 235 236 237 238 239 240 241 242 243
    def all_modes_for_courses(cls, course_id_list):
        """Find all modes for a list of course IDs, including expired modes.

        Courses that do not have a course mode will be given a default mode.

        Arguments:
            course_id_list (list): List of `CourseKey`s

        Returns:
            dict mapping `CourseKey` to lists of `Mode`

        """
        modes_by_course = defaultdict(list)
        for mode in cls.objects.filter(course_id__in=course_id_list):
stephensanchez committed
244
            modes_by_course[mode.course_id].append(mode.to_tuple())
245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283

        # Assign default modes if nothing available in the database
        missing_courses = set(course_id_list) - set(modes_by_course.keys())
        for course_id in missing_courses:
            modes_by_course[course_id] = [cls.DEFAULT_MODE]

        return modes_by_course

    @classmethod
    def all_and_unexpired_modes_for_courses(cls, course_id_list):
        """Retrieve course modes for a list of courses.

        To reduce the number of database queries, this function
        loads *all* course modes, then creates a second list
        of unexpired course modes.

        Arguments:
            course_id_list (list of `CourseKey`): List of courses for which
                to retrieve course modes.

        Returns:
            Tuple of `(all_course_modes, unexpired_course_modes)`, where
            the first is a list of *all* `Mode`s (including expired ones),
            and the second is a list of only unexpired `Mode`s.

        """
        now = datetime.now(pytz.UTC)
        all_modes = cls.all_modes_for_courses(course_id_list)
        unexpired_modes = {
            course_id: [
                mode for mode in modes
                if mode.expiration_datetime is None or mode.expiration_datetime >= now
            ]
            for course_id, modes in all_modes.iteritems()
        }

        return (all_modes, unexpired_modes)

    @classmethod
stephensanchez committed
284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301
    def paid_modes_for_course(cls, course_id):
        """
        Returns a list of non-expired modes for a course ID that have a set minimum price.

        If no modes have been set, returns an empty list.

        Args:
            course_id (CourseKey): The course to find paid modes for.

        Returns:
            A list of CourseModes with a minimum price.

        """
        now = datetime.now(pytz.UTC)
        found_course_modes = cls.objects.filter(
            Q(course_id=course_id) &
            Q(min_price__gt=0) &
            (
302 303
                Q(_expiration_datetime__isnull=True) |
                Q(_expiration_datetime__gte=now)
stephensanchez committed
304 305 306 307 308
            )
        )
        return [mode.to_tuple() for mode in found_course_modes]

    @classmethod
309
    @ns_request_cached(CACHE_NAMESPACE)
310
    def modes_for_course(cls, course_id, include_expired=False, only_selectable=True):
311
        """
312
        Returns a list of the non-expired modes for a given course id
313 314

        If no modes have been set in the table, returns the default mode
315 316 317 318 319

        Arguments:
            course_id (CourseKey): Search for course modes for this course.

        Keyword Arguments:
320 321 322
            include_expired (bool): If True, expired course modes will be included
            in the returned JSON data. If False, these modes will be omitted.

323 324 325 326 327 328 329 330
            only_selectable (bool): If True, include only modes that are shown
                to users on the track selection page.  (Currently, "credit" modes
                aren't available to users until they complete the course, so
                they are hidden in track selection.)

        Returns:
            list of `Mode` tuples

331
        """
332
        now = datetime.now(pytz.UTC)
333 334 335 336 337 338

        found_course_modes = cls.objects.filter(course_id=course_id)

        # Filter out expired course modes if include_expired is not set
        if not include_expired:
            found_course_modes = found_course_modes.filter(
339
                Q(_expiration_datetime__isnull=True) | Q(_expiration_datetime__gte=now)
340
            )
341 342 343 344 345 346 347 348

        # Credit course modes are currently not shown on the track selection page;
        # they're available only when students complete a course.  For this reason,
        # we exclude them from the list if we're only looking for selectable modes
        # (e.g. on the track selection page or in the payment/verification flows).
        if only_selectable:
            found_course_modes = found_course_modes.exclude(mode_slug__in=cls.CREDIT_MODES)

stephensanchez committed
349
        modes = ([mode.to_tuple() for mode in found_course_modes])
350 351
        if not modes:
            modes = [cls.DEFAULT_MODE]
352

353
        return modes
354

355
    @classmethod
356
    def modes_for_course_dict(cls, course_id, modes=None, **kwargs):
357 358 359 360 361 362 363 364 365 366
        """Returns the non-expired modes for a particular course.

        Arguments:
            course_id (CourseKey): Search for course modes for this course.

        Keyword Arguments:
            modes (list of `Mode`): If provided, search through this list
                of course modes.  This can be used to avoid an additional
                database query if you have already loaded the modes list.

367 368 369
            include_expired (bool): If True, expired course modes will be included
                in the returned values. If False, these modes will be omitted.

370 371 372 373 374
            only_selectable (bool): If True, include only modes that are shown
                to users on the track selection page.  (Currently, "credit" modes
                aren't available to users until they complete the course, so
                they are hidden in track selection.)

375 376 377
        Returns:
            dict: Keys are mode slugs, values are lists of `Mode` namedtuples.

378
        """
379
        if modes is None:
380
            modes = cls.modes_for_course(course_id, **kwargs)
381

382
        return {mode.slug: mode for mode in modes}
383

384
    @classmethod
385
    def mode_for_course(cls, course_id, mode_slug, modes=None, include_expired=False):
386
        """Returns the mode for the course corresponding to mode_slug.
387

388 389
        Returns only non-expired modes.

390
        If this particular mode is not set for the course, returns None
391 392 393 394 395 396 397 398 399 400

        Arguments:
            course_id (CourseKey): Search for course modes for this course.
            mode_slug (str): Search for modes with this slug.

        Keyword Arguments:
            modes (list of `Mode`): If provided, search through this list
                of course modes.  This can be used to avoid an additional
                database query if you have already loaded the modes list.

401 402 403
            include_expired (bool): If True, expired course modes will be included
                in the returned values. If False, these modes will be omitted.

404 405 406
        Returns:
            Mode

407
        """
408
        if modes is None:
409
            modes = cls.modes_for_course(course_id, include_expired=include_expired)
410

411
        matched = [m for m in modes if m.slug == mode_slug]
412 413 414 415
        if matched:
            return matched[0]
        else:
            return None
416

417
    @classmethod
418 419 420 421
    def verified_mode_for_course(cls, course_id, modes=None):
        """Find a verified mode for a particular course.

        Since we have multiple modes that can go through the verify flow,
422 423 424 425
        we want to be able to select the 'correct' verified mode for a given course.

        Currently, we prefer to return the professional mode over the verified one
        if both exist for the given course.
426 427 428 429 430 431 432 433 434 435 436 437

        Arguments:
            course_id (CourseKey): Search for course modes for this course.

        Keyword Arguments:
            modes (list of `Mode`): If provided, search through this list
                of course modes.  This can be used to avoid an additional
                database query if you have already loaded the modes list.

        Returns:
            Mode or None

438
        """
439
        modes_dict = cls.modes_for_course_dict(course_id, modes=modes)
440 441 442 443 444 445
        verified_mode = modes_dict.get('verified', None)
        professional_mode = modes_dict.get('professional', None)
        # we prefer professional over verify
        return professional_mode if professional_mode else verified_mode

    @classmethod
446 447 448 449 450 451 452 453 454 455 456 457
    def min_course_price_for_verified_for_currency(cls, course_id, currency):  # pylint: disable=invalid-name
        """
        Returns the minimum price of the course int he appropriate currency over all the
        course's *verified*, non-expired modes.

        Assuming all verified courses have a minimum price of >0, this value should always
        be >0.

        If no verified mode is found, 0 is returned.
        """
        modes = cls.modes_for_course(course_id)
        for mode in modes:
458
            if (mode.currency.lower() == currency.lower()) and (mode.slug == 'verified'):
459 460 461 462
                return mode.min_price
        return 0

    @classmethod
Will Daly committed
463
    def has_verified_mode(cls, course_mode_dict):
464
        """Check whether the modes for a course allow a student to pursue a verified certificate.
Will Daly committed
465 466 467 468 469 470 471 472 473 474 475 476 477 478

        Args:
            course_mode_dict (dictionary mapping course mode slugs to Modes)

        Returns:
            bool: True iff the course modes contain a verified track.

        """
        for mode in cls.VERIFIED_MODES:
            if mode in course_mode_dict:
                return True
        return False

    @classmethod
479
    def has_professional_mode(cls, modes_dict):
Julia Hansbrough committed
480
        """
481
        check the course mode is profession or no-id-professional
Julia Hansbrough committed
482

483 484
        Args:
            modes_dict (dict): course modes.
Julia Hansbrough committed
485

486 487
        Returns:
            bool
Julia Hansbrough committed
488
        """
489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513
        return cls.PROFESSIONAL in modes_dict or cls.NO_ID_PROFESSIONAL_MODE in modes_dict

    @classmethod
    def is_professional_mode(cls, course_mode_tuple):
        """
        checking that tuple is professional mode.
        Args:
            course_mode_tuple (tuple) : course mode tuple

        Returns:
            bool
        """
        return course_mode_tuple.slug in [cls.PROFESSIONAL, cls.NO_ID_PROFESSIONAL_MODE] if course_mode_tuple else False

    @classmethod
    def is_professional_slug(cls, slug):
        """checking slug is professional
        Args:
            slug (str) : course mode string
        Return:
            bool
        """
        return slug in [cls.PROFESSIONAL, cls.NO_ID_PROFESSIONAL_MODE]

    @classmethod
514 515 516 517 518 519 520 521 522 523
    def is_mode_upgradeable(cls, mode_slug):
        """
        Returns True if the given mode can be upgraded to another.

        Note: Although, in practice, learners "upgrade" from verified to credit,
        that particular upgrade path is excluded by this method.
        """
        return mode_slug in cls.AUDIT_MODES

    @classmethod
524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547
    def is_verified_mode(cls, course_mode_tuple):
        """Check whether the given modes is_verified or not.

        Args:
            course_mode_tuple(Mode): Mode tuple

        Returns:
            bool: True iff the course modes is verified else False.

        """
        return course_mode_tuple.slug in cls.VERIFIED_MODES

    @classmethod
    def is_verified_slug(cls, mode_slug):
        """Check whether the given mode_slug is_verified or not.

        Args:
            mode_slug(str): Mode Slug

        Returns:
            bool: True iff the course mode slug is verified else False.

        """
        return mode_slug in cls.VERIFIED_MODES
Julia Hansbrough committed
548 549

    @classmethod
550 551 552 553 554 555 556 557 558 559 560 561
    def is_credit_eligible_slug(cls, mode_slug):
        """Check whether the given mode_slug is credit eligible or not.

        Args:
            mode_slug(str): Mode Slug

        Returns:
            bool: True iff the course mode slug is credit eligible else False.
        """
        return mode_slug in cls.CREDIT_ELIGIBLE_MODES

    @classmethod
562 563 564 565 566 567 568 569 570
    def is_credit_mode(cls, course_mode_tuple):
        """Check whether this is a credit mode.

        Students enrolled in a credit mode are eligible to
        receive university credit upon completion of a course.
        """
        return course_mode_tuple.slug in cls.CREDIT_MODES

    @classmethod
571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589
    def has_payment_options(cls, course_id):
        """Determines if there is any mode that has payment options

        Check the dict of course modes and see if any of them have a minimum price or
        suggested prices. Returns True if any course mode has a payment option.

        Args:
            course_mode_dict (dict): Dictionary mapping course mode slugs to Modes

        Returns:
            True if any course mode has a payment option.

        """
        for mode in cls.modes_for_course(course_id):
            if mode.min_price > 0 or mode.suggested_prices != '':
                return True
        return False

    @classmethod
590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613
    def can_auto_enroll(cls, course_id, modes_dict=None):
        """Check whether students should be auto-enrolled in the course.

        If a course is behind a paywall (e.g. professional ed or white-label),
        then users should NOT be auto-enrolled.  Instead, the user will
        be enrolled when he/she completes the payment flow.

        Otherwise, users can be enrolled in the default mode "honor"
        with the option to upgrade later.

        Args:
            course_id (CourseKey): The course to check.

        Keyword Args:
            modes_dict (dict): If provided, use these course modes.
                Useful for avoiding unnecessary database queries.

        Returns:
            bool

        """
        if modes_dict is None:
            modes_dict = cls.modes_for_course_dict(course_id)

614 615
        # Professional and no-id-professional mode courses are always behind a paywall
        if cls.has_professional_mode(modes_dict):
616 617 618 619 620 621 622
            return False

        # White-label uses course mode honor with a price
        # to indicate that the course is behind a paywall.
        if cls.is_white_label(course_id, modes_dict=modes_dict):
            return False

623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643
        # Check that a free mode is available.
        return cls.AUDIT in modes_dict or cls.HONOR in modes_dict

    @classmethod
    def auto_enroll_mode(cls, course_id, modes_dict=None):
        """
        return the auto-enrollable mode from given dict

        Args:
            modes_dict (dict): course modes.

        Returns:
            String: Mode name
        """
        if modes_dict is None:
            modes_dict = cls.modes_for_course_dict(course_id)

        if cls.HONOR in modes_dict:
            return cls.HONOR
        elif cls.AUDIT in modes_dict:
            return cls.AUDIT
644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667

    @classmethod
    def is_white_label(cls, course_id, modes_dict=None):
        """Check whether a course is a "white label" (paid) course.

        By convention, white label courses have a course mode slug "honor"
        and a price.

        Args:
            course_id (CourseKey): The course to check.

        Keyword Args:
            modes_dict (dict): If provided, use these course modes.
                Useful for avoiding unnecessary database queries.

        Returns:
            bool

        """
        if modes_dict is None:
            modes_dict = cls.modes_for_course_dict(course_id)

        # White-label uses course mode honor with a price
        # to indicate that the course is behind a paywall.
668
        if cls.HONOR in modes_dict and len(modes_dict) == 1:
669 670 671 672 673
            if modes_dict["honor"].min_price > 0 or modes_dict["honor"].suggested_prices != '':
                return True
        return False

    @classmethod
674
    def min_course_price_for_currency(cls, course_id, currency):
675
        """
676 677
        Returns the minimum price of the course in the appropriate currency over all the course's
        non-expired modes.
678 679 680
        If there is no mode found, will return the price of DEFAULT_MODE, which is 0
        """
        modes = cls.modes_for_course(course_id)
681
        return min(mode.min_price for mode in modes if mode.currency.lower() == currency.lower())
682

683 684 685 686 687 688 689 690 691 692 693 694
    @classmethod
    def is_eligible_for_certificate(cls, mode_slug):
        """
        Returns whether or not the given mode_slug is eligible for a
        certificate. Currently all modes other than 'audit' grant a
        certificate. Note that audit enrollments which existed prior
        to December 2015 *were* given certificates, so there will be
        GeneratedCertificate records with mode='audit' which are
        eligible.
        """
        return mode_slug != cls.AUDIT

stephensanchez committed
695 696 697 698 699 700 701 702 703 704 705 706 707 708 709
    def to_tuple(self):
        """
        Takes a mode model and turns it into a model named tuple.

        Returns:
            A 'Model' namedtuple with all the same attributes as the model.

        """
        return Mode(
            self.mode_slug,
            self.mode_display_name,
            self.min_price,
            self.suggested_prices,
            self.currency,
            self.expiration_datetime,
Diana Huang committed
710
            self.description,
711 712
            self.sku,
            self.bulk_sku
stephensanchez committed
713 714
        )

715
    def __unicode__(self):
716 717
        return u"{} : {}, min={}".format(
            self.course_id, self.mode_slug, self.min_price
718
        )
719 720


721 722 723 724 725 726 727
@receiver(models.signals.post_save, sender=CourseMode)
@receiver(models.signals.post_delete, sender=CourseMode)
def invalidate_course_mode_cache(sender, **kwargs):   # pylint: disable=unused-argument
    """Invalidate the cache of course modes. """
    RequestCache.clear_request_cache(name=CourseMode.CACHE_NAMESPACE)


728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767
def get_cosmetic_verified_display_price(course):
    """
    Returns the minimum verified cert course price as a string preceded by correct currency, or 'Free'.
    """
    return get_course_prices(course, verified_only=True)[1]


def get_cosmetic_display_price(course):
    """
    Returns the course price as a string preceded by correct currency, or 'Free'.
    """
    return get_course_prices(course)[1]


def get_course_prices(course, verified_only=False):
    """
    Return registration_price and cosmetic_display_prices.
    registration_price is the minimum price for the course across all course modes.
    cosmetic_display_prices is the course price as a string preceded by correct currency, or 'Free'.
    """
    # Find the
    if verified_only:
        registration_price = CourseMode.min_course_price_for_verified_for_currency(
            course.id,
            settings.PAID_COURSE_REGISTRATION_CURRENCY[0]
        )
    else:
        registration_price = CourseMode.min_course_price_for_currency(
            course.id,
            settings.PAID_COURSE_REGISTRATION_CURRENCY[0]
        )

    if registration_price > 0:
        price = registration_price
    # Handle course overview objects which have no cosmetic_display_price
    elif hasattr(course, 'cosmetic_display_price'):
        price = course.cosmetic_display_price
    else:
        price = None

768 769 770 771 772 773 774 775 776
    return registration_price, format_course_price(price)


def format_course_price(price):
    """
    Return a formatted price for a course (a string preceded by correct currency, or 'Free').
    """
    currency_symbol = settings.PAID_COURSE_REGISTRATION_CURRENCY[1]

777 778 779 780 781 782 783 784
    if price:
        # Translators: This will look like '$50', where {currency_symbol} is a symbol such as '$' and {price} is a
        # numerical amount in that currency. Adjust this display as needed for your language.
        cosmetic_display_price = _("{currency_symbol}{price}").format(currency_symbol=currency_symbol, price=price)
    else:
        # Translators: This refers to the cost of the course. In this case, the course costs nothing so it is free.
        cosmetic_display_price = _('Free')

785
    return cosmetic_display_price
786 787


788 789
class CourseModesArchive(models.Model):
    """
790 791 792 793
    Store the past values of course_mode that a course had in the past. We decided on having
    separate model, because there is a uniqueness contraint on (course_mode, course_id)
    field pair in CourseModes. Having a separate table allows us to have an audit trail of any changes
    such as course price changes
794
    """
795 796 797
    class Meta(object):
        app_label = "course_modes"

798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820
    # the course that this mode is attached to
    course_id = CourseKeyField(max_length=255, db_index=True)

    # the reference to this mode that can be used by Enrollments to generate
    # similar behavior for the same slug across courses
    mode_slug = models.CharField(max_length=100)

    # The 'pretty' name that can be translated and displayed
    mode_display_name = models.CharField(max_length=255)

    # minimum price in USD that we would like to charge for this mode of the course
    min_price = models.IntegerField(default=0)

    # the suggested prices for this mode
    suggested_prices = models.CommaSeparatedIntegerField(max_length=255, blank=True, default='')

    # the currency these prices are in, using lower case ISO currency codes
    currency = models.CharField(default="usd", max_length=8)

    # turn this mode off after the given expiration date
    expiration_date = models.DateField(default=None, null=True, blank=True)

    expiration_datetime = models.DateTimeField(default=None, null=True, blank=True)
821 822 823 824 825 826


class CourseModeExpirationConfig(ConfigurationModel):
    """
    Configuration for time period from end of course to auto-expire a course mode.
    """
827 828 829
    class Meta(object):
        app_label = "course_modes"

830 831 832 833 834 835 836 837 838 839
    verification_window = models.DurationField(
        default=timedelta(days=10),
        help_text=_(
            "The time period before a course ends in which a course mode will expire"
        )
    )

    def __unicode__(self):
        """ Returns the unicode date of the verification window. """
        return unicode(self.verification_window)