Terms of Service
+ ${tos_html} ++ By clicking "Agree" I am representing that I have read the + above Terms, and expressly acknowledge and agree to the + above Terms. +
+ + +diff --git a/common/djangoapps/student/helpers.py b/common/djangoapps/student/helpers.py index 71b46d5af5ec..e2385e896fbc 100644 --- a/common/djangoapps/student/helpers.py +++ b/common/djangoapps/student/helpers.py @@ -56,6 +56,7 @@ from openedx.core.djangoapps.user_authn.utils import is_safe_login_or_logout_redirect from xmodule.data import CertificatesDisplayBehaviors # lint-amnesty, pylint: disable=wrong-import-order + # Enumeration of per-course verification statuses # we display on the student dashboard. VERIFY_STATUS_NEED_TO_VERIFY = "verify_need_to_verify" diff --git a/common/djangoapps/student/views/dashboard.py b/common/djangoapps/student/views/dashboard.py index 342fefe24e57..54f432c22562 100644 --- a/common/djangoapps/student/views/dashboard.py +++ b/common/djangoapps/student/views/dashboard.py @@ -61,6 +61,7 @@ ) from common.djangoapps.util.milestones_helpers import get_pre_requisite_courses_not_completed from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order +from openedx.features.termsofservice.api.v1 import views as tos_views log = logging.getLogger("edx.student") @@ -816,6 +817,10 @@ def student_dashboard(request): # lint-amnesty, pylint: disable=too-many-statem # TODO START: clean up as part of REVEM-199 (END) } + if settings.FEATURES.get('ENABLE_TERMSOFSERVICE'): + tos_modal = tos_views.terms_of_service_api(request) + context.update({'tos_modal': tos_modal}) + # Include enterprise learner portal metadata and messaging enterprise_learner_portal_context = get_enterprise_learner_portal_context(request) context.update(enterprise_learner_portal_context) diff --git a/lms/djangoapps/static_template_view/views.py b/lms/djangoapps/static_template_view/views.py index f63cb5817108..2c81800c4050 100644 --- a/lms/djangoapps/static_template_view/views.py +++ b/lms/djangoapps/static_template_view/views.py @@ -7,7 +7,6 @@ import mimetypes - from django.conf import settings from django.http import Http404, HttpResponse, HttpResponseNotFound, HttpResponseServerError from django.shortcuts import redirect @@ -22,7 +21,7 @@ from common.djangoapps.util.cache import cache_if_anonymous from common.djangoapps.util.views import fix_crum_request from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers - +from openedx.features.termsofservice import views as tos_views valid_templates = [] if settings.STATIC_GRAB: @@ -59,6 +58,11 @@ def render(request, template): # This is necessary for the dialog presented with the TOS in /register if template == 'honor.html': context['allow_iframing'] = True + + if settings.FEATURES.get('ENABLE_TERMSOFSERVICE'): + latest_tos_html = tos_views.latest_terms_of_service() + context['tos_html'] = latest_tos_html + # Format Examples: static_template_about_header configuration_base = 'static_template_' + template.replace('.html', '').replace('-', '_') page_header = configuration_helpers.get_value(configuration_base + '_header') diff --git a/lms/static/sass/_build-lms-v1.scss b/lms/static/sass/_build-lms-v1.scss index 1171e3d14cdf..41733bc1d8b8 100644 --- a/lms/static/sass/_build-lms-v1.scss +++ b/lms/static/sass/_build-lms-v1.scss @@ -73,6 +73,7 @@ @import 'features/_unsupported-browser-alert'; @import 'features/content-type-gating'; @import 'features/course-duration-limits'; +@import 'features/termsofservice'; // search @import 'search/search'; diff --git a/lms/static/sass/features/_termsofservice.scss b/lms/static/sass/features/_termsofservice.scss new file mode 100644 index 000000000000..3cb46c499294 --- /dev/null +++ b/lms/static/sass/features/_termsofservice.scss @@ -0,0 +1,105 @@ +.modal-tos { + position: fixed; + top: 0; + left: 0; + width:100%; + height: 100%; + background: rgba(0, 0, 0, 0.6); + display: inline-block; + } + +.modal-main-tos { + position:fixed; + background: white; + width: 80%; + height: auto; + top:50%; + left:50%; + transform: translate(-50%,-50%); + border-radius: 10px; + + form { + margin-bottom: 20px; + } +} + +.scrollable_tos_style { + height: 58vh; + width: auto; + overflow-x: hidden; + overflow-y: auto; + padding: 6px; + } + +.display-block { + display: block; + } + +.display-none { + display: none; + } + + .tos-part { + margin-top: 15px !important; +} + +.tos-section { + list-style: none !important; + padding: 0px !important; +} + +.tos-section-item { + display:block !important; + margin-bottom: 15px !important; +} + +.tos-section-item > span { + display:block !important; + font-weight: bold !important; + // position: absolute !important; +} + +.tos-section-item > p { + margin-top: -18px; + margin-left: 40px !important; + display:block; + // display: inline-block !important; + // position: relative !important; + /*top: -21px !important;*/ +} + + +.tos-section-alpha { + list-style: none !important; + counter-reset: tos-section-alpha-counter; +} + +.tos-section-alpha > li { + margin: 10px 0; +} + +.tos-section-alpha > li::after { + min-width: 30px !important; +} + +.tos-section-alpha > li::before { + content: '(' counter(tos-section-alpha-counter, lower-alpha) ')'; + counter-increment: tos-section-alpha-counter; + display:inline-block !important; + display: table-cell; + text-align: right; + + + // position: absolute !important; + /*min-width: 20px !important;*/ +} + +.tos-section-alpha > li p { + margin-left: 40px !important; + // display: inline-block !important; + margin-top: -18px; + + // position: relative !important; + /*top: -21px !important;*/ +} + diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index a09845b0163a..b7bf80f3da07 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -400,3 +400,12 @@
- <%block name="pagecontent">${page_content or _("This page left intentionally blank. Feel free to add your own content.")}%block> -
-+ By clicking "Agree" I am representing that I have read the + above Terms, and expressly acknowledge and agree to the + above Terms. +
+ + +Sample terms of service
', curf_id='test-2023-01') + tos.save() + + # get the previously created terms of service object + tos = apps.get_model('termsofservice', 'TermsOfService').objects.get(curf_id='test-2023-01') + + if settings.FEATURES.get('ENABLE_TERMSOFSERVICE_PER_SUBSITE'): + site_name = configuration_helpers.get_value("SITE_NAME", settings.SITE_NAME) + Site = apps.get_model('sites', 'Site') + cur_site = Site.objects.get(domain=site_name) + + TermsOfServiceSites = apps.get_model('termsofservice', 'TermsOfServiceSites') + tos_sites = TermsOfServiceSites.objects.create(site_id=cur_site.id, curf=tos) + tos_sites.save() + else: + TermsOfServiceAllSites = apps.get_model('termsofservice', 'TermsOfServiceAllSites') + + # create a sample terms of service all sites object + tos_all_sites = TermsOfServiceAllSites(curf=tos) + tos_all_sites.save() + + def backwards(apps, schema_editor): + TermsOfService = apps.get_model('termsofservice', 'TermsOfService') + tos = TermsOfService.objects.get(curf_id='test-2023-01') + + if settings.FEATURES.get('ENABLE_TERMSOFSERVICE_PER_SUBSITE'): + site_name = configuration_helpers.get_value("SITE_NAME", settings.SITE_NAME) + Site = apps.get_model('sites', 'Site') + cur_site = Site.objects.get(domain=site_name) + + TermsOfServiceSites = apps.get_model('termsofservice', 'TermsOfServiceSites') + try: + tos_sites = TermsOfServiceSites.objects.get(site=cur_site.id, curf=tos) + tos_sites.delete() + tos.delete() + except (TermsOfServiceSites.DoesNotExist, TermsOfService.DoesNotExist) as error: + print(f"Error: {error}") + else: + TermsOfServiceAllSites = apps.get_model('termsofservice', 'TermsOfServiceAllSites') + try: + tos_all_sites = TermsOfServiceAllSites.objects.get(curf=tos) + tos_all_sites.delete() + tos.delete() + except (TermsOfServiceAllSites.DoesNotExist, TermsOfService.DoesNotExist) as error: + print(f"Error: {error}") + + operations = [ + migrations.RunPython(forwards, backwards), + ] \ No newline at end of file diff --git a/openedx/features/termsofservice/migrations/__init__.py b/openedx/features/termsofservice/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/features/termsofservice/models.py b/openedx/features/termsofservice/models.py new file mode 100644 index 000000000000..3cf7044ae5e3 --- /dev/null +++ b/openedx/features/termsofservice/models.py @@ -0,0 +1,58 @@ +# pylint: disable=missing-module-docstring + +from django.db import models +from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user +from django.contrib.sites.models import Site +# Create your models here. + + +class TermsOfService(models.Model): + """ + Stores the Terms of Service Versions + """ + class Meta: + app_label = 'termsofservice' + + # View the object in Django Apps as Curf ID + def __str__(self): + return self.curf_id + + date_modified = models.DateTimeField() + terms_of_service_text = models.TextField() + curf_id = models.CharField(unique=True, max_length=25) + + +class TermsOfServiceAcknowledgement(models.Model): + """ + Model to keep track of a user's agreement to the latest terms and conditions + """ + class Meta: + app_label = 'termsofservice' + + user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE) + curf = models.ForeignKey(TermsOfService, to_field="curf_id", on_delete=models.CASCADE) + + +# TermsOfServiceSites +class TermsOfServiceSites(models.Model): + """ + Model to link a site with its active Terms of Service (Linked via curf_id) + """ + class Meta: + app_label = 'termsofservice' + verbose_name = 'TOS Site' + + site = models.OneToOneField(Site, primary_key=True, on_delete=models.CASCADE) + curf = models.ForeignKey(TermsOfService, to_field="curf_id", on_delete=models.CASCADE) + + +# TermsOfServiceAllSites - holds the default site - This model holds only one default object +class TermsOfServiceAllSites(models.Model): + """ + Model to assign all sites (platform) to a Terms of Service (Linked curf_id) + """ + class Meta: + app_label = 'termsofservice' + verbose_name = 'TermsOfServiceAllSite' + + curf = models.OneToOneField(TermsOfService, to_field="curf_id", on_delete=models.CASCADE) diff --git a/openedx/features/termsofservice/settings/__init__.py b/openedx/features/termsofservice/settings/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/features/termsofservice/settings/common.py b/openedx/features/termsofservice/settings/common.py new file mode 100644 index 000000000000..a94cf8aeb46e --- /dev/null +++ b/openedx/features/termsofservice/settings/common.py @@ -0,0 +1,28 @@ +"""Common settings for Terms of Service""" + + +def plugin_settings(settings): + """ + Common settings for Terms of Service + .. toggle_name: FEATURES['ENABLE_TERMSOFSERVICE'] + .. toggle_implementation: SettingDictToggle + .. toggle_default: False + .. toggle_description: This feature can be enabled to handle prompting users to + agree to the latest terms of service for a given site. + .. toggle_use_cases: open_edx + .. toggle_creation_date: 2022-11-01 + """ + settings.FEATURES['ENABLE_TERMSOFSERVICE'] = False + + # lint-amnesty, pylint: disable=pointless-string-statement + """ + Common settings for Terms of Service + .. toggle_name: FEATURES['ENABLE_TERMSOFSERVICE_PER_SUBSITE'] + .. toggle_implementation: SettingDictToggle + .. toggle_default: False + .. toggle_description: This feature can be enabled to handle prompting users to + agree to the latest terms of service for a different subsites. + .. toggle_use_cases: open_edx + .. toggle_creation_date: 2022-12-01 + """ + settings.FEATURES['ENABLE_TERMSOFSERVICE_PER_SUBSITE'] = False diff --git a/openedx/features/termsofservice/settings/test.py b/openedx/features/termsofservice/settings/test.py new file mode 100644 index 000000000000..41afe8d58fbc --- /dev/null +++ b/openedx/features/termsofservice/settings/test.py @@ -0,0 +1,8 @@ +"""Test settings for Terms of Service""" + + +def plugin_settings(settings): + """ + Test settings for Terms of service + """ + settings.FEATURES['ENABLE_TERMSOFSERVICE'] = False diff --git a/openedx/features/termsofservice/static/termsofservice/jsx/Modal.jsx b/openedx/features/termsofservice/static/termsofservice/jsx/Modal.jsx new file mode 100644 index 000000000000..9213b4cd24bf --- /dev/null +++ b/openedx/features/termsofservice/static/termsofservice/jsx/Modal.jsx @@ -0,0 +1,154 @@ +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; + +import $ from 'jquery'; + +// Due to an issue with importing modal from paragon, +// a modal has been made from scratch instead of importing a new one +// import { Button, Modal } from '@edx/paragon'; + + +class ModalView extends Component { + + constructor(props) { + super(props); + this.state = { + show: true, + tos_html: '', + tos_exists_for_site: false, + has_user_agreed_to_latest_tos: false, + tos_isChecked: false, + }; + this.showModal = this.showModal.bind(this); + this.hideModal = this.hideModal.bind(this); + this.checkboxClicked = this.checkboxClicked.bind(this); + this.isCheckboxClicked = this.isCheckboxClicked.bind(this); + this.retrievePage(); + } + + checkboxClicked() { + var isChecked = this.state.tos_isChecked; + this.setState({ tos_isChecked: !isChecked }); + } + + + isCheckboxClicked() { + return !this.state.tos_isChecked; + } + + // Prevent the default form from submitting and refreshing the page that it's included on. + // (e.g. The dashboard page won't refresh after the learner submits the form) + // https://stackoverflow.com/questions/28479239/setting-onsubmit-in-react-js + submitTOS (e) { + e.preventDefault(); + } + + retrievePage() { + $.get('/termsofservice/v1/current_tos/') + .then(data => { + this.setState({ + tos_exists_for_site: data.tos_exists_for_site, + tos_html: data.tos_html, + has_user_agreed_to_latest_tos: data.has_user_agreed_to_latest_tos + }); + }); + } + + showModal() { + console.log(this.state); + this.setState({ show: true }); + } + + + hideModal() { + + // Link to the code snippet that was referred to get the CSRF Token + // https://docs.djangoproject.com/en/3.2/ref/csrf/#ajax + var CSRFT = 'csrftoken'; + var csrftoken = null; + if (document.cookie && document.cookie !== '') { + var cookies = document.cookie.split(';'); + for (var i = 0; i < cookies.length; i++) { + var cookie = cookies[i].trim(); + // Does this cookie string begin with the name we want? + if (cookie.substring(0, CSRFT.length + 1) === (CSRFT + '=')) { + csrftoken = decodeURIComponent(cookie.substring(CSRFT.length + 1)); + break; + } + } + } + + + fetch('/termsofservice/v1/current_tos/', { + method: 'POST', + mode: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrftoken + }, + body: JSON.stringify({ + has_user_agreed: true + }) + + }) + this.setState({ show: false }); + } + + render() { + + if (!this.state.tos_exists_for_site || this.state.has_user_agreed_to_latest_tos) { + return () + } + else { + return ( + +EducateWorkforce has updated its terms of service. Please read the following terms and agree in order to continue use of the platform.
+ +Sample terms of service text 1
", + curf_id="cf_id1" + ) + # Fill in sample data for Site + site1 = Site.objects.create(domain='edx.org', name='edx.org') + + # Fill in sample data for TermsOfServiceSites + toss1 = TermsOfServiceSites.objects.create( + site=site1, + curf=tos1 + ) + + # Fill in sample data for TermsOfServiceAllSites + tosas = TermsOfServiceAllSites.objects.create( + curf=tos1 + ) + + def setUp(self): + super().setUp() + self.client = Client() + self.admin = AdminFactory.create( + email='staff@edx.org', + username='admin', + password='pass' + ) + self.client.login(username=self.admin.username, password='pass') + + def test_feature_flag_disabled(self): + """Ensures that the default settings effectively disables the feature""" + response = self.client.get('/dashboard') + self.assertNotContains(response, '