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 ( + setOpen(false)} + > + Action Required + + + +

+ Courses for {currentProgramEnrollment.title} are + offered on {COURSEWARE_BACKEND_NAMES[missingBackend]}. Continue to + create a new account and link it to your current MicroMasters + account. +

+
+
+
+ + + +
+ ) +} + +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,