diff --git a/courses/models.py b/courses/models.py
index 8a72a87c22..17f5bbc633 100644
--- a/courses/models.py
+++ b/courses/models.py
@@ -30,10 +30,23 @@ def __str__(self):
return self.name
+class ProgramQuerySet(models.QuerySet):
+ """
+ Custom QuerySet for Programs
+ """
+ def prefetch_course_runs(self):
+ """Returns a new query that prefetches the course runs"""
+ return self.prefetch_related(
+ models.Prefetch("course_set__courserun_set", to_attr="course_runs")
+ )
+
+
class Program(TimestampedModel):
"""
A degree someone can pursue, e.g. "Supply Chain Management"
"""
+ objects = ProgramQuerySet.as_manager()
+
title = models.CharField(max_length=255)
live = models.BooleanField(default=False)
description = models.TextField(blank=True, null=True)
@@ -54,11 +67,22 @@ def has_frozen_grades_for_all_courses(self):
"""
return all([course.has_frozen_runs() for course in self.course_set.all()])
+ @property
+ def course_runs(self):
+ """ Return the set of course runs """
+ return CourseRun.objects.filter(course__program=self)
+
+ @property
+ def courseware_backends(self):
+ """ Return the set of courseware backends """
+ return list({run.courseware_backend for run in self.course_runs})
+
+ @property
def has_mitxonline_courses(self):
"""
Return true if as least one course has at least one run that is on mitxonline
"""
- return CourseRun.objects.filter(course__program=self, courseware_backend=BACKEND_MITX_ONLINE).exists()
+ return BACKEND_MITX_ONLINE in self.courseware_backends
class Course(models.Model):
diff --git a/courses/serializers.py b/courses/serializers.py
index 98c396fa71..e7e91d431e 100644
--- a/courses/serializers.py
+++ b/courses/serializers.py
@@ -60,6 +60,7 @@ class Meta:
'enrolled',
'total_courses',
'topics',
+ 'courseware_backends',
)
diff --git a/courses/serializers_test.py b/courses/serializers_test.py
index 3d16d3d4b5..9f48b128a2 100644
--- a/courses/serializers_test.py
+++ b/courses/serializers_test.py
@@ -82,6 +82,7 @@ def setUpTestData(cls):
super().setUpTestData()
cls.program = ProgramFactory.create()
+ cls.course_run = CourseRunFactory.create(course__program=cls.program)
cls.user = UserFactory.create()
cls.context = {
"request": Mock(user=cls.user)
@@ -97,8 +98,9 @@ def test_program_no_programpage(self):
'title': self.program.title,
'programpage_url': None,
'enrolled': False,
- 'total_courses': 0,
- 'topics': [{'name': topic.name} for topic in self.program.topics.iterator()]
+ 'total_courses': 1,
+ 'topics': [{'name': topic.name} for topic in self.program.topics.iterator()],
+ "courseware_backends": ["edxorg"],
}
def test_program_with_programpage(self):
@@ -114,8 +116,9 @@ def test_program_with_programpage(self):
'title': self.program.title,
'programpage_url': programpage.get_full_url(),
'enrolled': False,
- 'total_courses': 0,
- 'topics': [{'name': topic.name} for topic in self.program.topics.iterator()]
+ 'total_courses': 1,
+ 'topics': [{'name': topic.name} for topic in self.program.topics.iterator()],
+ "courseware_backends": ["edxorg"],
}
assert len(programpage.url) > 0
@@ -130,8 +133,9 @@ def test_program_enrolled(self):
'title': self.program.title,
'programpage_url': None,
'enrolled': True,
- 'total_courses': 0,
- 'topics': [{'name': topic.name} for topic in self.program.topics.iterator()]
+ 'total_courses': 1,
+ 'topics': [{'name': topic.name} for topic in self.program.topics.iterator()],
+ "courseware_backends": ["edxorg"],
}
def test_program_courses(self):
@@ -145,8 +149,9 @@ def test_program_courses(self):
'title': self.program.title,
'programpage_url': None,
'enrolled': False,
- 'total_courses': 5,
- 'topics': [{'name': topic.name} for topic in self.program.topics.iterator()]
+ 'total_courses': 6,
+ 'topics': [{'name': topic.name} for topic in self.program.topics.iterator()],
+ "courseware_backends": ["edxorg"],
}
diff --git a/courses/views.py b/courses/views.py
index 053247f395..49f85817e7 100644
--- a/courses/views.py
+++ b/courses/views.py
@@ -39,7 +39,7 @@ class ProgramViewSet(viewsets.ReadOnlyModelViewSet):
permission_classes = (
IsAuthenticated,
)
- queryset = Program.objects.filter(live=True)
+ queryset = Program.objects.filter(live=True).prefetch_course_runs()
serializer_class = ProgramSerializer
diff --git a/micromasters/serializers.py b/micromasters/serializers.py
index b4c86ac9b1..17f4af3266 100644
--- a/micromasters/serializers.py
+++ b/micromasters/serializers.py
@@ -18,12 +18,14 @@ class UserSerializer(serializers.ModelSerializer):
first_name = serializers.SerializerMethodField()
last_name = serializers.SerializerMethodField()
preferred_name = serializers.SerializerMethodField()
+ social_auth_providers = serializers.SerializerMethodField()
class Meta:
model = User
fields = (
"username", "email",
"first_name", "last_name", "preferred_name",
+ "social_auth_providers",
)
def get_username(self, obj):
@@ -59,6 +61,12 @@ def get_preferred_name(self, obj):
except ObjectDoesNotExist:
return None
+ def get_social_auth_providers(self, obj):
+ """
+ Get the list of social auth providers
+ """
+ return list(obj.social_auth.values_list("provider", flat=True).distinct())
+
def serialize_maybe_user(user):
"""
diff --git a/micromasters/serializers_test.py b/micromasters/serializers_test.py
index cc18e92f98..8750ed102c 100644
--- a/micromasters/serializers_test.py
+++ b/micromasters/serializers_test.py
@@ -29,6 +29,7 @@ def test_basic_user(self):
"first_name": None,
"last_name": None,
"preferred_name": None,
+ "social_auth_providers": [],
}
def test_logged_in_user_through_maybe_wrapper(self):
@@ -45,6 +46,7 @@ def test_logged_in_user_through_maybe_wrapper(self):
"first_name": None,
"last_name": None,
"preferred_name": None,
+ "social_auth_providers": [],
}
def test_user_with_profile(self):
@@ -67,6 +69,7 @@ def test_user_with_profile(self):
"first_name": "Rando",
"last_name": "Cardrizzian",
"preferred_name": "Hobo",
+ "social_auth_providers": [],
}
diff --git a/static/js/components/CouponNotificationDialog_test.js b/static/js/components/CouponNotificationDialog_test.js
index 0261f3927a..b6c66392c5 100644
--- a/static/js/components/CouponNotificationDialog_test.js
+++ b/static/js/components/CouponNotificationDialog_test.js
@@ -82,11 +82,12 @@ const COURSE: Course = {
}
const PROGRAM: AvailableProgram = {
- id: 1,
- title: "Awesomesauce",
- enrolled: true,
- programpage_url: null,
- total_courses: 0
+ id: 1,
+ title: "Awesomesauce",
+ enrolled: true,
+ programpage_url: null,
+ total_courses: 0,
+ courseware_backends: ["edxorg"]
}
describe("CouponNotificationDialog", () => {
diff --git a/static/js/components/SocialAuthDialog.js b/static/js/components/SocialAuthDialog.js
new file mode 100644
index 0000000000..32e096c86e
--- /dev/null
+++ b/static/js/components/SocialAuthDialog.js
@@ -0,0 +1,71 @@
+/* global SETTINGS: false */
+import React, { useState, useEffect } from "react"
+import Button from "@material-ui/core/Button"
+import Dialog from "@material-ui/core/Dialog"
+import DialogActions from "@material-ui/core/DialogActions"
+import DialogContent from "@material-ui/core/DialogContent"
+import DialogTitle from "@material-ui/core/DialogTitle"
+import Grid from "@material-ui/core/Grid"
+import R from "ramda"
+
+import { COURSEWARE_BACKEND_NAMES } from "../constants"
+
+import type { AvailableProgram } from "../flow/enrollmentTypes"
+
+type Props = {
+ currentProgramEnrollment: AvailableProgram
+}
+
+const SocialAuthDialog = (props: Props) => {
+ const { currentProgramEnrollment } = props
+ const [open, setOpen] = useState(false)
+ const missingBackend = R.head(
+ R.difference(
+ R.propOr([], "courseware_backends", currentProgramEnrollment),
+ R.propOr([], "social_auth_providers", SETTINGS.user)
+ )
+ )
+
+ useEffect(() => {
+ setOpen(!R.isNil(currentProgramEnrollment) && !R.isNil(missingBackend))
+ }, [currentProgramEnrollment, missingBackend])
+
+ if (R.isNil(currentProgramEnrollment)) {
+ return null
+ }
+
+ return (
+
+ )
+}
+
+export default SocialAuthDialog
diff --git a/static/js/components/SocialAuthDialog_test.js b/static/js/components/SocialAuthDialog_test.js
new file mode 100644
index 0000000000..a8a1f1c799
--- /dev/null
+++ b/static/js/components/SocialAuthDialog_test.js
@@ -0,0 +1,87 @@
+// @flow
+/* global SETTINGS: false */
+import React from "react"
+import { mount } from "enzyme"
+import { assert } from "chai"
+import Button from "@material-ui/core/Button"
+import Grid from "@material-ui/core/Grid"
+import { MuiThemeProvider, createMuiTheme } from "@material-ui/core/styles"
+
+import SocialAuthDialog from "./SocialAuthDialog"
+import { COURSEWARE_BACKEND_NAMES } from "../constants"
+import { makeAvailableProgram } from "../factories/dashboard"
+
+import type { AvailableProgram } from "../flow/enrollmentTypes"
+
+describe("SocialAuthDialog", () => {
+ const renderDialog = (enrollment: AvailableProgram) =>
+ mount(
+
+
+
+ )
+ .find(SocialAuthDialog)
+ .children()
+
+ Object.entries(COURSEWARE_BACKEND_NAMES).forEach(
+ ([missingBackend, backendLabel]) => {
+ describe(`for missing backend '${missingBackend}'`, () => {
+ let authenticatedEnrollment,
+ unauthenticatedEnrollment,
+ availablePrograms
+
+ beforeEach(() => {
+ availablePrograms = Object.keys(COURSEWARE_BACKEND_NAMES)
+ SETTINGS.user.social_auth_providers = availablePrograms.filter(
+ backend => backend !== missingBackend
+ )
+ authenticatedEnrollment = makeAvailableProgram(
+ undefined,
+ availablePrograms
+ )
+ unauthenticatedEnrollment = makeAvailableProgram(
+ undefined,
+ availablePrograms.filter(backend => backend === missingBackend)
+ )
+ })
+
+ it("should be closed if the learner is authenticated with the backend", () => {
+ SETTINGS.user.social_auth_providers = availablePrograms
+ const wrapper = renderDialog(authenticatedEnrollment)
+ assert.isBoolean(wrapper.prop("open"))
+ assert.notOk(wrapper.prop("open"))
+ })
+
+ it("should be open if the learner is not authenticated with the backend", () => {
+ const wrapper = renderDialog(unauthenticatedEnrollment)
+ assert.isBoolean(wrapper.prop("open"))
+ assert.ok(wrapper.prop("open"))
+ })
+
+ it("should have a continue button linking to the social auth login url", () => {
+ const wrapper = renderDialog(unauthenticatedEnrollment)
+ const btn = wrapper.find(Button)
+ assert.ok(btn.exists())
+ assert.equal(btn.prop("href"), `/login/${missingBackend}/`)
+ assert.equal(btn.text(), `Continue to ${String(backendLabel)}`)
+ })
+
+ it("should have a description of what the learner needs to do", () => {
+ const wrapper = renderDialog(unauthenticatedEnrollment)
+ const text = wrapper
+ .find(Grid)
+ .at(0)
+ .text()
+ assert.equal(
+ text,
+ `Courses for ${
+ unauthenticatedEnrollment.title
+ } are offered on ${String(
+ backendLabel
+ )}. Continue to create a new account and link it to your current MicroMasters account.`
+ )
+ })
+ })
+ }
+ )
+})
diff --git a/static/js/components/dashboard/courses/ProgressMessage.js b/static/js/components/dashboard/courses/ProgressMessage.js
index bff221aa1b..3dd16a30b8 100644
--- a/static/js/components/dashboard/courses/ProgressMessage.js
+++ b/static/js/components/dashboard/courses/ProgressMessage.js
@@ -13,7 +13,8 @@ import {
STATUS_MISSED_DEADLINE,
STATUS_CURRENTLY_ENROLLED,
STATUS_PAID_BUT_NOT_ENROLLED,
- DASHBOARD_FORMAT
+ DASHBOARD_FORMAT,
+ COURSEWARE_BACKEND_NAMES
} from "../../../constants"
import { renderSeparatedComponents } from "../../../util/util"
import { courseRunUrl } from "../../../util/courseware"
@@ -145,7 +146,7 @@ export default class ProgressMessage extends React.Component {
target="_blank"
rel="noopener noreferrer"
>
- View on edX
+ View on {COURSEWARE_BACKEND_NAMES[courseRun.courseware_backend]}
) : null
}
diff --git a/static/js/components/dashboard/courses/ProgressMessage_test.js b/static/js/components/dashboard/courses/ProgressMessage_test.js
index 2d5944e5d0..faffd87d77 100644
--- a/static/js/components/dashboard/courses/ProgressMessage_test.js
+++ b/static/js/components/dashboard/courses/ProgressMessage_test.js
@@ -22,7 +22,8 @@ import {
STATUS_MISSED_DEADLINE,
STATUS_PAID_BUT_NOT_ENROLLED,
STATUS_PASSED,
- STATUS_NOT_PASSED
+ STATUS_NOT_PASSED,
+ COURSEWARE_BACKEND_NAMES
} from "../../../constants"
import { courseRunUrl } from "../../../util/courseware"
import { courseStartDateMessage } from "./util"
@@ -63,17 +64,20 @@ describe("Course ProgressMessage", () => {
assert.equal(wrapper.find(".details").text(), "Course in progress")
})
- it("displays a contact link, if appropriate, and a view on edX link", () => {
- makeRunEnrolled(course.runs[0])
- makeRunCurrent(course.runs[0])
- course.has_contact_email = true
- const wrapper = renderCourseDescription()
- const [edxLink, contactLink] = wrapper.find("a")
- assert.equal(edxLink.props.href, courseRunUrl(course.runs[0]))
- assert.equal(edxLink.props.target, "_blank")
- assert.equal(edxLink.props.children, "View on edX")
- assert.equal(contactLink.props.onClick, openCourseContactDialogStub)
- assert.equal(contactLink.props.children, "Contact Course Team")
+ Object.entries(COURSEWARE_BACKEND_NAMES).forEach(([name, label]) => {
+ it("displays a contact link, if appropriate, and a view on edX link", () => {
+ makeRunEnrolled(course.runs[0])
+ makeRunCurrent(course.runs[0])
+ course.runs[0].courseware_backend = name
+ course.has_contact_email = true
+ const wrapper = renderCourseDescription()
+ const [edxLink, contactLink] = wrapper.find("a")
+ assert.equal(edxLink.props.href, courseRunUrl(course.runs[0]))
+ assert.equal(edxLink.props.target, "_blank")
+ assert.deepEqual(edxLink.props.children, ["View on ", label])
+ assert.equal(contactLink.props.onClick, openCourseContactDialogStub)
+ assert.equal(contactLink.props.children, "Contact Course Team")
+ })
})
it("does not display contact course team or view on edX if staff user", () => {
diff --git a/static/js/containers/App.js b/static/js/containers/App.js
index e333871929..78e760cedf 100644
--- a/static/js/containers/App.js
+++ b/static/js/containers/App.js
@@ -10,6 +10,7 @@ import { TOAST_FAILURE } from "../constants"
import ErrorMessage from "../components/ErrorMessage"
import Navbar from "../components/Navbar"
import Toast from "../components/Toast"
+import SocialAuthDialog from "../components/SocialAuthDialog"
import { FETCH_SUCCESS, FETCH_FAILURE } from "../actions"
import {
fetchUserProfile,
@@ -274,6 +275,7 @@ class App extends React.Component {
/>
{this.renderToast()}
{children}
+
)
}
diff --git a/static/js/factories/dashboard.js b/static/js/factories/dashboard.js
index 9c27ed13e5..200eaa4a3e 100644
--- a/static/js/factories/dashboard.js
+++ b/static/js/factories/dashboard.js
@@ -54,16 +54,32 @@ export const makeDashboard = (): Dashboard => {
return { programs: programs, is_edx_data_fresh: true }
}
+export const makeAvailableProgram = (
+ enrolled: boolean = true,
+ backends: Array = ["edxorg"]
+) => {
+ const programId = newProgramId()
+ return {
+ enrolled: enrolled,
+ id: programId,
+ programpage_url: `/page/${programId}`,
+ title: `AvailableProgram for ${programId}`,
+ total_courses: 1,
+ courseware_backends: backends
+ }
+}
+
export const makeAvailablePrograms = (
dashboard: Dashboard,
enrolled: boolean = true
): AvailablePrograms => {
return dashboard.programs.map(program => ({
- enrolled: enrolled,
- id: program.id,
- programpage_url: `/page/${program.id}`,
- title: `AvailableProgram for ${program.id}`,
- total_courses: 1
+ enrolled: enrolled,
+ id: program.id,
+ programpage_url: `/page/${program.id}`,
+ title: `AvailableProgram for ${program.id}`,
+ total_courses: 1,
+ courseware_backends: ["edxorg"]
}))
}
diff --git a/static/js/flow/declarations.js b/static/js/flow/declarations.js
index 31f72d3eb7..944133e241 100644
--- a/static/js/flow/declarations.js
+++ b/static/js/flow/declarations.js
@@ -8,6 +8,7 @@ declare var SETTINGS: {
username: string,
user: {
username: string,
+ social_auth_providers: Array,
},
host: string,
edx_base_url: string,
diff --git a/static/js/flow/enrollmentTypes.js b/static/js/flow/enrollmentTypes.js
index afd09773fb..f47eccfdac 100644
--- a/static/js/flow/enrollmentTypes.js
+++ b/static/js/flow/enrollmentTypes.js
@@ -7,6 +7,7 @@ export type AvailableProgram = {
programpage_url: ?string,
enrolled: boolean,
total_courses: ?number,
+ courseware_backends: Array,
}
export type AvailablePrograms = Array
diff --git a/ui/decorators.py b/ui/decorators.py
index 889100053e..44bc786f9d 100644
--- a/ui/decorators.py
+++ b/ui/decorators.py
@@ -3,13 +3,8 @@
"""
from functools import wraps
-from django.conf import settings
-from django.db.models import Exists, OuterRef
from django.shortcuts import redirect
-from django.urls import reverse
-from backends.constants import BACKEND_MITX_ONLINE
-from courses.models import Program, CourseRun
from ui.url_utils import (
PROFILE_URL,
)
@@ -35,38 +30,3 @@ def wrapper(request, *args, **kwargs):
return func(request, *args, **kwargs)
return wrapper
-
-
-def require_mitxonline_auth(func):
- """
- If user is in a program that has at least one mitxonline course run.
- """
- @wraps(func)
- def wrapper(request, *args, **kwargs):
- """
- Wrapper for checking mitxonline auth status
-
- Args:
- request (django.http.request.HttpRequest): A request
- """
- user = request.user
-
- if (
- settings.FEATURES.get("MITXONLINE_LOGIN", False)
- and user.is_authenticated
- and Program.objects.annotate(
- has_mitxonline_courserun=Exists(CourseRun.objects.filter(
- course__program=OuterRef('pk'),
- courseware_backend=BACKEND_MITX_ONLINE
- ))
- ).filter(
- has_mitxonline_courserun=True,
- programenrollment__user=user
- ).exists()
- and not user.social_auth.filter(provider=BACKEND_MITX_ONLINE).exists()
- ):
- return redirect(reverse("mitx-online-required"))
-
- return func(request, *args, **kwargs)
-
- return wrapper
diff --git a/ui/urls.py b/ui/urls.py
index 9f69e5db03..3a353e65d0 100644
--- a/ui/urls.py
+++ b/ui/urls.py
@@ -12,7 +12,6 @@
DashboardView,
UsersView,
SignInView,
- MitxOnlineRequiredView,
terms_of_service,
page_404,
page_500,
@@ -36,7 +35,6 @@
urlpatterns = [
url(r'^logout/$', auth_views.LogoutView.as_view(), name='logout'),
url(r'^signin/$', SignInView.as_view(), name='signin'),
- url(r'^signin/mitxonline$', MitxOnlineRequiredView.as_view(), name='mitx-online-required'),
url(r'^404/$', page_404, name='ui-404'),
url(r'^500/$', page_500, name='ui-500'),
url(r'^verify-email/$', need_verified_email, name='verify-email'),
diff --git a/ui/views.py b/ui/views.py
index e09dc52489..b05ed7e936 100644
--- a/ui/views.py
+++ b/ui/views.py
@@ -7,7 +7,6 @@
from django.conf import settings
from django.contrib.auth.decorators import login_required
-from django.db.models import Exists, OuterRef
from django.urls import reverse
from django.shortcuts import Http404, redirect, render
from django.utils.decorators import method_decorator
@@ -16,15 +15,14 @@
from rolepermissions.permissions import available_perm_status
from rolepermissions.checkers import has_role
-from backends.constants import BACKEND_MITX_ONLINE
from cms.util import get_coupon_code
-from courses.models import Program, Course, CourseRun
+from courses.models import Program, Course
from ecommerce.models import Coupon
from micromasters.utils import webpack_dev_server_host
from micromasters.serializers import serialize_maybe_user
from profiles.permissions import CanSeeIfNotPrivate
from roles.models import Instructor, Staff
-from ui.decorators import require_mandatory_urls, require_mitxonline_auth
+from ui.decorators import require_mandatory_urls
from ui.templatetags.render_bundle import public_path
log = logging.getLogger(__name__)
@@ -105,7 +103,6 @@ def post(self, request, *args, **kwargs): # pylint: disable=unused-argument
return redirect(request.build_absolute_uri())
-@method_decorator(require_mitxonline_auth, name='dispatch')
@method_decorator(require_mandatory_urls, name='dispatch')
@method_decorator(login_required, name='dispatch')
@method_decorator(csrf_exempt, name='dispatch')
@@ -202,35 +199,6 @@ def get(self, request, *args, **kwargs): # pylint: disable=unused-argument
)
-# @method_decorator(login_required, name='dispatch')
-class MitxOnlineRequiredView(ReactView):
- """View to notify the learner that it's required to signin via mitx online"""
- template_name = "mitx-online-required.html"
-
- def get_context(self, request):
- """
- Get the context for the view
-
- Args:
- request (Request): the incoming request
-
- Returns:
- dict: the context object as a dictionary
- """
- return {
- **super().get_context(request),
- "mitxonline_programs": Program.objects.annotate(
- has_mitxonline_courserun=Exists(CourseRun.objects.filter(
- course__program=OuterRef('pk'),
- courseware_backend=BACKEND_MITX_ONLINE
- ))
- ).filter(
- has_mitxonline_courserun=True,
- programenrollment__user=request.user
- )
- }
-
-
def standard_error_page(request, status_code, template_filename):
"""
Returns an error page with a given template filename and provides necessary context variables
diff --git a/ui/views_test.py b/ui/views_test.py
index 56fc25fe20..2a9f40e699 100644
--- a/ui/views_test.py
+++ b/ui/views_test.py
@@ -275,6 +275,7 @@ def test_dashboard_settings(self):
'first_name': profile.first_name,
'last_name': profile.last_name,
'preferred_name': profile.preferred_name,
+ "social_auth_providers": ["edxorg", "not_edx"],
},
'host': host,
'edx_base_url': edx_base_url,
@@ -728,6 +729,7 @@ def test_users_logged_in(self):
'first_name': profile.first_name,
'last_name': profile.last_name,
'preferred_name': profile.preferred_name,
+ "social_auth_providers": ["edxorg", "not_edx"],
},
'host': host,
'edx_base_url': edx_base_url,