Merge pull request #18395 from edx/whitelabel/journal

Add journals support in LMS
This commit is contained in:
Bill Filler
2018-07-23 15:10:38 -04:00
committed by GitHub
40 changed files with 1633 additions and 5 deletions

View File

@@ -42,6 +42,7 @@ from openedx.core.djangoapps.util.maintenance_banner import add_maintenance_bann
from openedx.core.djangoapps.waffle_utils import WaffleFlag, WaffleFlagNamespace
from openedx.core.djangolib.markup import HTML, Text
from openedx.features.enterprise_support.api import get_dashboard_consent_notification
from openedx.features.journals.api import journals_enabled
from shoppingcart.api import order_history
from shoppingcart.models import CourseRegistrationCode, DonationConfiguration
from student.cookies import set_user_info_cookie
@@ -821,6 +822,7 @@ def student_dashboard(request):
'nav_hidden': True,
'inverted_programs': inverted_programs,
'show_program_listing': ProgramsApiConfig.is_enabled(),
'show_journal_listing': journals_enabled(), # TODO: Dashboard Plugin required
'show_dashboard_tabs': True,
'disable_courseware_js': True,
'display_course_modes_on_dashboard': enable_verified_certificates and display_course_modes_on_dashboard,

View File

@@ -70,6 +70,7 @@ from openedx.core.djangoapps.user_api.models import UserRetirementRequest
from openedx.core.djangoapps.user_api.preferences import api as preferences_api
from openedx.core.djangoapps.user_api.config.waffle import PREVENT_AUTH_USER_WRITES, SYSTEM_MAINTENANCE_MSG, waffle
from openedx.core.djangolib.markup import HTML, Text
from openedx.features.journals.api import get_journals_context
from student.cookies import set_logged_in_cookies
from student.forms import AccountCreationForm, PasswordResetFormNoActive, get_registration_extension_form
from student.helpers import (
@@ -195,6 +196,9 @@ def index(request, extra_context=None, user=AnonymousUser()):
# Add marketable programs to the context.
context['programs_list'] = get_programs_with_type(request.site, include_hidden=False)
# TODO: Course Listing Plugin required
context['journal_info'] = get_journals_context(request)
return render_to_response('index.html', context)

View File

@@ -31,7 +31,7 @@ class CourseDiscoveryPage(PageObject):
"""
Return search result items.
"""
return self.q(css=".courses-listing-item")
return self.q(css=".courses-list .courses-listing-item")
@property
def clear_button(self):

View File

@@ -93,6 +93,7 @@ from openedx.features.course_experience.views.course_dates import CourseDatesFra
from openedx.features.course_experience.waffle import waffle as course_experience_waffle
from openedx.features.course_experience.waffle import ENABLE_COURSE_ABOUT_SIDEBAR_HTML
from openedx.features.enterprise_support.api import data_sharing_consent_required
from openedx.features.journals.api import get_journals_context
from shoppingcart.utils import is_shopping_cart_enabled
from student.models import CourseEnrollment, UserTestGroup
from util.cache import cache, cache_if_anonymous
@@ -231,7 +232,8 @@ def courses(request):
{
'courses': courses_list,
'course_discovery_meanings': course_discovery_meanings,
'programs_list': programs_list
'programs_list': programs_list,
'journal_info': get_journals_context(request), # TODO: Course Listing Plugin required
}
)

View File

@@ -6,6 +6,7 @@ from edxmako.shortcuts import render_to_response
from lms.djangoapps.learner_dashboard.programs import ProgramsFragmentView, ProgramDetailsFragmentView
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
from openedx.features.journals.api import journals_enabled
@login_required
@@ -21,6 +22,7 @@ def program_listing(request):
'nav_hidden': True,
'show_dashboard_tabs': True,
'show_program_listing': programs_config.enabled,
'show_journal_listing': journals_enabled(), # TODO: Dashboard Plugin required
'uses_pattern_library': True,
}

View File

@@ -3456,7 +3456,6 @@ FERNET_KEYS = [
# Maximum number of rows to fetch in XBlockUserStateClient calls. Adjust for performance
USER_STATE_BATCH_SIZE = 5000
############## Plugin Django Apps #########################
from openedx.core.djangoapps.plugins import plugin_apps, plugin_settings, constants as plugin_constants

View File

@@ -69,6 +69,7 @@
// features
@import 'features/bookmarks-v1';
@import 'features/learner-profile';
@import 'features/journals';
// search
@import 'search/search';

View File

@@ -33,6 +33,7 @@
@import 'features/course-sock';
@import 'features/course-upgrade-message';
@import 'features/learner-analytics-dashboard';
@import 'features/journals';
// Responsive Design
@import 'header';

View File

@@ -0,0 +1,173 @@
// journal catalog listing
.journals-listing-item {
box-sizing: border-box;
box-shadow: 1px 2px 5px #ccc;
position: relative;
height: 360px;
overflow: visible;
min-height: 0;
border: none;
display: block;
margin: 0 auto 40px;
background: white;
border-radius: 0;
.journal-image {
height: 142px;
position: relative;
overflow: hidden;
.cover-image {
height: 142px;
img {
width: 100%;
height: auto;
min-height: 100%;
}
&::before {
content: '';
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
margin: auto;
background: black;
opacity: 0;
transition: all 0.2s ease-out;
}
.learn-more {
left: calc(50% - 100px);
box-sizing: border-box;
position: absolute;
z-index: 100;
top: 62px;
padding: 0 20px;
width: 200px;
height: 50px;
border-color: #0074b4;
border-radius: 3px;
background: #0074b4;
color: #fff;
line-height: 50px;
text-align: center;
opacity: 0;
text-transform: none;
transition: all 0.25s ease;
}
}
}
.banner {
background: #065784;
color: white;
@include padding-right(15px);
line-height: 18px;
font-weight: bold;
font-size: 0.7em;
text-align: right;
text-transform: uppercase;
}
.journal-info {
padding: 12px 15px 5px;
.journal-org {
font-weight: normal;
font-size: 0.9em;
color: #3d3e3f;
margin: 0;
line-height: 16px;
}
.journal-title {
max-height: 55px;
overflow: hidden;
color: #222;
font-size: 1.25em;
line-height: 1.333;
margin-bottom: 5px;
}
.journal-subtitle {
font-size: 1em;
margin-bottom: 33px;
line-height: 1.25em;
height: 40px;
color: #646464;
overflow: hidden;
}
}
.journal-footer {
display: table;
width: 100%;
padding: 0 15px 15px;
position: absolute;
bottom: 0;
.availability,
.journal-logo {
display: table-cell;
vertical-align: middle;
}
.availability {
text-align: left;
font-size: 0.9em;
line-height: 20px;
color: #3d3e3f;
}
.journal-logo {
text-align: right;
width: 75px;
}
}
&::before,
&::after {
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.2);
content: '';
position: absolute;
width: 100%;
height: 100%;
background: #d5d5d5;
border: 1px solid #b5b5b5;
}
&::before {
@include left(-5px);
top: -5px;
z-index: -1;
}
&::after {
@include left(-10px);
top: -10px;
z-index: -2;
}
&:hover {
opacity: 1;
.journal-image {
.cover-image {
.learn-more {
opacity: 1;
}
&::before {
opacity: 0.6;
}
}
}
}
}

View File

