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 @@

<%include file="dashboard/_dashboard_entitlement_unenrollment_modal.html"/> + +<%include file="dashboard/_dashboard_entitlement_unenrollment_modal.html"/> + +% if settings.FEATURES.get('ENABLE_TERMSOFSERVICE'): + <%include file='termsofservice/modal_termsofservice.html' /> +% endif + + + diff --git a/lms/templates/static_templates/tos.html b/lms/templates/static_templates/tos.html index 107b03594354..cd5918db5d72 100644 --- a/lms/templates/static_templates/tos.html +++ b/lms/templates/static_templates/tos.html @@ -4,13 +4,105 @@ <%block name="pagetitle">${_("Terms of Service")} -
-
-

- <%block name="pageheader">${page_header or _("Terms of Service")} -

-

- <%block name="pagecontent">${page_content or _("This page left intentionally blank. Feel free to add your own content.")} -

-
-
+ + +
+
+
+
+ +
+
+
+ +
+
+
+

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. +

+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
\ No newline at end of file diff --git a/lms/templates/termsofservice/modal_termsofservice.html b/lms/templates/termsofservice/modal_termsofservice.html new file mode 100644 index 000000000000..e62e2cec36bc --- /dev/null +++ b/lms/templates/termsofservice/modal_termsofservice.html @@ -0,0 +1,10 @@ + +<%page expression_filter="h"/> +<%namespace name='static' file='../static_content.html'/> +
+
+ + <%static:webpack entry="TOSModalView"> + new TOSModalView(); + +
\ No newline at end of file diff --git a/lms/templates/termsofservice/tos_content.html b/lms/templates/termsofservice/tos_content.html new file mode 100644 index 000000000000..ac249581743b --- /dev/null +++ b/lms/templates/termsofservice/tos_content.html @@ -0,0 +1,3 @@ +
+ {{tos_html | safe}} +
\ No newline at end of file diff --git a/lms/urls.py b/lms/urls.py index ae67be2e2a75..4db0226b91ee 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -53,6 +53,7 @@ from openedx.features.enterprise_support.api import enterprise_enabled from common.djangoapps.student import views as student_views from common.djangoapps.util import views as util_views +from openedx.features.termsofservice import views as tos_views RESET_COURSE_DEADLINES_NAME = 'reset_course_deadlines' RENDER_XBLOCK_NAME = 'render_xblock' @@ -111,6 +112,8 @@ # Static template view endpoints like blog, faq, etc. path('', include('lms.djangoapps.static_template_view.urls')), + path('', include('openedx.features.termsofservice.urls')), + path('heartbeat', include('openedx.core.djangoapps.heartbeat.urls')), path('i18n/', include('django.conf.urls.i18n')), diff --git a/openedx/features/termsofservice/__init__.py b/openedx/features/termsofservice/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/features/termsofservice/admin.py b/openedx/features/termsofservice/admin.py new file mode 100644 index 000000000000..ce48ce45e9cb --- /dev/null +++ b/openedx/features/termsofservice/admin.py @@ -0,0 +1,27 @@ +# lint-amnesty, pylint: disable=missing-module-docstring + +from django.contrib import admin +from .models import TermsOfService, TermsOfServiceAcknowledgement, TermsOfServiceSites, TermsOfServiceAllSites +# Register your models here. + + +class TermsOfServiceAdmin(admin.ModelAdmin): + list_display = ('id', 'curf_id') + + +class TermsOfServiceAcknowledgementAdmin(admin.ModelAdmin): + list_display = ('id', 'user', 'curf') + + +class TermsOfServiceSitesAdmin(admin.ModelAdmin): + list_display = ('site', 'curf') + + +class TermsOfServiceAllSitesAdmin(admin.ModelAdmin): + list_display = ('curf',) + + +admin.site.register(TermsOfService, TermsOfServiceAdmin) +admin.site.register(TermsOfServiceAcknowledgement, TermsOfServiceAcknowledgementAdmin) +admin.site.register(TermsOfServiceSites, TermsOfServiceSitesAdmin) +admin.site.register(TermsOfServiceAllSites, TermsOfServiceAllSitesAdmin) diff --git a/openedx/features/termsofservice/api/__init__.py b/openedx/features/termsofservice/api/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/features/termsofservice/api/v1/__init__.py b/openedx/features/termsofservice/api/v1/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/features/termsofservice/api/v1/urls.py b/openedx/features/termsofservice/api/v1/urls.py new file mode 100644 index 000000000000..31e48b2710fe --- /dev/null +++ b/openedx/features/termsofservice/api/v1/urls.py @@ -0,0 +1,13 @@ +# pylint: disable=missing-module-docstring + +""" +Contains URLs for the Terms of Service API +""" +from django.urls import path +from django.contrib.auth.decorators import login_required + +from openedx.features.termsofservice.api.v1.views import terms_of_service_api + +urlpatterns = [ + path('v1/current_tos/', login_required(terms_of_service_api), name='current_tos') +] diff --git a/openedx/features/termsofservice/api/v1/views.py b/openedx/features/termsofservice/api/v1/views.py new file mode 100644 index 000000000000..076dac4f1fc7 --- /dev/null +++ b/openedx/features/termsofservice/api/v1/views.py @@ -0,0 +1,90 @@ +# lint-amnesty, pylint: disable=missing-module-docstring + +from django.views.decorators.csrf import ensure_csrf_cookie +from django.http import HttpResponse, JsonResponse +from django.contrib.sites.models import Site +from django.conf import settings +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.features.termsofservice.models import TermsOfServiceSites, TermsOfService +from openedx.features.termsofservice.models import TermsOfServiceAcknowledgement, TermsOfServiceAllSites + + +@ensure_csrf_cookie +def terms_of_service_api(request): # lint-amnesty, pylint: disable=missing-function-docstring + latest_tos_html = '' + + cur_site_name = configuration_helpers.get_value("SITE_NAME", settings.SITE_NAME) + cur_site_id = Site.objects.get(domain=cur_site_name) + + if request.method == 'GET': + # Return Terms of Service as JSON + try: + latest_tos_html = '' + has_user_agreed_to_latest_tos = False + + if settings.FEATURES.get('ENABLE_TERMSOFSERVICE_PER_SUBSITE'): + # Get the curf_id associated with the Site + cur_site_curf_id = TermsOfServiceSites.objects.get(site_id=cur_site_id.id).curf_id + cur_user_tos_ack = TermsOfServiceAcknowledgement.objects.filter( + user_id=request.user.id, curf_id=cur_site_curf_id).first() + + # Check if the user agreed curf id matches the latest curf id + if cur_user_tos_ack is not None and cur_site_curf_id == cur_user_tos_ack.curf_id: + has_user_agreed_to_latest_tos = True + else: + latest_tos_html = TermsOfService.objects.get(curf_id=cur_site_curf_id).terms_of_service_text + + else: + default_tos = TermsOfServiceAllSites.objects.all().first() + + #if there is no default TOS assigned, return a JSON response with an error + if default_tos is None: + result = { + "tos_html": latest_tos_html, + "tos_exists_for_site": False, + "has_user_agreed_to_latest_tos": has_user_agreed_to_latest_tos, + "Error": f"Need to setup a Terms of Service Acknowledgment for {cur_site_name}" + } + return JsonResponse(result) + + cur_site_curf_id = default_tos.curf_id + cur_user_tos_ack = TermsOfServiceAcknowledgement.objects.filter( + user_id=request.user.id, curf_id=cur_site_curf_id).first() + + # Check if user's agreed curf id is present in the TOS Site table + # if the object is present, that implies that the user has agreed to the latest TOS + if cur_user_tos_ack is not None and cur_site_curf_id == cur_user_tos_ack.curf_id: + has_user_agreed_to_latest_tos = True + else: + latest_tos_html = TermsOfService.objects.get(curf_id=cur_site_curf_id).terms_of_service_text + + except ( + TermsOfServiceSites.DoesNotExist, + TermsOfServiceAllSites.DoesNotExist, + TermsOfServiceAcknowledgement.DoesNotExist + ): + latest_tos_html = '' + + result = { + "tos_exists_for_site": bool(latest_tos_html), + "tos_html": latest_tos_html, + "has_user_agreed_to_latest_tos": has_user_agreed_to_latest_tos + } + + return JsonResponse(result) + + if request.method == 'POST': + + if settings.FEATURES.get('ENABLE_TERMSOFSERVICE_PER_SUBSITE'): + current_valid_curf_id = TermsOfServiceSites.objects.get(site_id=cur_site_id.id).curf_id + else: + current_valid_curf_id = TermsOfServiceAllSites.objects.all().first().curf_id + + try: + user_TOS_ack = TermsOfServiceAcknowledgement.objects.get(user_id=request.user.id) + user_TOS_ack.curf_id = current_valid_curf_id + except TermsOfServiceAcknowledgement.DoesNotExist: + user_TOS_ack = TermsOfServiceAcknowledgement(user_id=request.user.id, curf_id=current_valid_curf_id) + user_TOS_ack.save() + + return HttpResponse("Successfully Posted Terms of Service Update", status=200) diff --git a/openedx/features/termsofservice/apps.py b/openedx/features/termsofservice/apps.py new file mode 100644 index 000000000000..9fa8f9b3695e --- /dev/null +++ b/openedx/features/termsofservice/apps.py @@ -0,0 +1,32 @@ +""" +Announcements Application Configuration +""" + + +from django.apps import AppConfig +from edx_django_utils.plugins import PluginURLs, PluginSettings + +from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType + + +class TermsOfServiceConfig(AppConfig): + """ + Application Configuration for TermsOfService + """ + name = 'openedx.features.termsofservice' + + plugin_app = { + PluginURLs.CONFIG: { + ProjectType.LMS: { + PluginURLs.NAMESPACE: 'termsofservice', + PluginURLs.REGEX: '^termsofservice/', + PluginURLs.RELATIVE_PATH: 'urls', + } + }, + PluginSettings.CONFIG: { + ProjectType.LMS: { + SettingsType.COMMON: {PluginSettings.RELATIVE_PATH: 'settings.common'}, + SettingsType.TEST: {PluginSettings.RELATIVE_PATH: 'settings.test'}, + } + } + } diff --git a/openedx/features/termsofservice/migrations/0001_initial.py b/openedx/features/termsofservice/migrations/0001_initial.py new file mode 100644 index 000000000000..b42d50f57393 --- /dev/null +++ b/openedx/features/termsofservice/migrations/0001_initial.py @@ -0,0 +1,55 @@ +# Generated by Django 3.2.13 on 2023-02-22 22:05 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('sites', '0002_alter_domain_unique'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='TermsOfService', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date_modified', models.DateTimeField()), + ('terms_of_service_text', models.TextField()), + ('curf_id', models.CharField(max_length=25, unique=True)), + ], + ), + migrations.CreateModel( + name='TermsOfServiceSites', + fields=[ + ('site', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='sites.site')), + ('curf', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='termsofservice.termsofservice', to_field='curf_id')), + ], + options={ + 'verbose_name': 'TOS Site', + }, + ), + migrations.CreateModel( + name='TermsOfServiceAllSites', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('curf', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='termsofservice.termsofservice', to_field='curf_id')), + ], + options={ + 'verbose_name': 'TermsOfServiceAllSite', + }, + ), + migrations.CreateModel( + name='TermsOfServiceAcknowledgement', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('curf', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='termsofservice.termsofservice', to_field='curf_id')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/openedx/features/termsofservice/migrations/0002_initial_data.py b/openedx/features/termsofservice/migrations/0002_initial_data.py new file mode 100644 index 000000000000..7ec1aff98089 --- /dev/null +++ b/openedx/features/termsofservice/migrations/0002_initial_data.py @@ -0,0 +1,66 @@ +from django.conf import settings +from django.contrib.sites.models import Site +from django.db import migrations + +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers + + +class Migration(migrations.Migration): + + dependencies = [ + ('termsofservice', '0001_initial'), + ] + + def forwards(apps, schema_editor): + + TermsOfService = apps.get_model('termsofservice', 'TermsOfService') + # create a sample terms of service object + tos = TermsOfService(date_modified='2023-01-01 00:00:00', terms_of_service_text='

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 ( + +
+ +

Terms of Service Agreement

+

EducateWorkforce has updated its terms of service. Please read the following terms and agree in order to continue use of the platform.

+ +
+
+
+ +
+
+
+ + +
+ +
+
+
+
+ ) + } + } +} + + +const Modal = ({ show, children }) => { + const showHideModal = show ? "modal-tos display-block" : "modal-tos display-none"; + + return ( +
+
+ {children} +
+
+ ); +}; + +export default class TOSModalView { + constructor() { + ReactDOM.render( + , + document.getElementById("tos-modal"), + ); + } +} + +export { TOSModalView, ModalView } \ No newline at end of file diff --git a/openedx/features/termsofservice/tests/test_termsofservice.py b/openedx/features/termsofservice/tests/test_termsofservice.py new file mode 100644 index 000000000000..19d064f9ed1a --- /dev/null +++ b/openedx/features/termsofservice/tests/test_termsofservice.py @@ -0,0 +1,70 @@ +""" +Unit tests for the announcements feature. +""" + + +import json +import unittest +import datetime +from unittest.mock import patch + +from django.conf import settings +from django.test import TestCase +from django.test.client import Client +from django.urls import reverse + +from common.djangoapps.student.tests.factories import AdminFactory +from openedx.features.termsofservice.models import TermsOfService, TermsOfServiceSites, TermsOfServiceAllSites +from django.contrib.sites.models import Site + + +@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +class TestGlobalTermsOfService(TestCase): + """ + Test TermsOfService in LMS + """ + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + # Fill in sample data for TermsOfService + tos1 = TermsOfService.objects.create( + date_modified=datetime.datetime.now(), + terms_of_service_text="

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, '