Skip to content

Commit

Permalink
Adding management command and function for pulling featured items (#2190
Browse files Browse the repository at this point in the history
)

* move create to management commands

* update create

* moving logic to util function and adding cms api function to grab courses for management command

* create and util move

* swapping the pull to a util

* Finish refactoring out the logic from the filter in CourseViewSet

* refactor out from v1 API as well

* update to use memcached

* make cache age a variable

* update ordering

* utils tests

* API test update

* fixes for test before merge

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* refactor out bool

* remove unused args

* fixed mgmt command

* fix unenrollable

* Fix ordering since Django breaks it

* remove extra fields

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
  • Loading branch information
JenniWhitman and pre-commit-ci[bot] authored May 8, 2024
1 parent 80a900d commit 0664e0f
Show file tree
Hide file tree
Showing 7 changed files with 427 additions and 73 deletions.
71 changes: 69 additions & 2 deletions cms/api.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,27 @@
"""API functionality for the CMS app"""

import logging
from datetime import timedelta
from typing import Tuple, Union # noqa: UP035
from urllib.parse import urlencode, urljoin

from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.utils.text import slugify
from wagtail.models import Page, Site
from mitol.common.utils import now_in_utc
from wagtail.models import Site
from wagtail.rich_text import RichText

from cms import models as cms_models
from cms.constants import CERTIFICATE_INDEX_SLUG, INSTRUCTOR_INDEX_SLUG
from cms.exceptions import WagtailSpecificPageError
from cms.models import Page # noqa: F811
from cms.models import Page
from courses.models import Course, Program
from courses.utils import (
get_enrollable_courseruns_qs,
)

log = logging.getLogger(__name__)
DEFAULT_HOMEPAGE_PROPS = dict( # noqa: C408
Expand All @@ -33,6 +39,7 @@
]
RESOURCE_PAGE_SLUGS = [slugify(title) for title in RESOURCE_PAGE_TITLES]
PROGRAM_INDEX_PAGE_PROPERTIES = dict(title="Programs") # noqa: C408
HOMEPAGE_CACHE_AGE = 86400 # 24 hours


def get_home_page(raise_if_missing=True, check_specific=False) -> Page: # noqa: FBT002
Expand Down Expand Up @@ -305,3 +312,63 @@ def create_default_courseware_page(
)

return page


def create_featured_items():
"""
Pulls a new set of featured items for the CMS home page
This will only be used by cron task or management command.
"""
featured_courses = cache.get("CMS_homepage_featured_courses")
if featured_courses is not None:
cache.delete("CMS_homepage_featured_courses")

now = now_in_utc()
end_of_day = now + timedelta(days=1)

enrollable_courseruns = get_enrollable_courseruns_qs(
end_of_day,
Course.objects.select_related("page").filter(page__live=True, live=True),
)

# Figure out which courses are self-paced and select 2 at random
enrollable_self_paced_courseruns = enrollable_courseruns.filter(is_self_paced=True)
self_paced_featured_courseruns = enrollable_self_paced_courseruns.order_by("?")[:2]
self_paced_featured_courses = Course.objects.filter(
id__in=self_paced_featured_courseruns.values_list("course_id", flat=True)
)

# Select 20 random courses that are not self-paced
random_featured_courseruns = enrollable_courseruns.exclude(
id__in=self_paced_featured_courseruns.values_list("id", flat=True)
).order_by("?")[:20]

# Split them into future and started courses, order the future courses by start_date, the rest do not matter, so we leave them as is to save time
future_featured_courseruns = [
courserun
for courserun in random_featured_courseruns
if courserun.start_date >= now
]
future_featured_courseruns.sort(key=lambda courserun: courserun.start_date)
future_featured_course_ids = [
courserun.course.id for courserun in future_featured_courseruns
]
future_featured_courses = Course.objects.filter(id__in=future_featured_course_ids)

started_featured_course_ids = [
courserun.course.id
for courserun in random_featured_courseruns
if courserun.start_date < now
]
started_featured_courses = Course.objects.filter(id__in=started_featured_course_ids)