@@ -7,6 +7,20 @@
% if settings.FEATURES.get('COURSES_ARE_BROWSABLE'):
<section class="courses">
<ul class="courses-listing journal-list">
% for bundle in journal_info.get('journal_bundles'):
<li class="courses-listing-item">
<%include file="journals/bundle_card.html" args="bundle=bundle"/>
</li>
% endfor
</ul>
<ul class="courses-listing journal-list">
%for journal in journal_info.get('journals'):
<li class="courses-listing-item">
<%include file="journals/journal_card.html" args="journal=journal, journals_root_url=journal_info.get('journals_root_url')" />
</li>
%endfor
</ul>
<ul class="courses-listing">
## limiting the course number by using HOMEPAGE_COURSE_MAX as the maximum number of courses
%for course in courses[:homepage_course_max]:

View File

@@ -56,7 +56,21 @@
% endif
<div class="courses${'' if course_discovery_enabled else ' no-course-discovery'}" role="region" aria-label="${_('List of Courses')}">
<ul class="courses-listing">
<ul class="courses-listing journal-list">
% for bundle in journal_info.get('journal_bundles'):
<li class="courses-listing-item">
<%include file="../journals/bundle_card.html" args="bundle=bundle" />
</li>
% endfor
</ul>
<ul class="courses-listing journal-list">
%for journal in journal_info.get('journals'):
<li class="courses-listing-item">
<%include file="../journals/journal_card.html" args="journal=journal, journals_root_url=journal_info.get('journals_root_url')" />
</li>
%endfor
</ul>
<ul class="courses-listing courses-list">
%for course in courses:
<li class="courses-listing-item">
<%include file="../course.html" args="course=course" />

View File

@@ -41,6 +41,13 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_
</a>
</div>
% endif
% if show_journal_listing:
<div class="mobile-nav-item hidden-mobile nav-item nav-tab">
<a class="${'active ' if reverse('openedx.journals.dashboard') in request.path else ''}tab-nav-link" href="${reverse('openedx.journals.dashboard')}">
${_("Journals")}
</a>
</div>
% endif
<div class="mobile-nav-item hidden-mobile nav-item nav-tab">
<a class="${'active ' if '/u/' in request.path else ''}tab-nav-link" href="${reverse('learner_profile', args=[self.real_user.username])}">
${_("Profile")}

View File

@@ -0,0 +1,8 @@
<%page expression_filter="h"/>
<%!
from django.utils.translation import ugettext as _
%>
<header class="wrapper-header-courses">
<h2 class="header-courses">${_("My Journals")}</h2>
</header>

View File

@@ -46,6 +46,13 @@ from django.utils.translation import ugettext as _
</a>
</li>
% endif
% if show_journal_listing:
<li class="nav-item mt-2 nav-item-open-collapsed">
<a class="nav-link active" href="${reverse('openedx.journals.dashboard')}">
${_("Journals")}
</a>
</li>
% endif
<%
self.real_user = getattr(user, 'real_user', user)
is_on_profile_page = data and data.get('profile_user_id') is not None

View File

@@ -29,6 +29,13 @@ from django.utils.translation import ugettext as _
</a>
</li>
% endif
% if show_journal_listing:
<li class="tab-nav-item">
<a class="${'active ' if reverse('openedx.journals.dashboard') in request.path else ''}tab-nav-link" href="${reverse('openedx.journals.dashboard')}">
${_("Journals")}
</a>
</li>
% endif
<%
self.real_user = getattr(user, 'real_user', user)
is_on_profile_page = data and data.get('profile_user_id') is not None

View File

