Merge pull request #18395 from edx/whitelabel/journal
Add journals support in LMS
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
// features
|
||||
@import 'features/bookmarks-v1';
|
||||
@import 'features/learner-profile';
|
||||
@import 'features/journals';
|
||||
|
||||
// search
|
||||
@import 'search/search';
|
||||
|
||||
@@ -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';
|
||||
|
||||
173
lms/static/sass/features/_journals.scss
Normal file
173
lms/static/sass/features/_journals.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]:
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
37
openedx/features/journals/README.rst
Normal file
37
openedx/features/journals/README.rst
Normal 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.
|
||||
0
openedx/features/journals/__init__.py
Normal file
0
openedx/features/journals/__init__.py
Normal file
310
openedx/features/journals/api.py
Normal file
310
openedx/features/journals/api.py
Normal 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
|
||||
30
openedx/features/journals/apps.py
Normal file
30
openedx/features/journals/apps.py
Normal 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'},
|
||||
}
|
||||
}
|
||||
}
|
||||
0
openedx/features/journals/settings/__init__.py
Normal file
0
openedx/features/journals/settings/__init__.py
Normal file
11
openedx/features/journals/settings/aws.py
Normal file
11
openedx/features/journals/settings/aws.py
Normal 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)
|
||||
12
openedx/features/journals/settings/common.py
Normal file
12
openedx/features/journals/settings/common.py
Normal 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')
|
||||
11
openedx/features/journals/settings/devstack.py
Normal file
11
openedx/features/journals/settings/devstack.py
Normal 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'
|
||||
9
openedx/features/journals/settings/test.py
Normal file
9
openedx/features/journals/settings/test.py
Normal 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
|
||||
164
openedx/features/journals/templates/journals/bundle_about.html
Normal file
164
openedx/features/journals/templates/journals/bundle_about.html
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
0
openedx/features/journals/tests/__init__.py
Normal file
0
openedx/features/journals/tests/__init__.py
Normal file
74
openedx/features/journals/tests/test_learner_dashboard.py
Normal file
74
openedx/features/journals/tests/test_learner_dashboard.py
Normal 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)
|
||||
128
openedx/features/journals/tests/test_marketing_views.py
Normal file
128
openedx/features/journals/tests/test_marketing_views.py
Normal 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)
|
||||
139
openedx/features/journals/tests/utils.py
Normal file
139
openedx/features/journals/tests/utils.py
Normal 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
|
||||
}
|
||||
19
openedx/features/journals/urls.py
Normal file
19
openedx/features/journals/urls.py
Normal 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'
|
||||
),
|
||||
]
|
||||
0
openedx/features/journals/views/__init__.py
Normal file
0
openedx/features/journals/views/__init__.py
Normal file
103
openedx/features/journals/views/learner_dashboard.py
Normal file
103
openedx/features/journals/views/learner_dashboard.py
Normal 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
|
||||
75
openedx/features/journals/views/marketing.py
Normal file
75
openedx/features/journals/views/marketing.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
1
setup.py
1
setup.py
@@ -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",
|
||||
|
||||
@@ -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")}
|
||||
|
||||
Reference in New Issue
Block a user