[MICROBA-1307] Before this change a user would not be auto refunded if they had a certificate in a course with any status. This had unintended consequences. This change updates the logic to only block auto refund for statuses that we do not want to refund on such as downloadable.
535 lines
21 KiB
Python
535 lines
21 KiB
Python
"""Entitlement Models"""
|
|
|
|
|
|
import logging
|
|
import uuid as uuid_tools
|
|
from datetime import timedelta
|
|
|
|
from django.conf import settings
|
|
from django.contrib.sites.models import Site
|
|
from django.db import IntegrityError, models, transaction
|
|
from django.utils.encoding import python_2_unicode_compatible
|
|
from django.utils.timezone import now
|
|
from model_utils import Choices
|
|
from model_utils.models import TimeStampedModel
|
|
from simple_history.models import HistoricalRecords
|
|
|
|
from common.djangoapps.course_modes.models import CourseMode
|
|
from common.djangoapps.entitlements.utils import is_course_run_entitlement_fulfillable
|
|
from common.djangoapps.student.models import CourseEnrollment, CourseEnrollmentException
|
|
from common.djangoapps.util.date_utils import strftime_localized
|
|
from lms.djangoapps.certificates import api as certificates_api
|
|
from lms.djangoapps.certificates.data import CertificateStatuses
|
|
from lms.djangoapps.commerce.utils import refund_entitlement
|
|
from openedx.core.djangoapps.catalog.utils import get_course_uuid_for_course
|
|
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
|
|
|
log = logging.getLogger("common.entitlements.models")
|
|
|
|
|
|
@python_2_unicode_compatible
|
|
class CourseEntitlementPolicy(models.Model):
|
|
"""
|
|
Represents the Entitlement's policy for expiration, refunds, and regaining a used certificate
|
|
|
|
.. no_pii:
|
|
"""
|
|
|
|
DEFAULT_EXPIRATION_PERIOD_DAYS = 730
|
|
DEFAULT_REFUND_PERIOD_DAYS = 60
|
|
DEFAULT_REGAIN_PERIOD_DAYS = 14
|
|
MODES = Choices((None, '---------'), CourseMode.VERIFIED, CourseMode.PROFESSIONAL)
|
|
|
|
# Use a DurationField to calculate time as it returns a timedelta, useful in performing operations with datetimes
|
|
expiration_period = models.DurationField(
|
|
default=timedelta(days=DEFAULT_EXPIRATION_PERIOD_DAYS),
|
|
help_text="Duration in days from when an entitlement is created until when it is expired.",
|
|
null=False
|
|
)
|
|
refund_period = models.DurationField(
|
|
default=timedelta(days=DEFAULT_REFUND_PERIOD_DAYS),
|
|
help_text="Duration in days from when an entitlement is created until when it is no longer refundable",
|
|
null=False
|
|
)
|
|
regain_period = models.DurationField(
|
|
default=timedelta(days=DEFAULT_REGAIN_PERIOD_DAYS),
|
|
help_text=("Duration in days from when an entitlement is redeemed for a course run until "
|
|
"it is no longer able to be regained by a user."),
|
|
null=False
|
|
)
|
|
site = models.ForeignKey(Site, null=True, on_delete=models.CASCADE)
|
|
mode = models.CharField(max_length=32, choices=MODES, null=True)
|
|
|
|
def get_days_until_expiration(self, entitlement):
|
|
"""
|
|
Returns an integer of number of days until the entitlement expires.
|
|
Includes the logic for regaining an entitlement.
|
|
"""
|
|
now_timestamp = now()
|
|
expiry_date = entitlement.created + self.expiration_period
|
|
days_until_expiry = (expiry_date - now_timestamp).days
|
|
if not entitlement.enrollment_course_run:
|
|
return days_until_expiry
|
|
course_overview = CourseOverview.get_from_id(entitlement.enrollment_course_run.course_id)
|
|
# Compute the days left for the regain
|
|
days_since_course_start = (now_timestamp - course_overview.start).days
|
|
days_since_enrollment = (now_timestamp - entitlement.enrollment_course_run.created).days
|
|
days_since_entitlement_created = (now_timestamp - entitlement.created).days
|
|
|
|
# We want to return whichever days value is less since it is then the more recent one
|
|
days_until_regain_ends = (self.regain_period.days -
|
|
min(days_since_course_start, days_since_enrollment, days_since_entitlement_created))
|
|
|
|
# If the base days until expiration is less than the days until the regain period ends, use that instead
|
|
if days_until_expiry < days_until_regain_ends:
|
|
return days_until_expiry
|
|
|
|
return days_until_regain_ends
|
|
|
|
def is_entitlement_regainable(self, entitlement):
|
|
"""
|
|
Determines from the policy if an entitlement can still be regained by the user, if they choose
|
|
to by leaving and regaining their entitlement within policy.regain_period days from start date of
|
|
the course or their redemption, whichever comes later, and the expiration period hasn't passed yet
|
|
"""
|
|
if entitlement.expired_at:
|
|
return False
|
|
|
|
if entitlement.enrollment_course_run:
|
|
certificate = certificates_api.get_certificate_for_user_id(
|
|
entitlement.user,
|
|
entitlement.enrollment_course_run.course_id
|
|
)
|
|
if certificate and not CertificateStatuses.is_refundable_status(certificate.status):
|
|
return False
|
|
|
|
# This is >= because a days_until_expiration 0 means that the expiration day has not fully passed yet
|
|
# and that the entitlement should not be expired as there is still time
|
|
return self.get_days_until_expiration(entitlement) >= 0
|
|
return False
|
|
|
|
def is_entitlement_refundable(self, entitlement):
|
|
"""
|
|
Determines from the policy if an entitlement can still be refunded, if the entitlement has not
|
|
yet been redeemed (enrollment_course_run is NULL) and policy.refund_period has not yet passed, or if
|
|
the entitlement has been redeemed, but the regain period hasn't passed yet.
|
|
"""
|
|
# If the Entitlement is expired already it is not refundable
|
|
if entitlement.expired_at:
|
|
return False
|
|
|
|
# If there's no order number, it cannot be refunded
|
|
if entitlement.order_number is None:
|
|
return False
|
|
|
|
# This is > because a get_days_since_created of refund_period means that that many days have passed,
|
|
# which should then make the entitlement no longer refundable
|
|
if entitlement.get_days_since_created() > self.refund_period.days:
|
|
return False
|
|
|
|
if entitlement.enrollment_course_run:
|
|
return self.is_entitlement_regainable(entitlement)
|
|
|
|
return True
|
|
|
|
def is_entitlement_redeemable(self, entitlement):
|
|
"""
|
|
Determines from the policy if an entitlement can be redeemed, if it has not passed the
|
|
expiration period of policy.expiration_period, and has not already been redeemed
|
|
"""
|
|
# This is < because a get_days_since_created of expiration_period means that that many days have passed,
|
|
# which should then expire the entitlement
|
|
return (entitlement.get_days_since_created() < self.expiration_period.days
|
|
and not entitlement.enrollment_course_run
|
|
and not entitlement.expired_at)
|
|
|
|
def __str__(self):
|
|
return 'Course Entitlement Policy: expiration_period: {}, refund_period: {}, regain_period: {}, mode: {}'\
|
|
.format(
|
|
self.expiration_period,
|
|
self.refund_period,
|
|
self.regain_period,
|
|
self.mode
|
|
)
|
|
|
|
|
|
class CourseEntitlement(TimeStampedModel):
|
|
"""
|
|
Represents a Student's Entitlement to a Course Run for a given Course.
|
|
|
|
.. no_pii:
|
|
"""
|
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
|
uuid = models.UUIDField(default=uuid_tools.uuid4, editable=False, unique=True)
|
|
course_uuid = models.UUIDField(help_text='UUID for the Course, not the Course Run')
|
|
expired_at = models.DateTimeField(
|
|
null=True,
|
|
help_text='The date that an entitlement expired, if NULL the entitlement has not expired.',
|
|
blank=True
|
|
)
|
|
mode = models.CharField(max_length=100, help_text='The mode of the Course that will be applied on enroll.')
|
|
enrollment_course_run = models.ForeignKey(
|
|
'student.CourseEnrollment',
|
|
null=True,
|
|
help_text='The current Course enrollment for this entitlement. If NULL the Learner has not enrolled.',
|
|
blank=True,
|
|
on_delete=models.CASCADE,
|
|
)
|
|
order_number = models.CharField(max_length=128, default=None, null=True)
|
|
refund_locked = models.BooleanField(default=False)
|
|
_policy = models.ForeignKey(CourseEntitlementPolicy, null=True, blank=True, on_delete=models.CASCADE)
|
|
|
|
history = HistoricalRecords()
|
|
|
|
class Meta:
|
|
unique_together = ('course_uuid', 'order_number')
|
|
|
|
@property
|
|
def expired_at_datetime(self):
|
|
"""
|
|
Getter to be used instead of expired_at because of the conditional check and update
|
|
"""
|
|
self.update_expired_at()
|
|
return self.expired_at
|
|
|
|
@expired_at_datetime.setter
|
|
def expired_at_datetime(self, value):
|
|
"""
|
|
Setter to be used instead for expired_at for consistency
|
|
"""
|
|
self.expired_at = value
|
|
|
|
@property
|
|
def policy(self):
|
|
"""
|
|
Getter to be used instead of _policy because of the null object pattern
|
|
"""
|
|
return self._policy or CourseEntitlementPolicy()
|
|
|
|
@policy.setter
|
|
def policy(self, value):
|
|
"""
|
|
Setter to be used instead of _policy because of the null object pattern
|
|
"""
|
|
self._policy = value
|
|
|
|
def get_days_since_created(self):
|
|
"""
|
|
Returns an integer of number of days since the entitlement has been created
|
|
"""
|
|
return (now() - self.created).days
|
|
|
|
def update_expired_at(self):
|
|
"""
|
|
Updates the expired_at attribute if it is not set AND it is expired according to the entitlement's policy,
|
|
OR if the policy can no longer be regained AND the policy has been redeemed
|
|
"""
|
|
if not self.expired_at:
|
|
if (self.policy.get_days_until_expiration(self) < 0 or
|
|
(self.enrollment_course_run and not self.is_entitlement_regainable())):
|
|
self.expire_entitlement()
|
|
|
|
def get_days_until_expiration(self):
|
|
"""
|
|
Returns an integer of number of days until the entitlement expires based on the entitlement's policy
|
|
"""
|
|
return self.policy.get_days_until_expiration(self)
|
|
|
|
def is_entitlement_regainable(self):
|
|
"""
|
|
Returns a boolean as to whether or not the entitlement can be regained based on the entitlement's policy
|
|
"""
|
|
return self.policy.is_entitlement_regainable(self)
|
|
|
|
def is_entitlement_refundable(self):
|
|
"""
|
|
Returns a boolean as to whether or not the entitlement can be refunded based on the entitlement's policy
|
|
"""
|
|
return not self.refund_locked and self.policy.is_entitlement_refundable(self)
|
|
|
|
def is_entitlement_redeemable(self):
|
|
"""
|
|
Returns a boolean as to whether or not the entitlement can be redeemed based on the entitlement's policy
|
|
"""
|
|
return self.policy.is_entitlement_redeemable(self)
|
|
|
|
def to_dict(self):
|
|
"""
|
|
Convert entitlement to dictionary representation including relevant policy information.
|
|
|
|
Returns:
|
|
The entitlement UUID
|
|
The associated course's UUID
|
|
The date at which the entitlement expired. None if it is still active.
|
|
The localized string representing the date at which the entitlement expires.
|
|
"""
|
|
expiration_date = None
|
|
if self.get_days_until_expiration() < settings.ENTITLEMENT_EXPIRED_ALERT_PERIOD:
|
|
expiration_date = strftime_localized(
|
|
now() + timedelta(days=self.get_days_until_expiration()),
|
|
'SHORT_DATE'
|
|
)
|
|
expired_at = strftime_localized(self.expired_at_datetime, 'SHORT_DATE') if self.expired_at_datetime else None
|
|
|
|
return {
|
|
'uuid': str(self.uuid),
|
|
'course_uuid': str(self.course_uuid),
|
|
'expired_at': expired_at,
|
|
'expiration_date': expiration_date
|
|
}
|
|
|
|
def set_enrollment(self, enrollment):
|
|
"""
|
|
Fulfills an entitlement by specifying a session.
|
|
"""
|
|
self.enrollment_course_run = enrollment
|
|
self.save()
|
|
|
|
def expire_entitlement(self):
|
|
"""
|
|
Expire the entitlement.
|
|
"""
|
|
self.expired_at = now()
|
|
self.save()
|
|
|
|
@classmethod
|
|
def unexpired_entitlements_for_user(cls, user):
|
|
return cls.objects.filter(user=user, expired_at=None).select_related('user')
|
|
|
|
@classmethod
|
|
def get_entitlement_if_active(cls, user, course_uuid):
|
|
"""
|
|
Retrieves the active entitlement for the course_uuid and User.
|
|
|
|
An active entitlement is defined as an entitlement that has not yet expired or has a currently enrolled session.
|
|
If there is more than one entitlement, return the most recently created active entitlement.
|
|
|
|
Arguments:
|
|
user: User that owns the Course Entitlement
|
|
course_uuid: The Course UUID for a Course that we are retrieving active entitlements for.
|
|
|
|
Returns:
|
|
CourseEntitlement: Returns the most recently created entitlement for a given course uuid if an
|
|
active entitlement exists, otherwise returns None
|
|
"""
|
|
try:
|
|
return cls.objects.filter(
|
|
user=user,
|
|
course_uuid=course_uuid
|
|
).exclude(
|
|
expired_at__isnull=False,
|
|
enrollment_course_run=None
|
|
).latest('created')
|
|
except CourseEntitlement.DoesNotExist:
|
|
return None
|
|
|
|
@classmethod
|
|
def get_active_entitlements_for_user(cls, user):
|
|
"""
|
|
Returns a list of active (enrolled or not yet expired) entitlements.
|
|
|
|
Returns any entitlements that are:
|
|
1) Not expired and no session selected
|
|
2) Not expired and a session is selected
|
|
3) Expired and a session is selected
|
|
|
|
Does not return any entitlements that are:
|
|
1) Expired and no session selected
|
|
"""
|
|
return cls.objects.filter(user=user).exclude(
|
|
expired_at__isnull=False,
|
|
enrollment_course_run=None
|
|
).select_related('user').select_related('enrollment_course_run')
|
|
|
|
@classmethod
|
|
def get_fulfillable_entitlements(cls, user):
|
|
"""
|
|
Returns all fulfillable entitlements for a User
|
|
|
|
Arguments:
|
|
user (User): The user we are looking at the entitlements of.
|
|
|
|
Returns
|
|
Queryset: A queryset of course Entitlements ordered descending by creation date that a user can enroll in.
|
|
These must not be expired and not have a course run already assigned to it.
|
|
"""
|
|
|
|
return cls.objects.filter(
|
|
user=user,
|
|
).exclude(
|
|
expired_at__isnull=False,
|
|
enrollment_course_run__isnull=False
|
|
).order_by('-created')
|
|
|
|
@classmethod
|
|
def get_fulfillable_entitlement_for_user_course_run(cls, user, course_run_key):
|
|
"""
|
|
Retrieves a fulfillable entitlement for the user and the given course run.
|
|
|
|
Arguments:
|
|
user (User): The user that we are inspecting the entitlements for.
|
|
course_run_key (CourseKey): The course run Key.
|
|
|
|
Returns:
|
|
CourseEntitlement: The most recent fulfillable CourseEntitlement, None otherwise.
|
|
"""
|
|
# Check if the User has any fulfillable entitlements.
|
|
# Note: Wait to retrieve the Course UUID until we have confirmed the User has fulfillable entitlements.
|
|
# This was done to avoid calling the APIs when the User does not have an entitlement.
|
|
entitlements = cls.get_fulfillable_entitlements(user)
|
|
if entitlements:
|
|
course_uuid = get_course_uuid_for_course(course_run_key)
|
|
if course_uuid:
|
|
entitlement = entitlements.filter(course_uuid=course_uuid).first()
|
|
if (entitlement and is_course_run_entitlement_fulfillable(
|
|
course_run_key=course_run_key, entitlement=entitlement) and
|
|
entitlement.is_entitlement_redeemable()):
|
|
return entitlement
|
|
return None
|
|
|
|
@classmethod
|
|
@transaction.atomic
|
|
def enroll_user_and_fulfill_entitlement(cls, entitlement, course_run_key):
|
|
"""
|
|
Enrolls the user in the Course Run and updates the entitlement with the new Enrollment.
|
|
|
|
Returns:
|
|
bool: True if successfully fulfills given entitlement by enrolling the user in the given course run.
|
|
"""
|
|
try:
|
|
enrollment = CourseEnrollment.enroll(
|
|
user=entitlement.user,
|
|
course_key=course_run_key,
|
|
mode=entitlement.mode
|
|
)
|
|
except CourseEnrollmentException:
|
|
log.exception(f'Login for Course Entitlement {entitlement.uuid} failed')
|
|
return False
|
|
|
|
entitlement.set_enrollment(enrollment)
|
|
return True
|
|
|
|
@classmethod
|
|
def check_for_existing_entitlement_and_enroll(cls, user, course_run_key):
|
|
"""
|
|
Looks at the User's existing entitlements to see if the user already has a Course Entitlement for the
|
|
course run provided in the course_key. If the user does have an Entitlement with no run set, the User is
|
|
enrolled in the mode set in the Entitlement.
|
|
|
|
Arguments:
|
|
user (User): The user that we are inspecting the entitlements for.
|
|
course_run_key (CourseKey): The course run Key.
|
|
Returns:
|
|
bool: True if the user had an eligible course entitlement to which an enrollment in the
|
|
given course run was applied.
|
|
"""
|
|
entitlement = cls.get_fulfillable_entitlement_for_user_course_run(user, course_run_key)
|
|
if entitlement:
|
|
return cls.enroll_user_and_fulfill_entitlement(entitlement, course_run_key)
|
|
return False
|
|
|
|
@classmethod
|
|
def unenroll_entitlement(cls, course_enrollment, skip_refund):
|
|
"""
|
|
Un-enroll the user from entitlement and refund if needed.
|
|
"""
|
|
course_uuid = get_course_uuid_for_course(course_enrollment.course_id)
|
|
course_entitlement = cls.get_entitlement_if_active(course_enrollment.user, course_uuid)
|
|
if course_entitlement and course_entitlement.enrollment_course_run == course_enrollment:
|
|
course_entitlement.set_enrollment(None)
|
|
if not skip_refund and course_entitlement.is_entitlement_refundable():
|
|
course_entitlement.expire_entitlement()
|
|
course_entitlement.refund()
|
|
|
|
def refund(self):
|
|
"""
|
|
Initiate refund process for the entitlement.
|
|
"""
|
|
refund_successful = refund_entitlement(course_entitlement=self)
|
|
if not refund_successful:
|
|
# This state is achieved in most cases by a failure in the ecommerce service to process the refund.
|
|
log.warning(
|
|
'Entitlement Refund failed for Course Entitlement [%s], alert User',
|
|
self.uuid
|
|
)
|
|
# Force Transaction reset with an Integrity error exception, this will revert all previous transactions
|
|
raise IntegrityError
|
|
|
|
def save(self, *args, **kwargs):
|
|
"""
|
|
Null out empty strings in order_number
|
|
"""
|
|
if not self.order_number:
|
|
self.order_number = None
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
@python_2_unicode_compatible
|
|
class CourseEntitlementSupportDetail(TimeStampedModel):
|
|
"""
|
|
Table recording support interactions with an entitlement
|
|
|
|
.. no_pii:
|
|
"""
|
|
# Reasons deprecated
|
|
LEAVE_SESSION = 'LEAVE'
|
|
CHANGE_SESSION = 'CHANGE'
|
|
LEARNER_REQUEST_NEW = 'LEARNER_NEW'
|
|
COURSE_TEAM_REQUEST_NEW = 'COURSE_TEAM_NEW'
|
|
OTHER = 'OTHER'
|
|
ENTITLEMENT_SUPPORT_REASONS = (
|
|
(LEAVE_SESSION, 'Learner requested leave session for expired entitlement'),
|
|
(CHANGE_SESSION, 'Learner requested session change for expired entitlement'),
|
|
(LEARNER_REQUEST_NEW, 'Learner requested new entitlement'),
|
|
(COURSE_TEAM_REQUEST_NEW, 'Course team requested entitlement for learnerg'),
|
|
(OTHER, 'Other'),
|
|
)
|
|
|
|
REISSUE = 'REISSUE'
|
|
CREATE = 'CREATE'
|
|
ENTITLEMENT_SUPPORT_ACTIONS = (
|
|
(REISSUE, 'Re-issue entitlement'),
|
|
(CREATE, 'Create new entitlement'),
|
|
)
|
|
|
|
entitlement = models.ForeignKey('entitlements.CourseEntitlement', on_delete=models.CASCADE)
|
|
support_user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
|
|
|
#Deprecated: use action instead.
|
|
reason = models.CharField(max_length=15, choices=ENTITLEMENT_SUPPORT_REASONS)
|
|
action = models.CharField(max_length=15, choices=ENTITLEMENT_SUPPORT_ACTIONS)
|
|
|
|
comments = models.TextField(null=True)
|
|
|
|
unenrolled_run = models.ForeignKey(
|
|
CourseOverview,
|
|
null=True,
|
|
blank=True,
|
|
db_constraint=False,
|
|
on_delete=models.DO_NOTHING,
|
|
)
|
|
|
|
history = HistoricalRecords()
|
|
|
|
def __str__(self):
|
|
"""Unicode representation of an Entitlement"""
|
|
return 'Course Entitlement Support Detail: entitlement: {}, support_user: {}, reason: {}'.format(
|
|
self.entitlement,
|
|
self.support_user,
|
|
self.reason,
|
|
)
|
|
|
|
@classmethod
|
|
def get_support_actions_list(cls):
|
|
"""
|
|
Method for retrieving a serializable version of the entitlement support reasons
|
|
|
|
Returns
|
|
list: Containing the possible support actions
|
|
"""
|
|
return [
|
|
action[0] # get just the action code, not the human readable description.
|
|
for action
|
|
in cls.ENTITLEMENT_SUPPORT_ACTIONS
|
|
]
|