@@ -37,6 +37,11 @@ class Command(BaseCommand):
discovery_user = None
discovery_base_url_fmt = None
discovery_oidc_url = None
journals = False
journals_user = None
journals_base_url_fmt = None
journals_oidc_url = None
configuration_filename = None
def add_arguments(self, parser):
@@ -63,6 +68,13 @@ class Command(BaseCommand):
help="Use devstack config, otherwise sandbox config is assumed",
)
parser.add_argument(
"--enable-journals",
dest="journals",
action="store_true",
help="Enable journal configuration",
)
def _create_oauth2_client(self, url, site_name, service_name, service_user):
"""
Creates the oauth2 client and add it in trusted clients.
@@ -178,7 +190,7 @@ class Command(BaseCommand):
def get_or_create_service_user(self, username):
"""
Creates the service user for ecommerce and discovery.
Creates the service user for ecommerce, discovery and journals.
"""
service_user, _ = User.objects.get_or_create(username=username)
service_user.is_active = True
@@ -198,6 +210,7 @@ class Command(BaseCommand):
def handle(self, *args, **options):
self.dns_name = options['dns_name']
self.theme_path = options['theme_path']
self.journals = options['journals']
if options['devstack']:
configuration_prefix = "devstack"
@@ -205,16 +218,22 @@ class Command(BaseCommand):
self.discovery_base_url_fmt = "http://discovery-{site_domain}:18381/"
self.ecommerce_oidc_url = "http://ecommerce-{}.e2e.devstack:18130/complete/edx-oidc/".format(self.dns_name)
self.ecommerce_base_url_fmt = "http://ecommerce-{site_domain}:18130/"
self.journals_oidc_url = "http://journals-{}.e2e.devstack:18606/complete/edx-oidc/".format(self.dns_name)
self.journals_base_url_fmt = "http://journals-{site_domain}:18606/"
else:
configuration_prefix = "sandbox"
self.discovery_oidc_url = "https://discovery-{}.sandbox.edx.org/complete/edx-oidc/".format(self.dns_name)
self.discovery_base_url_fmt = "https://discovery-{site_domain}/"
self.ecommerce_oidc_url = "https://ecommerce-{}.sandbox.edx.org/complete/edx-oidc/".format(self.dns_name)
self.ecommerce_base_url_fmt = "https://ecommerce-{site_domain}/"
self.journals_oidc_url = "https://journals-{}.sandbox.edx.org/complete/edx-oidc/".format(self.dns_name)
self.journals_base_url_fmt = "https://journals-{site_domain}/"
self.configuration_filename = '{}_configuration.json'.format(configuration_prefix)
self.discovery_user = self.get_or_create_service_user("lms_catalog_service_user")
self.ecommerce_user = self.get_or_create_service_user("ecommerce_worker")
if self.journals:
self.journals_user = self.get_or_create_service_user("journals_worker")
all_sites = self._get_sites_data()
self._update_default_clients()
@@ -225,6 +244,7 @@ class Command(BaseCommand):
discovery_url = self.discovery_base_url_fmt.format(site_domain=site_domain)
ecommerce_url = self.ecommerce_base_url_fmt.format(site_domain=site_domain)
journals_url = self.journals_base_url_fmt.format(site_domain=site_domain)
LOG.info("Creating '{site_name}' Site".format(site_name=site_name))
self._create_sites(site_domain, site_data['theme_dir_name'], site_data['configuration'])
@@ -235,4 +255,8 @@ class Command(BaseCommand):
LOG.info("Creating ecommerce oauth2 client for '{site_name}' site".format(site_name=site_name))
self._create_oauth2_client(ecommerce_url, site_name, 'ecommerce', self.ecommerce_user)
if self.journals:
LOG.info("Creating journals oauth2 client for '{site_name}' site".format(site_name=site_name))
self._create_oauth2_client(journals_url, site_name, 'journals', self.journals_user)
self._enable_commerce_configuration()

View File

@@ -0,0 +1,37 @@
Journals
---------
This directory contains a Django application that allows a learners to interact
with a content Journal. The majority of the capabilities
are provided through the Journals IDA here: `https://github.com/edx/journals`
and through Journal modules added to Discovery and Ecommerce services.
**Journal**:
The Journal is a new product type that will be offered for purchase in the LMS. It is indepedent from a course, and contains a collection of resources/content-types (documents, videos, rich text, etc) that can be updated easily through the Journal service. A Journal is linked to an organization and you can purchase/receive access to it. One notable difference is that a Journal will have an access_length, which determines the amount of time the learner will have access to it post-purchase. This is our first stage towards a subscription model. The Discovery Service is the source-of-truth for all Journal related marketing information that is displayed in the LMS.
**JournalBundle**:
The Journal Bundle is a collection of Journals and Courses. It works similar to a program in the bundling aspect, the difference lies in the fact that it doesn't necessarily constitute a progression of courses. The first (and possibly most common) use case that will use this is bundling a single course with a single journal. Journal Bundles are defined in Discovery Service.
**Things to note**:
- The Journals app was intentionally decoupled as much as possible from the rest of the LMS, both for future developer sanity, and to minimally affect the rest of the platform should the scope of the Journals product change.
- The Journals product has a seperate IDA (Journals) which works as the publishing platform, the consumption platform, and the marketing platform (for standalone Journals). This is different from the structure of studio/lms and so some information may be handled differently in this application.
**Functionality Added to LMS to support Journals**:
- "Cards" for Journals and Journal Bundles on main LMS index page such that learners can discover and purchase Journals. These are the equivalent to course cards and Program cards in the LMS.
- Journals dashboard, that lists Journals that have been purchased by the learner. Similar to Programs dashboard in LMS.
**API**:
api.py - This class provides an abstraction to Journal specific information that is needed by the LMS. Specifically, it provides an api to fetch Journals and Journal Bundles from the Discovery Service, both of which are needed to display Journals information on main LMS index page as well as dashboard. Additionally it provides an api to fetch JournalAccess records from the Journal Service. This is used to determine which Journals a user has access to and displays this information on the "Journals" Dashboard.
**Views**:
learner_dashboard.py: This view adds a "Journals" tab to the dashboard and displays a list of Journals that the learner has access to. Clicking on a Journal from the dashboard will direct learner to the Journal Service to view the Journal.
marketing.py: This view provides a marketing page for Journal Bundles. It can be thought of as the equivalent to a Program About page. Specifically, it will show information about Journal(s) and Course(s) which have been bundled together for marketing and discouting purposes. The bundle definition and meta-data lives in the Discovery service.

View File

View File

@@ -0,0 +1,310 @@
"""
APIs providing support for Journals functionality.
"""
import logging
import hashlib
import six
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist
from edx_rest_api_client.client import EdxRestApiClient
from openedx.core.djangoapps.catalog.models import CatalogIntegration
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace
from openedx.core.lib.token_utils import JwtBuilder
from slumber.exceptions import HttpClientError, HttpServerError
LOGGER = logging.getLogger("edx.journals")
JOURNALS_CACHE_TIMEOUT = 3600 # Value is in seconds
JOURNALS_API_PATH = '/journal/api/v1/'
JOURNAL_WORKER_USERNAME = 'journals_worker'
User = get_user_model()
# Waffle switches namespace for journals
WAFFLE_NAMESPACE = 'journals'
WAFFLE_SWITCHES = WaffleSwitchNamespace(name=WAFFLE_NAMESPACE)
# Waffle switch for enabling/disabling journals
JOURNAL_INTEGRATION = 'enable_journal_integration'
class DiscoveryApiClient(object):
"""
Class for interacting with the discovery service journals endpoint
"""
def __init__(self):
"""
Initialize an authenticated Discovery service API client by using the
provided user.
"""
catalog_integration = CatalogIntegration.current()
# Client can't be used if there is no catalog integration
if not (catalog_integration and catalog_integration.enabled):
LOGGER.error("Unable to create DiscoveryApiClient because catalog integration not set up or enabled")
return None
try:
user = catalog_integration.get_service_user()
except ObjectDoesNotExist:
LOGGER.error("Unable to retrieve catalog integration service user")
return None
jwt = JwtBuilder(user).build_token([])
base_url = configuration_helpers.get_value('COURSE_CATALOG_URL_BASE', settings.COURSE_CATALOG_URL_BASE)
self.client = EdxRestApiClient(
'{base_url}{journals_path}'.format(base_url=base_url, journals_path=JOURNALS_API_PATH),
jwt=jwt
)
def get_journals(self, orgs):
"""
get_journals from discovery, filter on orgs is supplied
"""
try:
if orgs:
response = self.client.journals.get(orgs=','.join(orgs), status='active')
else:
response = self.client.journals.get(status='active')
LOGGER.debug('response is type=%s', type(response))
return response.get('results')
except (HttpClientError, HttpServerError) as err:
LOGGER.exception(
'Failed to get journals from discovery-service [%s]',
err.content
)
return []
def get_journal_bundles(self, uuid=''):
"""
get_journal_bundles from discovery on the base of uuid (optional)
"""
try:
response = self.client.journal_bundles(uuid).get()
except (HttpClientError, HttpServerError) as err:
LOGGER.exception(
'Failed to get journal bundles from discovery-service [%s]',
err.content
)
return []
return [response] if uuid else response.get('results')
class JournalsApiClient(object):
"""
Class for interacting with the Journals Service
"""
def __init__(self):
"""
Initialize an authenticated Journals service API client by using the
provided user.
"""
try:
self.user = self.get_journals_worker()
except ObjectDoesNotExist:
error = 'Unable to retrieve {} service user'.format(JOURNAL_WORKER_USERNAME)
LOGGER.error(error)
raise ValueError(error)
jwt = JwtBuilder(self.user).build_token(['email', 'profile'], 16000)
self.client = EdxRestApiClient(
configuration_helpers.get_value('JOURNALS_API_URL', settings.JOURNALS_API_URL),
jwt=jwt
)
def get_journals_worker(self):
""" Return journals worker """
return User.objects.get(username=JOURNAL_WORKER_USERNAME)
def fetch_journal_access(site, user): # pylint: disable=unused-argument
"""
Retrieve journal access record for given user.
Retrieve if from the cache if present, otherwise send GET request to the journal access api
and store it in the cache
Args:
site (Site)
user (username | str): user to retrieve access records for
Returns:
list of dicts: list of journal access dicts
Raises:
ConnectionError: raised if lms is unable to connect to the journals service.
SlumberBaseException: raised if API response contains http error status like 4xx, 5xx etc...
Timeout: raised if API is talking to long to respond
"""
try:
# TODO: WL-1560:
# LMS should cache responses from Journal Access API
# Need strategy for updating cache when new purchase happens
journal_access_records = JournalsApiClient().client.journalaccess.get(
user=user,
get_latest=True
)
return journal_access_records.get('results', [])
except ValueError:
return []
def get_cache_key(**kwargs):
"""
Get MD5 encoded cache key for given arguments.
Here is the format of key before MD5 encryption.
key1:value1__key2:value2 ...
Example:
>>> get_cache_key(site_domain="example.com", resource="enterprise-learner")
# Here is key format for above call
# "site_domain:example.com__resource:enterprise-learner"
a54349175618ff1659dee0978e3149ca
Arguments:
**kwargs: Key word arguments that need to be present in cache key.
Returns:
An MD5 encoded key uniquely identified by the key word arguments.
"""
key = '__'.join(['{}:{}'.format(item, value) for item, value in six.iteritems(kwargs)])
return hashlib.md5(key).hexdigest()
def journals_enabled():
"""
Determines whether the Journals app is installed and enabled for the current Site
Returns:
True if global setting via waffle switch
'journals.enable_journal_integration' is enabled and
Site specific setting JOURNALS_ENABLED is True.
False if either of these settings is not enabled
"""
return 'openedx.features.journals.apps.JournalsConfig' in settings.INSTALLED_APPS and \
WAFFLE_SWITCHES.is_enabled(JOURNAL_INTEGRATION) and \
configuration_helpers.get_value('JOURNALS_ENABLED', settings.FEATURES.get('JOURNALS_ENABLED', False))
def get_journals(site):
"""Retrieve journals from the discovery service.
Keyword Arguments:
site (Site): Site object for the request
will be returned
Returns:
list of dict, representing journals
"""
if not journals_enabled():
return []
api_resource = 'journals'
orgs = configuration_helpers.get_current_site_orgs()
cache_key = get_cache_key(
site_domain=site.domain,
resource=api_resource,
orgs=orgs
)
# look up in cache
journals = cache.get(cache_key)
if not journals:
api_client = DiscoveryApiClient()
if not api_client:
return []
journals = api_client.get_journals(orgs)
cache.set(cache_key, journals, JOURNALS_CACHE_TIMEOUT)
return journals
def fix_course_images(bundle):
"""
Set the image for a course. If the course has an image, use that. Otherwise use the first
course run that has an image.
"""
for course in bundle['courses']:
course_image = course['image'].get('src') if course.get('image') else None
if course_image:
# Course already had image and we don't need to check course runs
continue
for course_run in course['course_runs']:
if course_run['image']:
course['image'] = course_run['image']
break
def get_journal_bundles(site, bundle_uuid=''):
"""Retrieve journal bundles from the discovery service.
Returns:
list of dict, representing journal bundles
"""
if not journals_enabled():
return []
api_resource = 'journal_bundles'
cache_key = get_cache_key(
site_domain=site.domain,
resource=api_resource,
bundle_uuid=bundle_uuid
)
_CACHE_MISS = object()
journal_bundles = cache.get(cache_key, _CACHE_MISS)
if journal_bundles is _CACHE_MISS:
api_client = DiscoveryApiClient()
if not api_client:
return []
journal_bundles = api_client.get_journal_bundles(uuid=bundle_uuid)
cache.set(cache_key, journal_bundles, JOURNALS_CACHE_TIMEOUT)
for bundle in journal_bundles:
fix_course_images(bundle)
return journal_bundles
def get_journals_root_url():
"""
Return the base url used to display Journals
"""
if journals_enabled():
if configuration_helpers.is_site_configuration_enabled():
return configuration_helpers.get_configuration_value(
'JOURNALS_URL_ROOT',
settings.JOURNALS_URL_ROOT
)
else:
return settings.JOURNALS_URL_ROOT
else:
return None
def get_journals_context(request):
"""
Return dict of Journal context information for a given request
Args:
request: The request to process
Returns:
dict containing the following information:
dict['journals'] - list of Journals available for purchase
dict['journals_root_url'] - root url for Journals service
dict['journal_bundles'] - list of JournalBundles available for purchase
"""
journal_info = {}
journal_info['journals'] = get_journals(request.site)
journal_info['journals_root_url'] = get_journals_root_url()
journal_info['journal_bundles'] = get_journal_bundles(request.site)
return journal_info

View File

@@ -0,0 +1,30 @@
"""
Journals Application Configuration
"""
from django.apps import AppConfig
from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType, PluginURLs, PluginSettings
class JournalsConfig(AppConfig):
"""
Application Configuration for Journals.
"""
name = u'openedx.features.journals'
plugin_app = {
PluginURLs.CONFIG: {
ProjectType.LMS: {
PluginURLs.NAMESPACE: u'',
PluginURLs.REGEX: r'^journals/',
PluginURLs.RELATIVE_PATH: u'urls',
}
},
PluginSettings.CONFIG: {
ProjectType.LMS: {
SettingsType.AWS: {PluginSettings.RELATIVE_PATH: u'settings.aws'},
SettingsType.COMMON: {PluginSettings.RELATIVE_PATH: u'settings.common'},
SettingsType.DEVSTACK: {PluginSettings.RELATIVE_PATH: u'settings.devstack'},
SettingsType.TEST: {PluginSettings.RELATIVE_PATH: u'settings.test'},
}
}
}

View File

@@ -0,0 +1,11 @@
'''AWS Settings for Journals'''
def plugin_settings(settings):
"""
Settings for AWS/Production
"""
settings.JOURNALS_URL_ROOT = settings.ENV_TOKENS.get('JOURNALS_URL_ROOT', settings.JOURNALS_URL_ROOT)
settings.JOURNALS_API_URL = settings.ENV_TOKENS.get('JOURNALS_API_URL', settings.JOURNALS_API_URL)
settings.COURSE_CATALOG_URL_BASE = settings.ENV_TOKENS.get(
'COURSE_CATALOG_URL_BASE', settings.COURSE_CATALOG_URL_BASE)

View File

@@ -0,0 +1,12 @@
'''Common Settings for Journals'''
def plugin_settings(settings):
"""
Common settings for Journals
"""
settings.JOURNALS_URL_ROOT = None
settings.JOURNALS_API_URL = None
settings.FEATURES['JOURNALS_ENABLED'] = False
settings.COURSE_CATALOG_URL_BASE = None
settings.MAKO_TEMPLATE_DIRS_BASE.append(settings.OPENEDX_ROOT / 'features' / 'journals' / 'templates')

View File

@@ -0,0 +1,11 @@
'''devstack settings for Journals'''
def plugin_settings(settings):
"""
Devstack settings for Journals
"""
settings.JOURNALS_URL_ROOT = 'http://localhost:18606'
settings.JOURNALS_API_URL = 'http://journals.app:18606/api/v1/'
settings.FEATURES['JOURNALS_ENABLED'] = True
settings.COURSE_CATALOG_URL_BASE = 'http://edx.devstack.discovery:18381'

View File

@@ -0,0 +1,9 @@
'''Test Settings for Journals'''
def plugin_settings(settings): # pylint ignore:Unused argument
"""
Test settings for Journals
"""
settings.COURSE_CATALOG_URL_BASE = 'https://catalog.example.com'
settings.FEATURES['JOURNALS_ENABLED'] = False

View File

@@ -0,0 +1,164 @@
## mako
<%page expression_filter="h"/>
<%inherit file="/main.html" />
<%!
from datetime import datetime
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext as _
from mako import exceptions
from urlparse import urljoin
from openedx.core.djangolib.markup import HTML, Text
%>
<%namespace name='static' file='../static_content.html'/>
## Override the default styles_version to use Bootstrap
<%! main_css = "css/bootstrap/lms-main.css" %>
<%
%>
<%block name="js_extra">
<script src="${static.url('js/leanModal.js')}"></script>
<script src="${static.url('js/program_marketing.js')}"></script>
</%block>
<%block name="pagetitle">${bundle['title']}</%block>
<%block name="marketing_hero">
<%
banner_image = bundle.get('banner_image', {}).get('large', {}).get('url', '')
price_format = '{0:.0f}' if bundle['pricing_data']['total_incl_tax'].is_integer() else '{0:.2f}'
%>
<div id="program-details-hero">
<div class="main-banner"
style="background: linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5) ), url(${banner_image});">
<div class="container" >
<div class="row">
<div class="col col-12 col-md-8">
<div>
<h1 class="program_title">${bundle.get('title', '')}</h1>
</div>
<div>
## Note: Weird formatting to fix the inline spacing issue.
<a href="${bundle['pricing_data']['purchase_url']}" class="btn btn-success">
<span>${_('Purchase the Bundle (')}</span
% if bundle['pricing_data']['is_discounted']:
><span aria-label="${_('Original Price')}" class="original-price"
>${Text('${oldPrice}').format(
oldPrice=price_format.format(bundle['pricing_data']['total_incl_tax_excl_discounts'])
)}</span
><span aria-label="${_('Discounted Price')}" class="discount">
${Text('${newPrice}').format(
newPrice=price_format.format(bundle['pricing_data']['total_incl_tax']),
)}
</span
><span class="savings">
${Text('{discount_value} {currency})').format(
discount_value=price_format.format(bundle['pricing_data']['discount_value']),
currency=bundle['pricing_data']['currency']
)}
</span>
% else:
><span>${Text('${price})').format(
price=price_format.format(bundle['pricing_data']['total_incl_tax']),
)}
</span>
% endif
</a>
</div>
</div>
</div>
</div>
</div>
<div class="quick-nav">
<div class="container">
<div class="row">
<ul class="nav nav-fill col-lg-12">
</ul>
</div>
</div>
</div>
</div>
</%block>
<div id="program-details-page" class="container">
% if bundle.get('courses') or bundle.get('journals'):
<hr class="thick_rule">
<div id="courses">
<div class="row">
<div class="col-12">
<h2>
${_('Courses included')}
</h2>
</div>
</div>
% for course in bundle.get('courses'):
<%
course_run = course['course_runs'][0]
course_img = course.get('image', {}).get('src', '') if course.get('image') else ''
course_about_url = reverse('about_course', args=[course_run['key']])
%>
<div class="row course">
<div class="col-3 col-lg-2 course-image">
% if course_img:
<img alt="" src="${course_img}" alt=""/>
% endif
</div>
<div class="col-9 col-lg-6">
<div>
<a href="${course_about_url}">${course_run['title']}</a>
</div>
<div>${course['short_description'] or ''}</div>
</div>
<div class="col-12 col-lg-4 course-enroll">
<div>
${Text(_('Starts on {}')).format(
datetime.strptime(course_run['start'], '%Y-%m-%dT%H:%M:%SZ').strftime('%B %-d, %Y')
)}
</div>
<div>
<a class="btn btn-primary btn-block btn-success" href="${course_about_url}">${_("View Course")}</a>
</div>
</div>
</div>
% endfor
% for journal in bundle.get('journals'):
<div class="row">
<div class="col-12">
<h2>
${_('Journals included')}
</h2>
</div>
</div>
<div class="row course">
<div class="col-3 col-lg-2 course-image">
% if journal.get('card_image_url'):
<img alt="" src="${journal['card_image_url']}" alt=""/>
% endif
</div>
<div class="col-9 col-lg-6">
<div>
<a href="${urljoin(journals_root_url, journal['slug'])}">${journal['title']}</a>
</div>
<div>${journal['short_description'] or ''}</div>
</div>
<div class="col-12 col-lg-4 course-enroll">
<div>
${_('{access_length} Day Access').format(
access_length=journal['access_length']
)}
</div>
<div>
<a class="btn btn-primary btn-block btn-success" href="${urljoin(journals_root_url, journal['slug'])}">${_("View Journal")}</a>
</div>
</div>
</div>
% endfor
</div>
% endif
</div>

View File

@@ -0,0 +1,48 @@
<%def name="online_help_token()"><% return "course" %></%def>
<%namespace name='static' file='static_content.html'/>
<%!
from django.utils.translation import ugettext as _
from django.core.urlresolvers import reverse
from six import text_type
from urlparse import urljoin
%>
<%page args="bundle" expression_filter="h"/>
<article class="bundle" id="${bundle.get('uuid')}" role="region" aria-label="${bundle.get('title')}">
<%
images = []
orgs = []
for journal in bundle['journals']:
images.append(journal.get('card_image_url', ''))
orgs.append(journal.get('organization', ''))
for course in bundle['courses']:
if course.get('image'):
images.append(course['image'].get('src', ''))
orgs.append(course.get('partner', ''))
card_img = next((img for img in images if img), '')
organization = next((org for org in orgs if org), '')
%>
<a class="journals-listing-item" href="${reverse('openedx.journals.bundle_about', kwargs={'bundle_uuid':bundle['uuid']})}">
<header class="journal-image">
<div class="cover-image">
<img src="${card_img}" alt="${bundle.get('title')}" />
<div class="learn-more" aria-hidden="true">${_("LEARN MORE")}</div>
</div>
</header>
<div class="banner">
${_("Bundle")}
</div>
<div class="journal-info" aria-hidden="true">
<span class="journal-org">${organization}</span>
<h3 class="journal-title">${bundle.get('title')}</h3>
</div>
<div class="sr">
<ul>
<li>${organization}</li>
<li>${bundle.get('title')}</li>
</ul>
</div>
</a>
</article>

View File

@@ -0,0 +1,48 @@
<%namespace name='static' file='static_content.html'/>
<%!
from django.utils.translation import ungettext, ugettext as _
from django.core.urlresolvers import reverse
from six import text_type
from urlparse import urljoin
%>
<%page args="journal, journals_root_url" expression_filter="h"/>
<article class="journal" id="${journal.get('slug')}" role="region" aria-label="${journal.get('title')}">
<a class="journals-listing-item" href="${urljoin(journals_root_url, journal.get('slug'))}">
<header class="journal-image">
<div class="cover-image">
<img src="${journal.get('card_image_url')}" alt="${journal.get('title')}" />
<div class="learn-more" aria-hidden="true">${_("LEARN MORE")}</div>
</div>
</header>
<div class="banner">
${_("Journal")}
</div>
<div class="journal-info" aria-hidden="true">
<span class="journal-org">${journal.get('organization')}</span>
<h3 class="journal-title">${journal.get('title')}</h3>
</div>
<span class="journal-footer">
<%
if journal.get('access_length') is not None:
access_length_string = ungettext(
'{num_months} month',
'{num_months} months',
int(journal.get('access_length')/30)
).format(num_months=int(journal.get('access_length')/30))
else:
access_length_string = _("unlimited")
%>
<div class="course-date" aria-hidden="true">${_("Access Length")}: ${access_length_string}</div>
</span>
<div class="sr">
<ul>
<li>${journal.get('organization')}</li>
<li>${journal.get('title')}</li>
<li>${_("Access Length")}: ${access_length_string}</li>
</ul>
</div>
</a>
</article>

View File

@@ -0,0 +1,135 @@
<%page expression_filter="h"/>
<%inherit file="/main.html" />
<%def name="online_help_token()"><% return "learnerdashboard" %></%def>
<%namespace name='static' file='/static_content.html'/>
<%!
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext as _
from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string
from openedx.features.journals.views.learner_dashboard import get_journal_about_page_url, format_expiration_date, has_access_expired
%>
<%block name="pagetitle">${_("Journal Dashboard")}</%block>
<%block name="bodyclass">view-dashboard is-authenticated</%block>
<%block name="header_extras">
% for template_name in ["donation"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="dashboard/${template_name}.underscore" />
</script>
% endfor
</%block>
<%block name="js_extra">
<script src="${static.url('js/commerce/credit.js')}"></script>
<%static:js group='dashboard'/>
<script type="text/javascript">
$(document).ready(function() {
edx.dashboard.legacy.init({
dashboard: "${reverse('dashboard') | n, js_escaped_string}",
signInUser: "${reverse('signin_user') | n, js_escaped_string}",
changeEmailSettings: "${reverse('change_email_settings') | n, js_escaped_string}"
});
});
</script>
% if settings.FEATURES.get('ENABLE_DASHBOARD_SEARCH'):
<%static:require_module module_name="course_search/js/dashboard_search_factory" class_name="DashboardSearchFactory">
DashboardSearchFactory();
</%static:require_module>
% endif
% if redirect_message:
<%static:require_module module_name="js/views/message_banner" class_name="MessageBannerView">
var banner = new MessageBannerView({urgency: 'low', type: 'warning'});
$('#content').prepend(banner.$el);
banner.showMessage(${redirect_message | n, dump_js_escaped_json})
</%static:require_module>
% endif
</%block>
<main id="main" aria-label="Content" tabindex="-1">
<div class="dashboard" id="dashboard-main">
<div class="main-container">
<div class="my-courses" id="my-courses">
<%include file="/learner_dashboard/_dashboard_navigation_journals.html"/>
% if len(journals) > 0:
<ul class="listing-courses">
% for journal in journals:
<%
about_page_url = get_journal_about_page_url(slug=journal['journal']['journalaboutpage']['slug'])
formatted_expiration_date = format_expiration_date(journal['expiration_date'])
access_expired = has_access_expired(journal['expiration_date'])
%>
<li class="course-item">
<div class="course-container">
<article class="course" aria-labelledby="journal-title-${journal['journal']['name']}" id="course-card-${journal['journal']['name']}">
<section class="details" aria-labelledby="details-heading-${journal['journal']['name']}">
<h2 class="hd hd-2 sr" id="details-heading-${journal['journal']['name']}">$_('Journal details')}</h2>
<div class="wrapper-course-image" aria-label="true">
<a href="${about_page_url}" class="cover" tabindex="-1">
<img src="${journal['journal']['journalaboutpage']['card_image_absolute_url']}" class="course-image" alt="${_('{journal_title} Cover Image').format(journal_title=journal['journal']['name'])}"/>
</a>
</div>
<div class="wrapper-course-details">
<h3 class="course-title" id="course-title-${journal['journal']['name']}" href="${about_page_url}">
<a data-course-key="${journal['uuid']}">${journal['journal']['name']}</a>
</h3>
<div class="course-info">
<span class="info-university">${journal['journal']['organization']}</span>
<span class="info-date-block-container">
% if access_expired:
<span class="info-date-block" aria-live="polite">
<span class="icon fa fa-warning" aria-hidden="true"></span>
${_('Access Expired: {date}').format(date=formatted_expiration_date)}
</span>
% else:
<span class="info-date-block">
${_('Access Expires: {date}').format(date=formatted_expiration_date)}
</span>
% endif
</span>
</div>
<div class="wrapper-course-actions">
<div class="course-actions">
% if access_expired:
<a href="${about_page_url}"
class="enter-course"
data-course-key="${journal['uuid']}">
${_('Renew Access')}
<span class="sr">
${journal['journal']['name']}
</span>
</a>
% else:
<a href="${about_page_url}"
class="enter-course"
data-course-key="${journal['uuid']}">
${_('View Journal')}
<span class="sr">
${journal['journal']['name']}
</span>
</a>
% endif
</div>
</div>
</div>
</section>
</article>
</div>
</li>
% endfor
</ul>
% else:
<div class="empty-dashboard-message">
<p>${_("You have not purchased access to any journals yet.")}</p>
<a class="btn btn-primary" href="${marketing_link('COURSES')}">
${_("Explore journals and courses")}
</a>
</div>
% endif
</div>
</div>
<div class="side-container"></div>
</div>
</main>

View File

@@ -0,0 +1,74 @@
""" Tests for journals learner dashboard views. """
import mock
from django.conf import settings
from django.core.urlresolvers import reverse
from lms.djangoapps.courseware.tests.helpers import LoginEnrollmentTestCase
from openedx.features.journals.tests.utils import get_mocked_journal_access, override_switch
from openedx.features.journals.api import JOURNAL_INTEGRATION
@mock.patch.dict(settings.FEATURES, {"JOURNALS_ENABLED": True})
class JournalLearnerDashboardTest(LoginEnrollmentTestCase):
""" Tests for the Leaner Dashboard views for journals data """
def setUp(self):
super(JournalLearnerDashboardTest, self).setUp()
self.setup_user()
self.path = reverse('openedx.journals.dashboard')
def test_without_authenticated_user(self):
"""
Test the learner dashboard without authenticated user.
"""
self.logout()
response = self.client.get(path=self.path)
self.assertEqual(response.status_code, 404)
@override_switch(JOURNAL_INTEGRATION, True)
@mock.patch("openedx.features.journals.views.learner_dashboard.fetch_journal_access")
def test_with_empty_journals(self, mocked_journal_access):
"""
Test the learner dashboard without journal access data.
"""
mocked_journal_access.return_value = []
response = self.client.get(path=self.path)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "My Journals")
self.assertContains(response, "You have not purchased access to any journals yet.")
@override_switch(JOURNAL_INTEGRATION, True)
@mock.patch("openedx.features.journals.views.learner_dashboard.fetch_journal_access")
def test_with_with_valid_data(self, mocked_journal_access):
"""
Test the learner dashboard with journal access data.
"""
journals = get_mocked_journal_access()
mocked_journal_access.return_value = journals
response = self.client.get(path=self.path)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "View Journal")
for journal in journals:
self.assertContains(response, journal["journal"]["name"])
self.assertContains(response, journal["journal"]["organization"])
@override_switch(JOURNAL_INTEGRATION, False)
def test_journals_waffle_disabled(self):
"""
Test the journal dashboard is not displayed if
waffle switch is off
"""
response = self.client.get(path=self.path)
self.assertEqual(response.status_code, 404)
@override_switch(JOURNAL_INTEGRATION, True)
@mock.patch.dict(settings.FEATURES, {"JOURNALS_ENABLED": False})
def test_journals_setting_disabled(self):
"""
Test the journal dashboard is not displayed if
waffle switch is on but setting is off
"""
response = self.client.get(path=self.path)
self.assertEqual(response.status_code, 404)

View File

@@ -0,0 +1,128 @@
""" Tests for journals marketing views. """
import uuid
import mock
from nose.plugins.attrib import attr
from django.conf import settings
from django.core.urlresolvers import reverse
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin
from openedx.features.journals.tests.utils import (get_mocked_journals,
get_mocked_journal_bundles,
get_mocked_pricing_data,
override_switch)
from openedx.features.journals.api import JOURNAL_INTEGRATION
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
@mock.patch.dict(settings.FEATURES, {"JOURNALS_ENABLED": True})
class JournalBundleViewTest(CacheIsolationTestCase, SiteMixin):
""" Tests for journals marketing views. """
@override_switch(JOURNAL_INTEGRATION, True)
@mock.patch('openedx.features.journals.api.DiscoveryApiClient.get_journal_bundles')
def test_journal_bundle_with_empty_data(self, mock_bundles):
"""
Test the marketing page without journal bundle data.
"""
mock_bundles.return_value = []
response = self.client.get(
path=reverse(
"openedx.journals.bundle_about",
kwargs={'bundle_uuid': str(uuid.uuid4())}
)
)
self.assertEqual(response.status_code, 404)
@override_switch(JOURNAL_INTEGRATION, True)
@mock.patch('openedx.features.journals.views.marketing.get_pricing_data')
@mock.patch('openedx.features.journals.api.DiscoveryApiClient.get_journal_bundles')
def test_journal_bundle_with_valid_data(self, mock_bundles, mock_pricing_data):
"""
Test the marketing page with journal bundle data.
"""
journal_bundles = get_mocked_journal_bundles()
journal_bundle = journal_bundles[0]
mock_pricing_data.return_value = get_mocked_pricing_data()
mock_bundles.return_value = journal_bundles
response = self.client.get(
path=reverse(
"openedx.journals.bundle_about",
kwargs={'bundle_uuid': str(uuid.uuid4())}
)
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Purchase the Bundle")
self.assertContains(response, journal_bundle["title"])
self.assertContains(response, journal_bundle["courses"][0]["short_description"])
self.assertContains(response, journal_bundle["courses"][0]["course_runs"][0]["title"])
@attr(shard=1)
@mock.patch.dict(settings.FEATURES, {"JOURNALS_ENABLED": True})
class JournalIndexViewTest(SiteMixin, ModuleStoreTestCase):
"""
Tests for Journals Listing in Marketing Pages.
"""
def setUp(self):
super(JournalIndexViewTest, self).setUp()
self.journal_bundles = get_mocked_journal_bundles()
self.journal_bundle = self.journal_bundles[0]
self.journals = get_mocked_journals()
def assert_journal_data(self, response):
"""
Checks the journal data in given response
"""
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Bundle")
self.assertContains(response, self.journal_bundle["uuid"])
self.assertContains(response, self.journal_bundle["title"])
self.assertContains(response, self.journal_bundle["organization"])
for journal in self.journals:
self.assertContains(response, "Journal")
self.assertContains(response, journal["title"])
self.assertContains(response, journal["organization"])
@override_switch(JOURNAL_INTEGRATION, True)
@mock.patch('student.views.management.get_journals_context')
def test_journals_index_page(self, mock_journals_context):
"""
Test the journal data on index page.
"""
mock_journals_context.return_value = {'journal_bundles': self.journal_bundles, 'journals': self.journals}
response = self.client.get(reverse('root'))
self.assert_journal_data(response)
@override_switch(JOURNAL_INTEGRATION, False)
def test_journals_index_page_disabled(self):
"""
Test the index page can load with journals disabled
"""
response = self.client.get(reverse('root'))
self.assertEqual(response.status_code, 200)
@override_switch(JOURNAL_INTEGRATION, True)
@mock.patch('openedx.features.journals.api.DiscoveryApiClient.get_journals')
@mock.patch('openedx.features.journals.api.DiscoveryApiClient.get_journal_bundles')
def test_journals_courses_page(self, mock_journal_bundles, mock_journals):
"""
Test the journal data on courses page.
"""
mock_journal_bundles.return_value = self.journal_bundles
mock_journals.return_value = self.journals
response = self.client.get(reverse('courses'))
self.assert_journal_data(response)
@override_switch(JOURNAL_INTEGRATION, False)
def test_journals_courses_page_disabled(self):
"""
Test the courses pages can load with journals disabled
"""
response = self.client.get(reverse('courses'))
self.assertEqual(response.status_code, 200)

View File

@@ -0,0 +1,139 @@
""" Returns the dummy data for journals endpoint of discovery."""
import uuid
from functools import wraps
from openedx.features.journals.api import WAFFLE_SWITCHES
def override_switch(switch, active):
"""
Overrides the given waffle switch to `active` boolean.
Arguments:
switch(str): switch name
active(bool): A boolean representing (to be overridden) value
"""
def decorate(function):
"""
decorator function
"""
@wraps(function)
def inner(*args, **kwargs):
with WAFFLE_SWITCHES.override(switch, active=active):
function(*args, **kwargs)
return inner
return decorate
def get_mocked_journal_access():
"""
Returns the dummy data of journal access
"""
return [
{
"expiration_date": "2050-11-08",
"uuid": uuid.uuid4(),
"journal": {
"name": "dummy-name1",
"organization": "edx",
"journalaboutpage": {
"slug": "dummy-slug1",
"card_image_absolute_url": "dummy-url"
}
}
},
{
"expiration_date": "2050-10-08",
"uuid": uuid.uuid4(),
"journal": {
"name": "dummy-name2",
"organization": "edx",
"journalaboutpage": {
"slug": "dummy-slug2",
"card_image_absolute_url": "dummy-url"
}
}
}
]
def get_mocked_journal_bundles():
"""
Returns the dummy data of journal bundle.
"""
return [{
"uuid": "1918b738-979f-42cb-bde0-13335366fa86",
"title": "dummy-title",
"partner": "edx",
"organization": "edx",
"journals": [
{
"title": "dummy-title",
"sku": "ASZ1GZ",
"card_image_url": "dummy-url",
"slug": "dummy-title",
"access_length": "8 weeks",
"short_description": "dummy short description"
}
],
"courses": [
{
"short_description": "dummy short description",
"course_runs": [
{
"key": "course-v1:ABC+ABC101+2015_T1",
"title": "Matt edX test course",
"start": "2015-01-08T00:00:00Z",
"end": "2016-12-30T00:00:00Z",
"image": {
"src": "dummy/url"
},
"seats": [
{
"type": "verified",
"sku": "unit03",
"bulk_sku": "2DF467D"
}
]
}
]
}
],
"applicable_seat_types": ["credit", "honor", "verified"]
}]
def get_mocked_journals():
"""
Returns the dummy data of journals
"""
return [
{
"title": "dummy-title1",
"card_image_url": "dummy-url1",
"slug": "dummy-title1",
"access_length": 60,
"organization": "edx"
},
{
"title": "dummy-title2",
"card_image_url": "dummy-url2",
"slug": "dummy-title2",
"access_length": 60,
"organization": "edx"
}
]
def get_mocked_pricing_data():
"""
Returns the dummy data for e-commerce pricing
"""
return {
"currency": "USD",
"discount_value": 0.3,
"is_discounted": False,
"total_incl_tax": 23.01,
"purchase_url": "dummy-url",
"total_incl_tax_excl_discounts": 40
}

View File

@@ -0,0 +1,19 @@
"""
Defines URLs for course bookmarks.
"""
from django.conf.urls import url
from openedx.features.journals.views.marketing import bundle_about
from openedx.features.journals.views import learner_dashboard
urlpatterns = [
url(r'^bundles/{}/about'.format(r'(?P<bundle_uuid>[0-9a-f-]+)',),
bundle_about,
name='openedx.journals.bundle_about'
),
url(r'^$',
learner_dashboard.journal_listing,
name='openedx.journals.dashboard'
),
]

View File

@@ -0,0 +1,103 @@
""" Journal Tab of Learner Dashboard views """
from datetime import datetime, time
import logging
from urlparse import urljoin, urlsplit, urlunsplit
from django.conf import settings
from django.http import Http404
from edxmako.shortcuts import render_to_response
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
from openedx.features.journals.api import fetch_journal_access, journals_enabled
logger = logging.getLogger(__name__)
def journal_listing(request):
""" View a list of journals which the user has or had access to"""
user = request.user
if not journals_enabled() or not user.is_authenticated():
raise Http404
journals = fetch_journal_access(
site=request.site,
user=request.user
)
context = {
'journals': journals,
'show_dashboard_tabs': True,
'show_program_listing': ProgramsApiConfig.is_enabled(),
'show_journal_listing': journals_enabled()
}
return render_to_response('journals/learner_dashboard/journal_dashboard.html', context)
def get_journal_about_page_url(slug=''):
"""
Return url to journal about page.
The url will redirect through the journals service log in page. Otherwise the user may be
sent to a page to purchase the book - and that is an awkward user experience.
Arguments:
slug (str): unique string associated with each journal about page
Returns:
url (str): url points to Journals Service login, w/ a redirect to journal about page
"""
login_url = urljoin(settings.JOURNALS_URL_ROOT, 'login')
about_page_url = urljoin(settings.JOURNALS_URL_ROOT, slug)
query = 'next={next_url}'.format(next_url=about_page_url)
split_url = urlsplit(login_url)
url = urlunsplit((
split_url.scheme,
split_url.netloc,
split_url.path,
query,
split_url.fragment,
))
return url
def format_expiration_date(expiration_date):
"""
Formats Expiration Date
Arguments:
expiration_date (str): in format 'YYYY-mm-dd' (ex. April 26, 2018 is: '2018-26-04')
Returns:
formatted expiration date (str): in format 'Mmm dd YYYY' (ex. April 26, 2018 is: 'Apr 26 2018')
"""
# set expiration date to be the last second of the day it expires
expiration_datetime = datetime.combine(
date=datetime.strptime(expiration_date, '%Y-%m-%d').date(),
time=time.max
)
return expiration_datetime.strftime("%b %d %Y")
def has_access_expired(expiration_date):
"""
Returns true if it is now past the expiration date.
Arguments:
expiration_date (str): in format 'YYYY-mm-dd' (ex. April 26, 2018 is: '2018-26-04')
Returns:
has access expired (boolean): True if access has expired
"""
# set expiration date to be the last second of the day it expires
expiration_datetime = datetime.combine(
date=datetime.strptime(expiration_date, '%Y-%m-%d').date(),
time=time.max
)
now = datetime.today()
return now > expiration_datetime

View File

@@ -0,0 +1,75 @@
""" Journal bundle about page's view """
from django.conf import settings
from django.contrib.auth.models import User
from django.http import Http404
from edxmako.shortcuts import render_to_response
from openedx.core.djangoapps.catalog.models import CatalogIntegration
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client
from openedx.features.journals.api import get_journal_bundles, get_journals_root_url
from lms.djangoapps.commerce.utils import EcommerceService
def bundle_about(request, bundle_uuid):
"""
Journal bundle about page's view.
"""
bundle = get_journal_bundles(request.site, bundle_uuid=bundle_uuid)
if not bundle:
raise Http404
bundle = bundle[0] # get_journal_bundles always returns list of bundles
bundle = extend_bundle(bundle)
context = {
'journals_root_url': get_journals_root_url(),
'discovery_root_url': CatalogIntegration.current().get_internal_api_url(),
'bundle': bundle,
'uses_bootstrap': True,
}
return render_to_response('journals/bundle_about.html', context)
def extend_bundle(bundle):
"""
Extend the pricing data in journal bundle.
"""
applicable_seat_types = bundle['applicable_seat_types']
matching_seats = [
get_matching_seat(course, applicable_seat_types)
for course in bundle['courses']
]
# Remove `None`s from above.
matching_seats = [seat for seat in matching_seats if seat]
course_skus = [seat['sku'] for seat in matching_seats]
journal_skus = [journal['sku'] for journal in bundle['journals']]
all_skus = course_skus + journal_skus
pricing_data = get_pricing_data(all_skus)
bundle.update({
'pricing_data': pricing_data
})
return bundle
def get_matching_seat(course, seat_types):
""" Filtered out the course runs on the bases of applicable_seat_types """
for course_run in course['course_runs']:
for seat in course_run['seats']:
if seat['type'] in seat_types:
return seat
def get_pricing_data(skus):
"""
Get the pricing data from ecommerce for given skus.
"""
user = User.objects.get(username=settings.ECOMMERCE_SERVICE_WORKER_USERNAME)
api = ecommerce_api_client(user)
pricing_data = api.baskets.calculate.get(sku=skus, is_anonymous=True)
discount_value = float(pricing_data['total_incl_tax_excl_discounts']) - float(pricing_data['total_incl_tax'])
ecommerce_service = EcommerceService()
purchase_url = ecommerce_service.get_checkout_page_url(*skus)
pricing_data.update({
'is_discounted': pricing_data['total_incl_tax'] != pricing_data['total_incl_tax_excl_discounts'],
'discount_value': discount_value,
'purchase_url': purchase_url,
})
return pricing_data