# Union all the featured courses together
featured_courses = []
featured_courses.extend(list(self_paced_featured_courses))
featured_courses.extend(list(future_featured_courses))
featured_courses.extend(list(started_featured_courses))

# Set the value in cache for 24 hours
cache.set("CMS_homepage_featured_courses", featured_courses, HOMEPAGE_CACHE_AGE)
return featured_courses
77 changes: 76 additions & 1 deletion cms/api_test.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
"""Tests for CMS app API functionality"""

from datetime import timedelta

import pytest
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.core.exceptions import ValidationError
from wagtail.models import Page
from wagtail_factories import PageFactory

from cms.api import (
RESOURCE_PAGE_TITLES,
create_default_courseware_page,
create_featured_items,
ensure_home_page_and_site,
ensure_product_index,
ensure_program_product_index,
Expand All @@ -27,7 +31,7 @@
ProgramPage,
ResourcePage,
)
from courses.factories import CourseFactory, ProgramFactory
from courses.factories import CourseFactory, CourseRunFactory, ProgramFactory


@pytest.mark.django_db
Expand Down Expand Up @@ -282,3 +286,74 @@ def test_create_courseware_page():

with pytest.raises(ValidationError):
resulting_page = create_default_courseware_page(course.programs[0])


@pytest.mark.django_db
def test_create_featured_items():
# pytest does not clear cache thus if we have a cache value set, it will persist between tests and test runs
featured_courses = cache.get("CMS_homepage_featured_courses")
if featured_courses is not None:
cache.delete("CMS_homepage_featured_courses")

enrollable_future_course = CourseFactory.create(page=None, live=True)
CoursePageFactory.create(course=enrollable_future_course, live=True)
enrollable_future_courserun = CourseRunFactory.create(
course=enrollable_future_course,
live=True,
in_future=True,
)

enrollable_other_future_course = CourseFactory.create(page=None, live=True)
CoursePageFactory.create(course=enrollable_other_future_course, live=True)
enrollable_other_future_courserun = CourseRunFactory.create(
course=enrollable_other_future_course,
live=True,
in_future=True,
)
enrollable_other_future_courserun.start_date = (
enrollable_future_courserun.start_date + timedelta(days=2)
)
enrollable_other_future_courserun.enrollment_end = (
enrollable_future_courserun.enrollment_end + timedelta(days=2)
)
enrollable_other_future_courserun.save()

enrollable_self_paced_course = CourseFactory.create(page=None, live=True)
CoursePageFactory.create(course=enrollable_self_paced_course, live=True)
self_paced_run = CourseRunFactory.create(
course=enrollable_self_paced_course,
live=True,
in_progress=True,
)
self_paced_run.is_self_paced = True
self_paced_run.save()

in_progress_course = CourseFactory.create(page=None, live=True)
CoursePageFactory.create(course=in_progress_course, live=True)
CourseRunFactory.create(
course=in_progress_course,
live=True,
in_progress=True,
)

unenrollable_course = CourseFactory.create(page=None, live=False)
CoursePageFactory.create(course=unenrollable_course, live=False)
CourseRunFactory.create(
course=unenrollable_course, live=False, past_enrollment_end=True
)

create_featured_items()
cache_value = cache.get("CMS_homepage_featured_courses")

assert len(cache_value) == 4
assert enrollable_future_course in cache_value
assert enrollable_other_future_course in cache_value
assert enrollable_self_paced_course in cache_value
assert in_progress_course in cache_value

assert cache_value[0] == enrollable_self_paced_course
assert cache_value[1] == enrollable_future_course
assert cache_value[2] == enrollable_other_future_course
assert cache_value[3] == in_progress_course