View File

@@ -20,6 +20,7 @@ from openedx.core.djangoapps.user_api.errors import UserNotAuthorized, UserNotFo
from openedx.core.djangoapps.user_api.preferences.api import get_user_preferences
from openedx.core.djangoapps.util.user_messages import PageLevelMessages
from openedx.core.djangolib.markup import HTML, Text
from openedx.features.journals.api import journals_enabled
from student.models import User
from .. import SHOW_PROFILE_MESSAGE
@@ -137,6 +138,7 @@ def learner_profile_context(request, profile_username, user_is_staff):
'social_platforms': settings.SOCIAL_PLATFORMS,
},
'show_program_listing': ProgramsApiConfig.is_enabled(),
'show_journal_listing': journals_enabled(),
'show_dashboard_tabs': True,
'disable_courseware_js': True,
'nav_hidden': True,

View File

@@ -69,6 +69,7 @@ setup(
"credentials = openedx.core.djangoapps.credentials.apps:CredentialsConfig",
"discussion = lms.djangoapps.discussion.apps:DiscussionConfig",
"grades = lms.djangoapps.grades.apps:GradesConfig",
"journals = openedx.features.journals.apps:JournalsConfig",
"plugins = openedx.core.djangoapps.plugins.apps:PluginsConfig",
"schedules = openedx.core.djangoapps.schedules.apps:SchedulesConfig",
"theming = openedx.core.djangoapps.theming.apps:ThemingConfig",

View File

@@ -44,6 +44,13 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_
</a>
</div>
% endif
% if show_journal_listing:
<div class="mobile-nav-item hidden-mobile nav-item nav-tab">
<a class="${'active ' if reverse('openedx.journals.dashboard') in request.path else ''}tab-nav-link" href="${reverse('openedx.journals.dashboard')}">
${_("Journals")}
</a>
</div>
% endif
<div class="mobile-nav-item hidden-mobile nav-item nav-tab">
<a class="${'active ' if '/u/' in request.path else ''}tab-nav-link" href="${reverse('learner_profile', args=[self.real_user.username])}">
${_("Profile")}