assert unenrollable_course not in cache_value
18 changes: 18 additions & 0 deletions cms/management/commands/create_featured_items.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""Management command to manually pull a new set of featured items for the CMS home page"""

from django.core.management.base import BaseCommand

from cms.api import create_featured_items


class Command(BaseCommand):
"""Management command to manually pull a new set of featured items for the CMS home page"""

help = __doc__

def handle(self, *args, **options): # pylint: disable=unused-argument # noqa: ARG002
self.stdout.write("Generating new featured courses for the CMS home page")
featured_courses = create_featured_items()
self.stdout.write("Featured courses set in cache")
for featured_course in featured_courses:
self.stdout.write(f"{featured_course}")
96 changes: 95 additions & 1 deletion courses/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
import logging
import re

from django.db.models import Prefetch, Q
from mitol.common.utils.datetime import now_in_utc
from requests.exceptions import HTTPError

from courses.models import CourseRunEnrollment, ProgramCertificate
from courses.models import CourseRun, CourseRunEnrollment, ProgramCertificate

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -72,3 +74,95 @@ def get_program_certificate_by_enrollment(enrollment, program=None):
return ProgramCertificate.objects.get(user_id=user_id, program_id=program_id)
except ProgramCertificate.DoesNotExist:
return None


def get_enrollable_courseruns_qs(enrollment_end_date=None, valid_courses=None):
"""
Returns all course runs that are open for enrollment.
args:
enrollment_end_date: datetime, the date to check for enrollment end if a future date is needed
valid_courses: Queryset of Course objects, to filter the course runs by if needed
"""
now = now_in_utc()
if enrollment_end_date is None:
enrollment_end_date = now

valid_course_runs = CourseRun.objects.filter(
Q(live=True)
& Q(start_date__isnull=False)
& Q(enrollment_start__lt=now)
& (Q(enrollment_end=None) | Q(enrollment_end__gt=enrollment_end_date))
)

if valid_courses:
return valid_course_runs.filter(course__in=valid_courses)

return valid_course_runs


def get_unenrollable_courseruns_qs():
"""Returns all course runs that are closed for enrollment."""
now = now_in_utc()
return CourseRun.objects.filter(
Q(live=False)
| Q(start_date__isnull=True)
| (Q(enrollment_end__lte=now) | Q(enrollment_start__gt=now))
)


def get_self_paced_courses(queryset, enrollment_end_date=None):
"""Returns all course runs that are self-paced."""
now = now_in_utc()
if enrollment_end_date is None:
enrollment_end_date = now
course_ids = queryset.values_list("id", flat=True)
all_runs = CourseRun.objects.filter(
Q(live=True)
& Q(course_id__in=course_ids)
& Q(start_date__isnull=False)
& Q(enrollment_start__lt=now)
& (Q(enrollment_end=None) | Q(enrollment_end__gt=enrollment_end_date))
)
self_paced_runs = all_runs.filter(is_self_paced=True)
return (
queryset.prefetch_related(Prefetch("courseruns", queryset=self_paced_runs))
.prefetch_related("courseruns__course")
.filter(courseruns__id__in=self_paced_runs.values_list("id", flat=True))
.distinct()
)


def get_enrollable_courses(queryset, enrollment_end_date=None):
"""
Returns courses that are open for enrollment
Args:
queryset: Queryset of Course objects
enrollment_end_date: datetime, the date to check for enrollment end if a future date is needed
"""
if enrollment_end_date is None:
enrollment_end_date = now_in_utc()
courseruns_qs = get_enrollable_courseruns_qs(enrollment_end_date)
return (
queryset.prefetch_related(Prefetch("courseruns", queryset=courseruns_qs))
.prefetch_related("courseruns__course")
.filter(courseruns__id__in=courseruns_qs.values_list("id", flat=True))
.distinct()
)


def get_unenrollable_courses(queryset):
"""
Returns courses that are closed for enrollment
Args:
queryset: Queryset of Course objects
"""
courseruns_qs = get_unenrollable_courseruns_qs()
return (
queryset.prefetch_related(Prefetch("courseruns", queryset=courseruns_qs))
.prefetch_related("courseruns__course")
.filter(courseruns__id__in=courseruns_qs.values_list("id", flat=True))
.distinct()
)
Loading

0 comments on commit 0664e0f

Please sign in to comment.