diff --git a/common/static/sass/_builtin-block-variables.scss b/common/static/sass/_builtin-block-variables.scss index 2c567c6fb1..e232cb57d5 100644 --- a/common/static/sass/_builtin-block-variables.scss +++ b/common/static/sass/_builtin-block-variables.scss @@ -16,7 +16,6 @@ :root { --action-primary-active-bg: $action-primary-active-bg; - --all-text-inputs: $all-text-inputs; --base-font-size: $base-font-size; --base-line-height: $base-line-height; --baseline: $baseline; @@ -26,6 +25,7 @@ --blue-d1: $blue-d1; --blue-d2: $blue-d2; --blue-d4: $blue-d4; + --blue-s1: $blue-s1; --body-color: $body-color; --border-color: $border-color; --bp-screen-lg: $bp-screen-lg; @@ -34,6 +34,8 @@ --danger: $danger; --darkGrey: $darkGrey; --error-color: $error-color; + --error-color-dark: darken($error-color, 11%); + --error-color-light: lighten($error-color, 25%); --font-bold: $font-bold; --font-family-sans-serif: $font-family-sans-serif; --general-color-accent: $general-color-accent; @@ -44,6 +46,12 @@ --gray-l3: $gray-l3; --gray-l4: $gray-l4; --gray-l6: $gray-l6; + --icon-correct: url($static-path + '/images/correct-icon.png'); + --icon-incorrect: url($static-path + '/images/incorrect-icon.png'); + --icon-info: url($static-path + '/images/info-icon.png'); + --icon-partially-correct: url($static-path + '/images/partially-correct-icon.png'); + --icon-spinner: url($static-path + '/images/spinner.gif'); + --icon-unanswered: url($static-path + '/images/unanswered-icon.png'); --incorrect: $incorrect; --lightGrey: $lightGrey; --lighter-base-font-color: $lighter-base-font-color; diff --git a/docs/hooks/events.rst b/docs/hooks/events.rst index 7f9584c9e8..2a7561df24 100644 --- a/docs/hooks/events.rst +++ b/docs/hooks/events.rst @@ -233,17 +233,29 @@ Content Authoring Events - 2023-07-20 * - `LIBRARY_BLOCK_CREATED `_ - - org.openedx.content_authoring.content_library.created.v1 + - org.openedx.content_authoring.library_block.created.v1 - 2023-07-20 * - `LIBRARY_BLOCK_UPDATED `_ - - org.openedx.content_authoring.content_library.updated.v1 + - org.openedx.content_authoring.library_block.updated.v1 - 2023-07-20 * - `LIBRARY_BLOCK_DELETED `_ - - org.openedx.content_authoring.content_library.deleted.v1 + - org.openedx.content_authoring.library_block.deleted.v1 - 2023-07-20 * - `CONTENT_OBJECT_TAGS_CHANGED `_ - org.openedx.content_authoring.content.object.tags.changed.v1 - 2024-03-31 + + * - `LIBRARY_COLLECTION_CREATED `_ + - org.openedx.content_authoring.content_library.collection.created.v1 + - 2024-08-23 + + * - `LIBRARY_COLLECTION_UPDATED `_ + - org.openedx.content_authoring.content_library.collection.updated.v1 + - 2024-08-23 + + * - `LIBRARY_COLLECTION_DELETED `_ + - org.openedx.content_authoring.content_library.collection.deleted.v1 + - 2024-08-23 diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index 65d35221d4..0042b42c7b 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -47,6 +47,7 @@ from common.djangoapps.student.models import ( UNENROLLED_TO_ALLOWEDTOENROLL, UNENROLLED_TO_ENROLLED, UNENROLLED_TO_UNENROLLED, + CourseAccessRole, CourseEnrollment, CourseEnrollmentAllowed, ManualEnrollmentAudit, @@ -60,12 +61,14 @@ from common.djangoapps.student.roles import ( CourseFinanceAdminRole, CourseInstructorRole, ) -from common.djangoapps.student.tests.factories import BetaTesterFactory -from common.djangoapps.student.tests.factories import CourseEnrollmentFactory -from common.djangoapps.student.tests.factories import GlobalStaffFactory -from common.djangoapps.student.tests.factories import InstructorFactory -from common.djangoapps.student.tests.factories import StaffFactory -from common.djangoapps.student.tests.factories import UserFactory +from common.djangoapps.student.tests.factories import ( + BetaTesterFactory, + CourseEnrollmentFactory, + GlobalStaffFactory, + InstructorFactory, + StaffFactory, + UserFactory +) from lms.djangoapps.bulk_email.models import BulkEmailFlag, CourseEmail, CourseEmailTemplate from lms.djangoapps.certificates.data import CertificateStatuses from lms.djangoapps.certificates.tests.factories import ( @@ -94,6 +97,9 @@ from openedx.core.djangoapps.course_date_signals.handlers import extract_dates from openedx.core.djangoapps.course_groups.cohorts import set_course_cohorted from openedx.core.djangoapps.django_comment_common.models import FORUM_ROLE_COMMUNITY_TA from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles +from openedx.core.djangoapps.oauth_dispatch import jwt as jwt_api +from openedx.core.djangoapps.oauth_dispatch.adapters import DOTAdapter +from openedx.core.djangoapps.oauth_dispatch.tests.factories import AccessTokenFactory, ApplicationFactory from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin from openedx.core.djangoapps.user_api.preferences.api import delete_user_preference @@ -4675,3 +4681,116 @@ class TestInstructorCertificateExceptions(SharedModuleStoreTestCase): f"The student {self.user} does not have certificate for the course {self.course.id.course}. Kindly " "verify student username/email and the selected course are correct and try again." ) + + +@patch.dict(settings.FEATURES, {'ALLOW_AUTOMATED_SIGNUPS': True}) +class TestOauthInstructorAPILevelsAccess(SharedModuleStoreTestCase, LoginEnrollmentTestCase): + """ + Test endpoints using Oauth2 authentication. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create( + entrance_exam_id='i4x://{}/{}/chapter/Entrance_exam'.format('test_org', 'test_course') + ) + + def setUp(self): + super().setUp() + + self.other_user = UserFactory() + dot_application = ApplicationFactory(user=self.other_user, authorization_grant_type='password') + access_token = AccessTokenFactory(user=self.other_user, application=dot_application) + oauth_adapter = DOTAdapter() + token_dict = { + 'access_token': access_token, + 'scope': 'email profile', + } + jwt_token = jwt_api.create_jwt_from_token(token_dict, oauth_adapter, use_asymmetric_key=True) + + self.headers = { + 'HTTP_AUTHORIZATION': 'JWT ' + jwt_token + } + + # endpoints contains all urls with body and role. + self.endpoints = [ + ('list_course_role_members', {'rolename': 'staff'}, 'instructor'), + ('register_and_enroll_students', {}, 'staff'), + ('get_student_progress_url', {'course_id': str(self.course.id), + 'unique_student_identifier': self.other_user.email + }, 'staff' + ), + ('list_entrance_exam_instructor_tasks', {'unique_student_identifier': self.other_user.email}, 'staff'), + ('list_email_content', {}, 'staff'), + ('show_student_extensions', {'student': self.other_user.email}, 'staff'), + ('list_email_content', {}, 'staff'), + ('list_report_downloads', { + "send-to": ["myself"], + "subject": "This is subject", + "message": "message" + }, 'data_researcher') + ] + + self.fake_jwt = ('wyJUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJjaGFuZ2UtbWUiLCJleHAiOjE3MjU4OTA2NzIsImdyY' + 'W50X3R5cGUiOiJwYXNzd29yZCIsImlhdCI6MTcyNTg4NzA3MiwiaXNzIjoiaHR0cDovLzEyNy4wLjAuMTo4MDAwL29h' + 'XNlcl9pZCI6MX0' + '.ec8neWp1YAuF40ye4oeK40obaapUvjfNPUQCycrsajwvcu58KcuLc96sf0JKmMMMn7DH9N98hg8W38iwbhKif1kLsCKr' + 'tStl1u2XGvFkyMov8TtespbHit5LYRZpJwrhC1h50ru2buYj3isWrAElGPIDyAj0FAvSJnvJhWSMDtIwB2gxZI1DqOm' + 'M6mzT7JbOU4QH2PNZrb2EZ11F6k9I-HrHnLQymr4s0vyjMlcBWllW3y19futNCgsFFRMXI4Z9zIbspsy5bq_Skub' + 'dBpnl0P9x8vUJCAbFnJABAVPtF7F7nNsROQMKsZtQxaUUwdcYZi5qKL2GcgGfO0eTL4IbJA') + + def assert_all_end_points(self, endpoint, body, role, add_role, use_jwt=True): + """ + Util method for verifying different end-points. + """ + if add_role: + role, _ = CourseAccessRole.objects.get_or_create( + course_id=self.course.id, + user=self.other_user, + role=role, + org=self.course.id.org + ) + + if use_jwt: + headers = self.headers + else: + headers = { + 'HTTP_AUTHORIZATION': 'JWT ' + self.fake_jwt # this is fake jwt. + } + + url = reverse(endpoint, kwargs={'course_id': str(self.course.id)}) + response = self.client.post( + url, + data=body, + **headers + ) + return response + + def run_endpoint_tests(self, expected_status, add_role, use_jwt): + """ + Util method for running different end-points. + """ + for endpoint, body, role in self.endpoints: + with self.subTest(endpoint=endpoint, role=role, body=body): + response = self.assert_all_end_points(endpoint, body, role, add_role, use_jwt) + # JWT authentication works but it has no permissions. + assert response.status_code == expected_status, f"Failed for endpoint: {endpoint}" + + def test_end_points_with_oauth_without_jwt(self): + """ + Verify the endpoint using invalid JWT returns 401. + """ + self.run_endpoint_tests(expected_status=401, add_role=False, use_jwt=False) + + def test_end_points_with_oauth_without_permissions(self): + """ + Verify the endpoint using JWT authentication. But has no permissions. + """ + self.run_endpoint_tests(expected_status=403, add_role=False, use_jwt=True) + + def test_end_points_with_oauth_with_permissions(self): + """ + Verify the endpoint using JWT authentication with permissions. + """ + self.run_endpoint_tests(expected_status=200, add_role=True, use_jwt=True) diff --git a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py index 8fa590c37f..1fb25aeb8c 100644 --- a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py +++ b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py @@ -406,7 +406,7 @@ class TestInstructorGradeReport(InstructorGradeReportTestCase): with patch('lms.djangoapps.instructor_task.tasks_helper.runner._get_current_task'): with check_mongo_calls(2): - with self.assertNumQueries(53): + with self.assertNumQueries(54): CourseGradeReport.generate(None, None, course.id, {}, 'graded') def test_inactive_enrollments(self): diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index 57ef79e8b0..9d2195d1e5 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -1215,6 +1215,11 @@ class VerificationAttempt(TimeStampedModel): blank=True, ) + @property + def updated_at(self): + """Backwards compatibility with existing IDVerification models""" + return self.modified + @classmethod def retire_user(cls, user_id): """ diff --git a/lms/djangoapps/verify_student/services.py b/lms/djangoapps/verify_student/services.py index bdfa31fee6..f1c5543e85 100644 --- a/lms/djangoapps/verify_student/services.py +++ b/lms/djangoapps/verify_student/services.py @@ -17,7 +17,7 @@ from common.djangoapps.student.models import User from lms.djangoapps.verify_student.utils import is_verification_expiring_soon from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers -from .models import ManualVerification, SoftwareSecurePhotoVerification, SSOVerification +from .models import ManualVerification, SoftwareSecurePhotoVerification, SSOVerification, VerificationAttempt from .utils import most_recent_verification log = logging.getLogger(__name__) @@ -75,7 +75,8 @@ class IDVerificationService: Return a list of all verifications associated with the given user. """ verifications = [] - for verification in chain(SoftwareSecurePhotoVerification.objects.filter(user=user).order_by('-created_at'), + for verification in chain(VerificationAttempt.objects.filter(user=user).order_by('-created'), + SoftwareSecurePhotoVerification.objects.filter(user=user).order_by('-created_at'), SSOVerification.objects.filter(user=user).order_by('-created_at'), ManualVerification.objects.filter(user=user).order_by('-created_at')): verifications.append(verification) @@ -92,6 +93,11 @@ class IDVerificationService: 'created_at__gt': now() - timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]) } return chain( + VerificationAttempt.objects.filter(**{ + 'user__in': users, + 'status': 'approved', + 'created__gt': now() - timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]) + }).values_list('user_id', flat=True), SoftwareSecurePhotoVerification.objects.filter(**filter_kwargs).values_list('user_id', flat=True), SSOVerification.objects.filter(**filter_kwargs).values_list('user_id', flat=True), ManualVerification.objects.filter(**filter_kwargs).values_list('user_id', flat=True) @@ -117,11 +123,14 @@ class IDVerificationService: 'status__in': statuses, } + id_verifications = VerificationAttempt.objects.filter(**filter_kwargs) photo_id_verifications = SoftwareSecurePhotoVerification.objects.filter(**filter_kwargs) sso_id_verifications = SSOVerification.objects.filter(**filter_kwargs) manual_id_verifications = ManualVerification.objects.filter(**filter_kwargs) - attempt = most_recent_verification((photo_id_verifications, sso_id_verifications, manual_id_verifications)) + attempt = most_recent_verification( + (photo_id_verifications, sso_id_verifications, manual_id_verifications, id_verifications) + ) return attempt and attempt.expiration_datetime @classmethod @@ -242,8 +251,18 @@ class IDVerificationService: """ Returns a verification attempt object by attempt_id If the verification object cannot be found, returns None + + This method does not take into account verifications stored in the + VerificationAttempt model used for pluggable IDV implementations. + + As part of the work to implement pluggable IDV, this method's use + will be deprecated: https://openedx.atlassian.net/browse/OSPR-1011 """ verification = None + + # This does not look at the VerificationAttempt model since the provided id would become + # ambiguous between tables. The verification models in this list all inherit from the same + # base class and share the same id space. verification_models = [ SoftwareSecurePhotoVerification, SSOVerification, diff --git a/lms/djangoapps/verify_student/tests/test_services.py b/lms/djangoapps/verify_student/tests/test_services.py index 56f388b7c9..5351e3ede6 100644 --- a/lms/djangoapps/verify_student/tests/test_services.py +++ b/lms/djangoapps/verify_student/tests/test_services.py @@ -2,8 +2,8 @@ Tests for the service classes in verify_student. """ -from datetime import datetime, timedelta, timezone import itertools +from datetime import datetime, timedelta, timezone from random import randint from unittest.mock import patch @@ -16,10 +16,16 @@ from freezegun import freeze_time from pytz import utc from common.djangoapps.student.tests.factories import UserFactory -from lms.djangoapps.verify_student.models import ManualVerification, SoftwareSecurePhotoVerification, SSOVerification +from lms.djangoapps.verify_student.models import ( + ManualVerification, + SoftwareSecurePhotoVerification, + SSOVerification, + VerificationAttempt +) from lms.djangoapps.verify_student.services import IDVerificationService from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import \ + ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order FAKE_SETTINGS = { @@ -34,12 +40,15 @@ class TestIDVerificationService(ModuleStoreTestCase): Tests for IDVerificationService. """ - def test_user_is_verified(self): + @ddt.data( + SoftwareSecurePhotoVerification, VerificationAttempt + ) + def test_user_is_verified(self, verification_model): """ Test to make sure we correctly answer whether a user has been verified. """ user = UserFactory.create() - attempt = SoftwareSecurePhotoVerification(user=user) + attempt = verification_model(user=user) attempt.save() # If it's any of these, they're not verified... @@ -49,16 +58,24 @@ class TestIDVerificationService(ModuleStoreTestCase): assert not IDVerificationService.user_is_verified(user), status attempt.status = "approved" + if verification_model == VerificationAttempt: + attempt.expiration_datetime = now() + timedelta(days=19) + else: + attempt.expiration_date = now() + timedelta(days=19) attempt.save() + assert IDVerificationService.user_is_verified(user), attempt.status - def test_user_has_valid_or_pending(self): + @ddt.data( + SoftwareSecurePhotoVerification, VerificationAttempt + ) + def test_user_has_valid_or_pending(self, verification_model): """ Determine whether we have to prompt this user to verify, or if they've already at least initiated a verification submission. """ user = UserFactory.create() - attempt = SoftwareSecurePhotoVerification(user=user) + attempt = verification_model(user=user) # If it's any of these statuses, they don't have anything outstanding for status in ["created", "ready", "denied"]: @@ -70,6 +87,10 @@ class TestIDVerificationService(ModuleStoreTestCase): # -- must_retry, and submitted both count until we hear otherwise for status in ["submitted", "must_retry", "approved"]: attempt.status = status + if verification_model == VerificationAttempt: + attempt.expiration_datetime = now() + timedelta(days=19) + else: + attempt.expiration_date = now() + timedelta(days=19) attempt.save() assert IDVerificationService.user_has_valid_or_pending(user), status @@ -102,18 +123,22 @@ class TestIDVerificationService(ModuleStoreTestCase): user_a = UserFactory.create() user_b = UserFactory.create() user_c = UserFactory.create() + user_d = UserFactory.create() user_unverified = UserFactory.create() user_denied = UserFactory.create() + user_denied_b = UserFactory.create() SoftwareSecurePhotoVerification.objects.create(user=user_a, status='approved') ManualVerification.objects.create(user=user_b, status='approved') SSOVerification.objects.create(user=user_c, status='approved') + VerificationAttempt.objects.create(user=user_d, status='approved') SSOVerification.objects.create(user=user_denied, status='denied') + VerificationAttempt.objects.create(user=user_denied_b, status='denied') verified_user_ids = set(IDVerificationService.get_verified_user_ids([ - user_a, user_b, user_c, user_unverified, user_denied + user_a, user_b, user_c, user_d, user_unverified, user_denied ])) - expected_user_ids = {user_a.id, user_b.id, user_c.id} + expected_user_ids = {user_a.id, user_b.id, user_c.id, user_d.id} assert expected_user_ids == verified_user_ids @@ -158,6 +183,23 @@ class TestIDVerificationService(ModuleStoreTestCase): expiration_datetime = IDVerificationService.get_expiration_datetime(user_a, ['approved']) assert expiration_datetime == newer_record.expiration_datetime + def test_get_expiration_datetime_mixed_models(self): + """ + Test that the latest expiration datetime is returned if there are both instances of + IDVerification models and VerificationAttempt models + """ + user = UserFactory.create() + + SoftwareSecurePhotoVerification.objects.create( + user=user, status='approved', expiration_date=datetime(2021, 11, 12, 0, 0, tzinfo=timezone.utc) + ) + newest = VerificationAttempt.objects.create( + user=user, status='approved', expiration_datetime=datetime(2022, 1, 12, 0, 0, tzinfo=timezone.utc) + ) + + expiration_datetime = IDVerificationService.get_expiration_datetime(user, ['approved']) + assert expiration_datetime == newest.expiration_datetime + @ddt.data( {'status': 'denied', 'error_msg': '[{"generalReasons": ["Name mismatch"]}]'}, {'status': 'approved', 'error_msg': ''}, diff --git a/openedx/core/djangoapps/content/search/api.py b/openedx/core/djangoapps/content/search/api.py index 9fb49b24b6..76bb5eb3f4 100644 --- a/openedx/core/djangoapps/content/search/api.py +++ b/openedx/core/djangoapps/content/search/api.py @@ -296,16 +296,12 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None: status_cb("Counting courses...") num_courses = CourseOverview.objects.count() - # Get the list of collections - status_cb("Counting collections...") - num_collections = authoring_api.get_collections().count() - # Some counters so we can track our progress as indexing progresses: - num_contexts = num_courses + num_libraries + num_collections + num_contexts = num_courses + num_libraries num_contexts_done = 0 # How many courses/libraries we've indexed num_blocks_done = 0 # How many individual components/XBlocks we've indexed - status_cb(f"Found {num_courses} courses, {num_libraries} libraries and {num_collections} collections.") + status_cb(f"Found {num_courses} courses, {num_libraries} libraries.") with _using_temp_index(status_cb) as temp_index_name: ############## Configure the index ############## @@ -390,10 +386,43 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None: status_cb(f"Error indexing library {lib_key}: {err}") return docs + ############## Collections ############## + def index_collection_batch(batch, num_done) -> int: + docs = [] + for collection in batch: + try: + doc = searchable_doc_for_collection(collection) + # Uncomment below line once collections are tagged. + # doc.update(searchable_doc_tags(collection.id)) + docs.append(doc) + except Exception as err: # pylint: disable=broad-except + status_cb(f"Error indexing collection {collection}: {err}") + num_done += 1 + + if docs: + try: + # Add docs in batch of 100 at once (usually faster than adding one at a time): + _wait_for_meili_task(client.index(temp_index_name).add_documents(docs)) + except (TypeError, KeyError, MeilisearchError) as err: + status_cb(f"Error indexing collection batch {p}: {err}") + return num_done + for lib_key in lib_keys: - status_cb(f"{num_contexts_done + 1}/{num_contexts}. Now indexing library {lib_key}") + status_cb(f"{num_contexts_done + 1}/{num_contexts}. Now indexing blocks in library {lib_key}") lib_docs = index_library(lib_key) num_blocks_done += len(lib_docs) + + # To reduce memory usage on large instances, split up the Collections into pages of 100 collections: + library = lib_api.get_library(lib_key) + collections = authoring_api.get_collections(library.learning_package.id, enabled=True) + num_collections = collections.count() + num_collections_done = 0 + status_cb(f"{num_collections_done + 1}/{num_collections}. Now indexing collections in library {lib_key}") + paginator = Paginator(collections, 100) + for p in paginator.page_range: + num_collections_done = index_collection_batch(paginator.page(p).object_list, num_collections_done) + status_cb(f"{num_collections_done}/{num_collections} collections indexed for library {lib_key}") + num_contexts_done += 1 ############## Courses ############## @@ -430,39 +459,6 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None: num_contexts_done += 1 num_blocks_done += len(course_docs) - ############## Collections ############## - status_cb("Indexing collections...") - - def index_collection_batch(batch, num_contexts_done) -> int: - docs = [] - for collection in batch: - status_cb( - f"{num_contexts_done + 1}/{num_contexts}. " - f"Now indexing collection {collection.title} ({collection.id})" - ) - try: - doc = searchable_doc_for_collection(collection) - # Uncomment below line once collections are tagged. - # doc.update(searchable_doc_tags(collection.id)) - docs.append(doc) - except Exception as err: # pylint: disable=broad-except - status_cb(f"Error indexing collection {collection}: {err}") - finally: - num_contexts_done += 1 - - if docs: - try: - # Add docs in batch of 100 at once (usually faster than adding one at a time): - _wait_for_meili_task(client.index(temp_index_name).add_documents(docs)) - except (TypeError, KeyError, MeilisearchError) as err: - status_cb(f"Error indexing collection batch {p}: {err}") - return num_contexts_done - - # To reduce memory usage on large instances, split up the Collections into pages of 100 collections: - paginator = Paginator(authoring_api.get_collections(enabled=True), 100) - for p in paginator.page_range: - num_contexts_done = index_collection_batch(paginator.page(p).object_list, num_contexts_done) - status_cb(f"Done! {num_blocks_done} blocks indexed across {num_contexts_done} courses, collections and libraries.") diff --git a/openedx/core/djangoapps/content/search/tests/test_api.py b/openedx/core/djangoapps/content/search/tests/test_api.py index 5498177003..d6b58674f5 100644 --- a/openedx/core/djangoapps/content/search/tests/test_api.py +++ b/openedx/core/djangoapps/content/search/tests/test_api.py @@ -177,8 +177,16 @@ class TestSearchApi(ModuleStoreTestCase): # Create a collection: self.learning_package = authoring_api.get_learning_package_by_key(self.library.key) + with freeze_time(created_date): + self.collection = authoring_api.create_collection( + learning_package_id=self.learning_package.id, + key="MYCOL", + title="my_collection", + created_by=None, + description="my collection description" + ) self.collection_dict = { - 'id': 1, + 'id': self.collection.id, 'type': 'collection', 'display_name': 'my_collection', 'description': 'my collection description', @@ -189,13 +197,6 @@ class TestSearchApi(ModuleStoreTestCase): "access_id": lib_access.id, 'breadcrumbs': [{'display_name': 'Library'}] } - with freeze_time(created_date): - self.collection = authoring_api.create_collection( - learning_package_id=self.learning_package.id, - title="my_collection", - created_by=None, - description="my collection description" - ) @override_settings(MEILISEARCH_ENABLED=False) def test_reindex_meilisearch_disabled(self, mock_meilisearch): diff --git a/openedx/core/djangoapps/content/search/tests/test_documents.py b/openedx/core/djangoapps/content/search/tests/test_documents.py index 6140411705..1f10fc3c98 100644 --- a/openedx/core/djangoapps/content/search/tests/test_documents.py +++ b/openedx/core/djangoapps/content/search/tests/test_documents.py @@ -215,6 +215,7 @@ class StudioDocumentsTest(SharedModuleStoreTestCase): ) collection = authoring_api.create_collection( learning_package_id=learning_package.id, + key="MYCOL", title="my_collection", created_by=None, description="my collection description" @@ -223,11 +224,11 @@ class StudioDocumentsTest(SharedModuleStoreTestCase): assert doc == { "id": collection.id, "type": "collection", - "display_name": collection.title, - "description": collection.description, + "display_name": "my_collection", + "description": "my collection description", "context_key": learning_package.key, "access_id": self.toy_course_access_id, - "breadcrumbs": [{"display_name": learning_package.title}], + "breadcrumbs": [{"display_name": "some learning_package"}], "created": created_date.timestamp(), "modified": created_date.timestamp(), } diff --git a/openedx/core/djangoapps/content_libraries/api.py b/openedx/core/djangoapps/content_libraries/api.py index 17bea80b3a..0011014345 100644 --- a/openedx/core/djangoapps/content_libraries/api.py +++ b/openedx/core/djangoapps/content_libraries/api.py @@ -69,24 +69,32 @@ from django.db.models import Q, QuerySet from django.utils.translation import gettext as _ from edx_rest_api_client.client import OAuthAPIClient from lxml import etree -from opaque_keys.edx.keys import UsageKey, UsageKeyV2 +from opaque_keys.edx.keys import BlockTypeKey, UsageKey, UsageKeyV2 from opaque_keys.edx.locator import ( LibraryLocatorV2, LibraryUsageLocatorV2, LibraryLocator as LibraryLocatorV1 ) from opaque_keys import InvalidKeyError -from openedx_events.content_authoring.data import ContentLibraryData, LibraryBlockData +from openedx_events.content_authoring.data import ( + ContentLibraryData, + ContentObjectData, + LibraryBlockData, + LibraryCollectionData, +) from openedx_events.content_authoring.signals import ( + CONTENT_OBJECT_TAGS_CHANGED, CONTENT_LIBRARY_CREATED, CONTENT_LIBRARY_DELETED, CONTENT_LIBRARY_UPDATED, LIBRARY_BLOCK_CREATED, LIBRARY_BLOCK_DELETED, LIBRARY_BLOCK_UPDATED, + LIBRARY_COLLECTION_CREATED, + LIBRARY_COLLECTION_UPDATED, ) from openedx_learning.api import authoring as authoring_api -from openedx_learning.api.authoring_models import Component, MediaType +from openedx_learning.api.authoring_models import Collection, Component, MediaType, LearningPackage, PublishableEntity from organizations.models import Organization from xblock.core import XBlock from xblock.exceptions import XBlockNotFoundError @@ -111,6 +119,8 @@ log = logging.getLogger(__name__) ContentLibraryNotFound = ContentLibrary.DoesNotExist +ContentLibraryCollectionNotFound = Collection.DoesNotExist + class ContentLibraryBlockNotFound(XBlockNotFoundError): """ XBlock not found in the content library """ @@ -120,6 +130,10 @@ class LibraryAlreadyExists(KeyError): """ A library with the specified slug already exists """ +class LibraryCollectionAlreadyExists(IntegrityError): + """ A Collection with that key already exists in the library """ + + class LibraryBlockAlreadyExists(KeyError): """ An XBlock with that ID already exists in the library """ @@ -150,6 +164,7 @@ class ContentLibraryMetadata: Class that represents the metadata about a content library. """ key = attr.ib(type=LibraryLocatorV2) + learning_package = attr.ib(type=LearningPackage) title = attr.ib("") description = attr.ib("") num_blocks = attr.ib(0) @@ -323,13 +338,14 @@ def get_metadata(queryset, text_search=None): has_unpublished_changes=False, has_unpublished_deletes=False, license=lib.license, + learning_package=lib.learning_package, ) for lib in queryset ] return libraries -def require_permission_for_library_key(library_key, user, permission): +def require_permission_for_library_key(library_key, user, permission) -> ContentLibrary: """ Given any of the content library permission strings defined in openedx.core.djangoapps.content_libraries.permissions, @@ -339,10 +355,12 @@ def require_permission_for_library_key(library_key, user, permission): Raises django.core.exceptions.PermissionDenied if the user doesn't have permission. """ - library_obj = ContentLibrary.objects.get_by_key(library_key) + library_obj = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] if not user.has_perm(permission, obj=library_obj): raise PermissionDenied + return library_obj + def get_library(library_key): """ @@ -408,6 +426,7 @@ def get_library(library_key): license=ref.license, created=learning_package.created, updated=learning_package.updated, + learning_package=learning_package ) @@ -479,6 +498,7 @@ def create_library( allow_public_learning=ref.allow_public_learning, allow_public_read=ref.allow_public_read, license=library_license, + learning_package=ref.learning_package ) @@ -1056,6 +1076,174 @@ def revert_changes(library_key): ) +def create_library_collection( + library_key: LibraryLocatorV2, + collection_key: str, + title: str, + *, + description: str = "", + created_by: int | None = None, + # As an optimization, callers may pass in a pre-fetched ContentLibrary instance + content_library: ContentLibrary | None = None, +) -> Collection: + """ + Creates a Collection in the given ContentLibrary, + and emits a LIBRARY_COLLECTION_CREATED event. + + If you've already fetched a ContentLibrary for the given library_key, pass it in here to avoid refetching. + """ + if not content_library: + content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] + assert content_library + assert content_library.learning_package_id + assert content_library.library_key == library_key + + try: + collection = authoring_api.create_collection( + learning_package_id=content_library.learning_package_id, + key=collection_key, + title=title, + description=description, + created_by=created_by, + ) + except IntegrityError as err: + raise LibraryCollectionAlreadyExists from err + + # Emit event for library collection created + LIBRARY_COLLECTION_CREATED.send_event( + library_collection=LibraryCollectionData( + library_key=library_key, + collection_key=collection.key, + ) + ) + + return collection + + +def update_library_collection( + library_key: LibraryLocatorV2, + collection_key: str, + *, + title: str | None = None, + description: str | None = None, + # As an optimization, callers may pass in a pre-fetched ContentLibrary instance + content_library: ContentLibrary | None = None, +) -> Collection: + """ + Creates a Collection in the given ContentLibrary, + and emits a LIBRARY_COLLECTION_CREATED event. + """ + if not content_library: + content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] + assert content_library + assert content_library.learning_package_id + assert content_library.library_key == library_key + + try: + collection = authoring_api.update_collection( + learning_package_id=content_library.learning_package_id, + key=collection_key, + title=title, + description=description, + ) + except Collection.DoesNotExist as exc: + raise ContentLibraryCollectionNotFound from exc + + # Emit event for library collection updated + LIBRARY_COLLECTION_UPDATED.send_event( + library_collection=LibraryCollectionData( + library_key=library_key, + collection_key=collection.key, + ) + ) + + return collection + + +def update_library_collection_components( + library_key: LibraryLocatorV2, + collection_key: str, + *, + usage_keys: list[UsageKeyV2], + created_by: int | None = None, + remove=False, + # As an optimization, callers may pass in a pre-fetched ContentLibrary instance + content_library: ContentLibrary | None = None, +) -> Collection: + """ + Associates the Collection with Components for the given UsageKeys. + + By default the Components are added to the Collection. + If remove=True, the Components are removed from the Collection. + + If you've already fetched the ContentLibrary, pass it in to avoid refetching. + + Raises: + * ContentLibraryCollectionNotFound if no Collection with the given pk is found in the given library. + * ContentLibraryBlockNotFound if any of the given usage_keys don't match Components in the given library. + + Returns the updated Collection. + """ + if not content_library: + content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] + assert content_library + assert content_library.learning_package_id + assert content_library.library_key == library_key + + # Fetch the Component.key values for the provided UsageKeys. + component_keys = [] + for usage_key in usage_keys: + # Parse the block_family from the key to use as namespace. + block_type = BlockTypeKey.from_string(str(usage_key)) + + try: + component = authoring_api.get_component_by_key( + content_library.learning_package_id, + namespace=block_type.block_family, + type_name=usage_key.block_type, + local_key=usage_key.block_id, + ) + except Component.DoesNotExist as exc: + raise ContentLibraryBlockNotFound(usage_key) from exc + + component_keys.append(component.key) + + # Note: Component.key matches its PublishableEntity.key + entities_qset = PublishableEntity.objects.filter( + key__in=component_keys, + ) + + if remove: + collection = authoring_api.remove_from_collection( + content_library.learning_package_id, + collection_key, + entities_qset, + ) + else: + collection = authoring_api.add_to_collection( + content_library.learning_package_id, + collection_key, + entities_qset, + created_by=created_by, + ) + + # Emit event for library collection updated + LIBRARY_COLLECTION_UPDATED.send_event( + library_collection=LibraryCollectionData( + library_key=library_key, + collection_key=collection.key, + ) + ) + + # Emit a CONTENT_OBJECT_TAGS_CHANGED event for each of the objects added/removed + for usage_key in usage_keys: + CONTENT_OBJECT_TAGS_CHANGED.send_event( + content_object=ContentObjectData(object_id=usage_key), + ) + + return collection + + # V1/V2 Compatibility Helpers # (Should be removed as part of # https://github.com/openedx/edx-platform/issues/32457) diff --git a/openedx/core/djangoapps/content_libraries/serializers.py b/openedx/core/djangoapps/content_libraries/serializers.py index 497eda8147..2062f96d93 100644 --- a/openedx/core/djangoapps/content_libraries/serializers.py +++ b/openedx/core/djangoapps/content_libraries/serializers.py @@ -4,7 +4,12 @@ Serializers for the content libraries REST API # pylint: disable=abstract-method from django.core.validators import validate_unicode_slug from rest_framework import serializers +from rest_framework.exceptions import ValidationError +from opaque_keys.edx.keys import UsageKeyV2 +from opaque_keys import InvalidKeyError + +from openedx_learning.api.authoring_models import Collection from openedx.core.djangoapps.content_libraries.constants import ( LIBRARY_TYPES, COMPLEX, @@ -245,3 +250,52 @@ class ContentLibraryBlockImportTaskCreateSerializer(serializers.Serializer): """ course_key = CourseKeyField() + + +class ContentLibraryCollectionSerializer(serializers.ModelSerializer): + """ + Serializer for a Content Library Collection + """ + + class Meta: + model = Collection + fields = '__all__' + + +class ContentLibraryCollectionUpdateSerializer(serializers.Serializer): + """ + Serializer for updating a Collection in a Content Library + """ + + title = serializers.CharField() + description = serializers.CharField(allow_blank=True) + + +class UsageKeyV2Serializer(serializers.Serializer): + """ + Serializes a UsageKeyV2. + """ + def to_representation(self, value: UsageKeyV2) -> str: + """ + Returns the UsageKeyV2 value as a string. + """ + return str(value) + + def to_internal_value(self, value: str) -> UsageKeyV2: + """ + Returns a UsageKeyV2 from the string value. + + Raises ValidationError if invalid UsageKeyV2. + """ + try: + return UsageKeyV2.from_string(value) + except InvalidKeyError as err: + raise ValidationError from err + + +class ContentLibraryCollectionComponentsUpdateSerializer(serializers.Serializer): + """ + Serializer for adding/removing Components to/from a Collection. + """ + + usage_keys = serializers.ListField(child=UsageKeyV2Serializer(), allow_empty=False) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_api.py b/openedx/core/djangoapps/content_libraries/tests/test_api.py index 87ae180d29..b7b646acac 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_api.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_api.py @@ -13,8 +13,20 @@ from opaque_keys.edx.keys import ( UsageKey, ) from opaque_keys.edx.locator import LibraryLocatorV2 +from openedx_events.content_authoring.data import ( + ContentObjectData, + LibraryCollectionData, +) +from openedx_events.content_authoring.signals import ( + CONTENT_OBJECT_TAGS_CHANGED, + LIBRARY_COLLECTION_CREATED, + LIBRARY_COLLECTION_UPDATED, +) +from openedx_events.tests.utils import OpenEdxEventsTestMixin from .. import api +from ..models import ContentLibrary +from .base import ContentLibrariesRestApiTest class EdxModulestoreImportClientTest(TestCase): @@ -241,3 +253,220 @@ class EdxApiImportClientTest(TestCase): block_olx ) mock_publish_changes.assert_not_called() + + +class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTestMixin): + """ + Tests for Content Library API collections methods. + + Same guidelines as ContentLibrariesTestCase. + """ + ENABLED_OPENEDX_EVENTS = [ + CONTENT_OBJECT_TAGS_CHANGED.event_type, + LIBRARY_COLLECTION_CREATED.event_type, + LIBRARY_COLLECTION_UPDATED.event_type, + ] + + @classmethod + def setUpClass(cls): + """ + Set up class method for the Test class. + + TODO: It's unclear why we need to call start_events_isolation ourselves rather than relying on + OpenEdxEventsTestMixin.setUpClass to handle it. It fails it we don't, and many other test cases do it, + so we're following a pattern here. But that pattern doesn't really make sense. + """ + super().setUpClass() + cls.start_events_isolation() + + def setUp(self): + super().setUp() + + # Create Content Libraries + self._create_library("test-lib-col-1", "Test Library 1") + self._create_library("test-lib-col-2", "Test Library 2") + + # Fetch the created ContentLibrare objects so we can access their learning_package.id + self.lib1 = ContentLibrary.objects.get(slug="test-lib-col-1") + self.lib2 = ContentLibrary.objects.get(slug="test-lib-col-2") + + # Create Content Library Collections + self.col1 = api.create_library_collection( + self.lib1.library_key, + collection_key="COL1", + title="Collection 1", + description="Description for Collection 1", + created_by=self.user.id, + ) + self.col2 = api.create_library_collection( + self.lib2.library_key, + collection_key="COL2", + title="Collection 2", + description="Description for Collection 2", + created_by=self.user.id, + ) + + # Create some library blocks in lib1 + self.lib1_problem_block = self._add_block_to_library( + self.lib1.library_key, "problem", "problem1", + ) + self.lib1_html_block = self._add_block_to_library( + self.lib1.library_key, "html", "html1", + ) + + def test_create_library_collection(self): + event_receiver = mock.Mock() + LIBRARY_COLLECTION_CREATED.connect(event_receiver) + + collection = api.create_library_collection( + self.lib2.library_key, + collection_key="COL4", + title="Collection 4", + description="Description for Collection 4", + created_by=self.user.id, + ) + assert collection.key == "COL4" + assert collection.title == "Collection 4" + assert collection.description == "Description for Collection 4" + assert collection.created_by == self.user + + assert event_receiver.call_count == 1 + self.assertDictContainsSubset( + { + "signal": LIBRARY_COLLECTION_CREATED, + "sender": None, + "library_collection": LibraryCollectionData( + self.lib2.library_key, + collection_key="COL4", + ), + }, + event_receiver.call_args_list[0].kwargs, + ) + + def test_create_library_collection_invalid_library(self): + library_key = LibraryLocatorV2.from_string("lib:INVALID:test-lib-does-not-exist") + with self.assertRaises(api.ContentLibraryNotFound) as exc: + api.create_library_collection( + library_key, + collection_key="COL4", + title="Collection 3", + ) + + def test_update_library_collection(self): + event_receiver = mock.Mock() + LIBRARY_COLLECTION_UPDATED.connect(event_receiver) + + self.col1 = api.update_library_collection( + self.lib1.library_key, + self.col1.key, + title="New title for Collection 1", + ) + assert self.col1.key == "COL1" + assert self.col1.title == "New title for Collection 1" + assert self.col1.description == "Description for Collection 1" + assert self.col1.created_by == self.user + + assert event_receiver.call_count == 1 + self.assertDictContainsSubset( + { + "signal": LIBRARY_COLLECTION_UPDATED, + "sender": None, + "library_collection": LibraryCollectionData( + self.lib1.library_key, + collection_key="COL1", + ), + }, + event_receiver.call_args_list[0].kwargs, + ) + + def test_update_library_collection_wrong_library(self): + with self.assertRaises(api.ContentLibraryCollectionNotFound) as exc: + api.update_library_collection( + self.lib1.library_key, + self.col2.key, + ) + + def test_update_library_collection_components(self): + assert not list(self.col1.entities.all()) + + self.col1 = api.update_library_collection_components( + self.lib1.library_key, + self.col1.key, + usage_keys=[ + UsageKey.from_string(self.lib1_problem_block["id"]), + UsageKey.from_string(self.lib1_html_block["id"]), + ], + ) + assert len(self.col1.entities.all()) == 2 + + self.col1 = api.update_library_collection_components( + self.lib1.library_key, + self.col1.key, + usage_keys=[ + UsageKey.from_string(self.lib1_html_block["id"]), + ], + remove=True, + ) + assert len(self.col1.entities.all()) == 1 + + def test_update_library_collection_components_event(self): + """ + Check that a CONTENT_OBJECT_TAGS_CHANGED event is raised for each added/removed component. + """ + event_receiver = mock.Mock() + CONTENT_OBJECT_TAGS_CHANGED.connect(event_receiver) + LIBRARY_COLLECTION_UPDATED.connect(event_receiver) + + api.update_library_collection_components( + self.lib1.library_key, + self.col1.key, + usage_keys=[ + UsageKey.from_string(self.lib1_problem_block["id"]), + UsageKey.from_string(self.lib1_html_block["id"]), + ], + ) + + assert event_receiver.call_count == 3 + self.assertDictContainsSubset( + { + "signal": LIBRARY_COLLECTION_UPDATED, + "sender": None, + "library_collection": LibraryCollectionData( + self.lib1.library_key, + collection_key="COL1", + ), + }, + event_receiver.call_args_list[0].kwargs, + ) + self.assertDictContainsSubset( + { + "signal": CONTENT_OBJECT_TAGS_CHANGED, + "sender": None, + "content_object": ContentObjectData( + object_id=UsageKey.from_string(self.lib1_problem_block["id"]), + ), + }, + event_receiver.call_args_list[1].kwargs, + ) + self.assertDictContainsSubset( + { + "signal": CONTENT_OBJECT_TAGS_CHANGED, + "sender": None, + "content_object": ContentObjectData( + object_id=UsageKey.from_string(self.lib1_html_block["id"]), + ), + }, + event_receiver.call_args_list[2].kwargs, + ) + + def test_update_collection_components_from_wrong_library(self): + with self.assertRaises(api.ContentLibraryBlockNotFound) as exc: + api.update_library_collection_components( + self.lib2.library_key, + self.col2.key, + usage_keys=[ + UsageKey.from_string(self.lib1_problem_block["id"]), + UsageKey.from_string(self.lib1_html_block["id"]), + ], + ) + assert self.lib1_problem_block["id"] in str(exc.exception) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_views_collections.py b/openedx/core/djangoapps/content_libraries/tests/test_views_collections.py new file mode 100644 index 0000000000..bc600759b5 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/tests/test_views_collections.py @@ -0,0 +1,457 @@ +""" +Tests Library Collections REST API views +""" + +from __future__ import annotations +import ddt + +from openedx_learning.api.authoring_models import Collection +from opaque_keys.edx.locator import LibraryLocatorV2 + +from openedx.core.djangolib.testing.utils import skip_unless_cms +from openedx.core.djangoapps.content_libraries import api +from openedx.core.djangoapps.content_libraries.tests.base import ContentLibrariesRestApiTest +from openedx.core.djangoapps.content_libraries.models import ContentLibrary +from common.djangoapps.student.tests.factories import UserFactory + +URL_PREFIX = '/api/libraries/v2/{lib_key}/' +URL_LIB_COLLECTIONS = URL_PREFIX + 'collections/' +URL_LIB_COLLECTION = URL_LIB_COLLECTIONS + '{collection_key}/' +URL_LIB_COLLECTION_COMPONENTS = URL_LIB_COLLECTION + 'components/' + + +@ddt.ddt +@skip_unless_cms # Content Library Collections REST API is only available in Studio +class ContentLibraryCollectionsViewsTest(ContentLibrariesRestApiTest): + """ + Tests for Content Library Collection REST API Views + """ + + def setUp(self): + super().setUp() + + # Create Content Libraries + self._create_library("test-lib-col-1", "Test Library 1") + self._create_library("test-lib-col-2", "Test Library 2") + self.lib1 = ContentLibrary.objects.get(slug="test-lib-col-1") + self.lib2 = ContentLibrary.objects.get(slug="test-lib-col-2") + + # Create Content Library Collections + self.col1 = api.create_library_collection( + self.lib1.library_key, + "COL1", + title="Collection 1", + created_by=self.user.id, + description="Description for Collection 1", + ) + + self.col2 = api.create_library_collection( + self.lib1.library_key, + "COL2", + title="Collection 2", + created_by=self.user.id, + description="Description for Collection 2", + ) + self.col3 = api.create_library_collection( + self.lib2.library_key, + "COL3", + title="Collection 3", + created_by=self.user.id, + description="Description for Collection 3", + ) + + # Create some library blocks + self.lib1_problem_block = self._add_block_to_library( + self.lib1.library_key, "problem", "problem1", + ) + self.lib1_html_block = self._add_block_to_library( + self.lib1.library_key, "html", "html1", + ) + self.lib2_problem_block = self._add_block_to_library( + self.lib2.library_key, "problem", "problem2", + ) + self.lib2_html_block = self._add_block_to_library( + self.lib2.library_key, "html", "html2", + ) + + def test_get_library_collection(self): + """ + Test retrieving a Content Library Collection + """ + resp = self.client.get( + URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_key=self.col3.key) + ) + + # Check that correct Content Library Collection data retrieved + expected_collection = { + "title": "Collection 3", + "description": "Description for Collection 3", + } + assert resp.status_code == 200 + self.assertDictContainsEntries(resp.data, expected_collection) + + # Check that a random user without permissions cannot access Content Library Collection + random_user = UserFactory.create(username="Random", email="random@example.com") + with self.as_user(random_user): + resp = self.client.get( + URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_key=self.col3.key) + ) + assert resp.status_code == 403 + + def test_get_invalid_library_collection(self): + """ + Test retrieving a an invalid Content Library Collection or one that does not exist + """ + # Fetch collection that belongs to a different library, it should fail + resp = self.client.get( + URL_LIB_COLLECTION.format(lib_key=self.lib1.library_key, collection_key=self.col3.key) + ) + + assert resp.status_code == 404 + + # Fetch collection with invalid ID provided, it should fail + resp = self.client.get( + URL_LIB_COLLECTION.format(lib_key=self.lib1.library_key, collection_key='123') + ) + + assert resp.status_code == 404 + + # Fetch collection with invalid library_key provided, it should fail + resp = self.client.get( + URL_LIB_COLLECTION.format(lib_key=123, collection_key='123') + ) + assert resp.status_code == 404 + + def test_list_library_collections(self): + """ + Test listing Content Library Collections + """ + resp = self.client.get(URL_LIB_COLLECTIONS.format(lib_key=self.lib1.library_key)) + + # Check that the correct collections are listed + assert resp.status_code == 200 + assert len(resp.data["results"]) == 2 + expected_collections = [ + {"key": "COL1", "title": "Collection 1", "description": "Description for Collection 1"}, + {"key": "COL2", "title": "Collection 2", "description": "Description for Collection 2"}, + ] + for collection, expected in zip(resp.data["results"], expected_collections): + self.assertDictContainsEntries(collection, expected) + + # Check that a random user without permissions cannot access Content Library Collections + random_user = UserFactory.create(username="Random", email="random@example.com") + with self.as_user(random_user): + resp = self.client.get(URL_LIB_COLLECTIONS.format(lib_key=self.lib1.library_key)) + assert resp.status_code == 403 + + def test_list_invalid_library_collections(self): + """ + Test listing invalid Content Library Collections + """ + non_existing_key = LibraryLocatorV2.from_string("lib:DoesNotExist:NE1") + resp = self.client.get(URL_LIB_COLLECTIONS.format(lib_key=non_existing_key)) + + assert resp.status_code == 404 + + # List collections with invalid library_key provided, it should fail + resp = resp = self.client.get(URL_LIB_COLLECTIONS.format(lib_key=123)) + assert resp.status_code == 404 + + def test_create_library_collection(self): + """ + Test creating a Content Library Collection + """ + post_data = { + "title": "Collection 4", + "description": "Description for Collection 4", + } + resp = self.client.post( + URL_LIB_COLLECTIONS.format(lib_key=self.lib1.library_key), post_data, format="json" + ) + # Check that the new Content Library Collection is returned in response and created in DB + assert resp.status_code == 200 + post_data["key"] = 'collection-4' + self.assertDictContainsEntries(resp.data, post_data) + + created_collection = Collection.objects.get(id=resp.data["id"]) + self.assertIsNotNone(created_collection) + + # Check that user with read only access cannot create new Content Library Collection + reader = UserFactory.create(username="Reader", email="reader@example.com") + self._add_user_by_email(self.lib1.library_key, reader.email, access_level="read") + + with self.as_user(reader): + post_data = { + "title": "Collection 5", + "description": "Description for Collection 5", + } + resp = self.client.post( + URL_LIB_COLLECTIONS.format(lib_key=self.lib1.library_key), post_data, format="json" + ) + + assert resp.status_code == 403 + + def test_create_collection_same_key(self): + """ + Test collection creation with same key + """ + post_data = { + "title": "Same Collection", + "description": "Description for Collection 4", + } + self.client.post( + URL_LIB_COLLECTIONS.format(lib_key=self.lib1.library_key), post_data, format="json" + ) + + for i in range(100): + resp = self.client.post( + URL_LIB_COLLECTIONS.format(lib_key=self.lib1.library_key), post_data, format="json" + ) + expected_data = { + "key": f"same-collection-{i + 1}", + "title": "Same Collection", + "description": "Description for Collection 4", + } + + assert resp.status_code == 200 + self.assertDictContainsEntries(resp.data, expected_data) + + def test_create_invalid_library_collection(self): + """ + Test creating an invalid Content Library Collection + """ + post_data_missing_title = { + "key": "COL_KEY", + } + resp = self.client.post( + URL_LIB_COLLECTIONS.format(lib_key=self.lib1.library_key), post_data_missing_title, format="json" + ) + + assert resp.status_code == 400 + + post_data_missing_key = { + "title": "Collection 4", + } + resp = self.client.post( + URL_LIB_COLLECTIONS.format(lib_key=self.lib1.library_key), post_data_missing_key, format="json" + ) + + assert resp.status_code == 400 + + # Create collection with an existing collection.key; it should fail + post_data_existing_key = { + "key": self.col1.key, + "title": "Collection 4", + } + resp = self.client.post( + URL_LIB_COLLECTIONS.format(lib_key=self.lib1.library_key), + post_data_existing_key, + format="json" + ) + assert resp.status_code == 400 + + # Create collection with invalid library_key provided, it should fail + resp = self.client.post( + URL_LIB_COLLECTIONS.format(lib_key=123), + {**post_data_missing_title, **post_data_missing_key}, + format="json" + ) + assert resp.status_code == 404 + + def test_update_library_collection(self): + """ + Test updating a Content Library Collection + """ + patch_data = { + "title": "Collection 3 Updated", + } + resp = self.client.patch( + URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_key=self.col3.key), + patch_data, + format="json" + ) + + # Check that updated Content Library Collection is returned in response and updated in DB + assert resp.status_code == 200 + self.assertDictContainsEntries(resp.data, patch_data) + + created_collection = Collection.objects.get(id=resp.data["id"]) + self.assertIsNotNone(created_collection) + self.assertEqual(created_collection.title, patch_data["title"]) + + # Check that user with read only access cannot update a Content Library Collection + reader = UserFactory.create(username="Reader", email="reader@example.com") + self._add_user_by_email(self.lib1.library_key, reader.email, access_level="read") + + with self.as_user(reader): + patch_data = { + "title": "Collection 3 should not update", + } + resp = self.client.patch( + URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_key=self.col3.key), + patch_data, + format="json" + ) + + assert resp.status_code == 403 + + def test_update_invalid_library_collection(self): + """ + Test updating an invalid Content Library Collection or one that does not exist + """ + patch_data = { + "title": "Collection 3 Updated", + } + # Update collection that belongs to a different library, it should fail + resp = self.client.patch( + URL_LIB_COLLECTION.format(lib_key=self.lib1.library_key, collection_key=self.col3.key), + patch_data, + format="json" + ) + + assert resp.status_code == 404 + + # Update collection with invalid ID provided, it should fail + resp = self.client.patch( + URL_LIB_COLLECTION.format(lib_key=self.lib1.library_key, collection_key='123'), + patch_data, + format="json" + ) + + assert resp.status_code == 404 + + # Update collection with invalid library_key provided, it should fail + resp = self.client.patch( + URL_LIB_COLLECTION.format(lib_key=123, collection_key=self.col3.key), + patch_data, + format="json" + ) + assert resp.status_code == 404 + + def test_delete_library_collection(self): + """ + Test deleting a Content Library Collection + + Note: Currently not implemented and should return a 405 + """ + resp = self.client.delete( + URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_key=self.col3.key) + ) + + assert resp.status_code == 405 + + def test_get_components(self): + """ + Retrieving components is not supported by the REST API; + use Meilisearch instead. + """ + resp = self.client.get( + URL_LIB_COLLECTION_COMPONENTS.format( + lib_key=self.lib1.library_key, + collection_key=self.col1.key, + ), + ) + assert resp.status_code == 405 + + def test_update_components(self): + """ + Test adding and removing components from a collection. + """ + # Add two components to col1 + resp = self.client.patch( + URL_LIB_COLLECTION_COMPONENTS.format( + lib_key=self.lib1.library_key, + collection_key=self.col1.key, + ), + data={ + "usage_keys": [ + self.lib1_problem_block["id"], + self.lib1_html_block["id"], + ] + } + ) + assert resp.status_code == 200 + assert resp.data == {"count": 2} + + # Remove one of the added components from col1 + resp = self.client.delete( + URL_LIB_COLLECTION_COMPONENTS.format( + lib_key=self.lib1.library_key, + collection_key=self.col1.key, + ), + data={ + "usage_keys": [ + self.lib1_problem_block["id"], + ] + } + ) + assert resp.status_code == 200 + assert resp.data == {"count": 1} + + @ddt.data("patch", "delete") + def test_update_components_wrong_collection(self, method): + """ + Collection must belong to the requested library. + """ + resp = getattr(self.client, method)( + URL_LIB_COLLECTION_COMPONENTS.format( + lib_key=self.lib2.library_key, + collection_key=self.col1.key, + ), + data={ + "usage_keys": [ + self.lib1_problem_block["id"], + ] + } + ) + assert resp.status_code == 404 + + @ddt.data("patch", "delete") + def test_update_components_missing_data(self, method): + """ + List of usage keys must contain at least one item. + """ + resp = getattr(self.client, method)( + URL_LIB_COLLECTION_COMPONENTS.format( + lib_key=self.lib2.library_key, + collection_key=self.col3.key, + ), + ) + assert resp.status_code == 400 + assert resp.data == { + "usage_keys": ["This field is required."], + } + + @ddt.data("patch", "delete") + def test_update_components_from_another_library(self, method): + """ + Adding/removing components from another library raises a 404. + """ + resp = getattr(self.client, method)( + URL_LIB_COLLECTION_COMPONENTS.format( + lib_key=self.lib2.library_key, + collection_key=self.col3.key, + ), + data={ + "usage_keys": [ + self.lib1_problem_block["id"], + self.lib1_html_block["id"], + ] + } + ) + assert resp.status_code == 404 + + @ddt.data("patch", "delete") + def test_update_components_permissions(self, method): + """ + Check that a random user without permissions cannot update a Content Library Collection's components. + """ + random_user = UserFactory.create(username="Random", email="random@example.com") + with self.as_user(random_user): + resp = getattr(self.client, method)( + URL_LIB_COLLECTION_COMPONENTS.format( + lib_key=self.lib1.library_key, + collection_key=self.col1.key, + ), + ) + assert resp.status_code == 403 diff --git a/openedx/core/djangoapps/content_libraries/urls.py b/openedx/core/djangoapps/content_libraries/urls.py index 6e450df635..9455f0de5e 100644 --- a/openedx/core/djangoapps/content_libraries/urls.py +++ b/openedx/core/djangoapps/content_libraries/urls.py @@ -7,6 +7,7 @@ from django.urls import include, path, re_path from rest_framework import routers from . import views +from . import views_collections # Django application name. @@ -18,6 +19,11 @@ app_name = 'openedx.core.djangoapps.content_libraries' import_blocks_router = routers.DefaultRouter() import_blocks_router.register(r'tasks', views.LibraryImportTaskViewSet, basename='import-block-task') +library_collections_router = routers.DefaultRouter() +library_collections_router.register( + r'collections', views_collections.LibraryCollectionsView, basename="library-collections" +) + # These URLs are only used in Studio. The LMS already provides all the # API endpoints needed to serve XBlocks from content libraries using the # standard XBlock REST API (see openedx.core.django_apps.xblock.rest_api.urls) @@ -45,6 +51,8 @@ urlpatterns = [ path('import_blocks/', include(import_blocks_router.urls)), # Paste contents of clipboard into library path('paste_clipboard/', views.LibraryPasteClipboardView.as_view()), + # Library Collections + path('', include(library_collections_router.urls)), ])), path('blocks//', include([ # Get metadata about a specific XBlock in this library, or delete the block: diff --git a/openedx/core/djangoapps/content_libraries/views.py b/openedx/core/djangoapps/content_libraries/views.py index bde8142d3f..835a5de1f1 100644 --- a/openedx/core/djangoapps/content_libraries/views.py +++ b/openedx/core/djangoapps/content_libraries/views.py @@ -84,6 +84,7 @@ from pylti1p3.contrib.django import DjangoCacheDataStorage, DjangoDbToolConf, Dj from pylti1p3.exception import LtiException, OIDCException import edx_api_doc_tools as apidocs +from opaque_keys import InvalidKeyError from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 from organizations.api import ensure_organization from organizations.exceptions import InvalidOrganizationException @@ -136,12 +137,21 @@ def convert_exceptions(fn): def wrapped_fn(*args, **kwargs): try: return fn(*args, **kwargs) + except InvalidKeyError as exc: + log.exception(str(exc)) + raise NotFound # lint-amnesty, pylint: disable=raise-missing-from except api.ContentLibraryNotFound: log.exception("Content library not found") raise NotFound # lint-amnesty, pylint: disable=raise-missing-from except api.ContentLibraryBlockNotFound: log.exception("XBlock not found in content library") raise NotFound # lint-amnesty, pylint: disable=raise-missing-from + except api.ContentLibraryCollectionNotFound: + log.exception("Collection not found in content library") + raise NotFound # lint-amnesty, pylint: disable=raise-missing-from + except api.LibraryCollectionAlreadyExists as exc: + log.exception(str(exc)) + raise ValidationError(str(exc)) # lint-amnesty, pylint: disable=raise-missing-from except api.LibraryBlockAlreadyExists as exc: log.exception(str(exc)) raise ValidationError(str(exc)) # lint-amnesty, pylint: disable=raise-missing-from diff --git a/openedx/core/djangoapps/content_libraries/views_collections.py b/openedx/core/djangoapps/content_libraries/views_collections.py new file mode 100644 index 0000000000..2f40a17886 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/views_collections.py @@ -0,0 +1,198 @@ +""" +Collections API Views +""" + +from __future__ import annotations + +from django.db.models import QuerySet +from django.utils.text import slugify +from django.db import transaction + +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet +from rest_framework.status import HTTP_405_METHOD_NOT_ALLOWED + +from opaque_keys.edx.locator import LibraryLocatorV2 +from openedx_learning.api import authoring as authoring_api +from openedx_learning.api.authoring_models import Collection + +from openedx.core.djangoapps.content_libraries import api, permissions +from openedx.core.djangoapps.content_libraries.models import ContentLibrary +from openedx.core.djangoapps.content_libraries.views import convert_exceptions +from openedx.core.djangoapps.content_libraries.serializers import ( + ContentLibraryCollectionSerializer, + ContentLibraryCollectionComponentsUpdateSerializer, + ContentLibraryCollectionUpdateSerializer, +) + + +class LibraryCollectionsView(ModelViewSet): + """ + Views to get, create and update Library Collections. + """ + + serializer_class = ContentLibraryCollectionSerializer + lookup_field = 'key' + + def __init__(self, *args, **kwargs) -> None: + """ + Caches the ContentLibrary for the duration of the request. + """ + super().__init__(*args, **kwargs) + self._content_library: ContentLibrary | None = None + + def get_content_library(self) -> ContentLibrary: + """ + Returns the requested ContentLibrary object, if access allows. + """ + if self._content_library: + return self._content_library + + lib_key_str = self.kwargs["lib_key_str"] + library_key = LibraryLocatorV2.from_string(lib_key_str) + permission = ( + permissions.CAN_VIEW_THIS_CONTENT_LIBRARY + if self.request.method in ['OPTIONS', 'GET'] + else permissions.CAN_EDIT_THIS_CONTENT_LIBRARY + ) + + self._content_library = api.require_permission_for_library_key( + library_key, + self.request.user, + permission, + ) + return self._content_library + + def get_queryset(self) -> QuerySet[Collection]: + """ + Returns a queryset for the requested Collections, if access allows. + + This method may raise exceptions; these are handled by the @convert_exceptions wrapper on the views. + """ + content_library = self.get_content_library() + assert content_library.learning_package_id + return authoring_api.get_collections(content_library.learning_package_id) + + def get_object(self) -> Collection: + """ + Returns the requested Collections, if access allows. + + This method may raise exceptions; these are handled by the @convert_exceptions wrapper on the views. + """ + collection = super().get_object() + content_library = self.get_content_library() + + # Ensure the ContentLibrary and Collection share the same learning package + if collection.learning_package_id != content_library.learning_package_id: + raise api.ContentLibraryCollectionNotFound + return collection + + @convert_exceptions + def retrieve(self, request, *args, **kwargs) -> Response: + """ + Retrieve the Content Library Collection + """ + # View declared so we can wrap it in @convert_exceptions + return super().retrieve(request, *args, **kwargs) + + @convert_exceptions + def list(self, request, *args, **kwargs) -> Response: + """ + List Collections that belong to Content Library + """ + # View declared so we can wrap it in @convert_exceptions + return super().list(request, *args, **kwargs) + + @convert_exceptions + def create(self, request, *args, **kwargs) -> Response: + """ + Create a Collection that belongs to a Content Library + """ + content_library = self.get_content_library() + create_serializer = ContentLibraryCollectionUpdateSerializer(data=request.data) + create_serializer.is_valid(raise_exception=True) + + title = create_serializer.validated_data['title'] + key = slugify(title) + + attempt = 0 + collection = None + while not collection: + modified_key = key if attempt == 0 else key + '-' + str(attempt) + try: + # Add transaction here to avoid TransactionManagementError on retry + with transaction.atomic(): + collection = api.create_library_collection( + library_key=content_library.library_key, + content_library=content_library, + collection_key=modified_key, + title=title, + description=create_serializer.validated_data["description"], + created_by=request.user.id, + ) + except api.LibraryCollectionAlreadyExists: + attempt += 1 + + serializer = self.get_serializer(collection) + + return Response(serializer.data) + + @convert_exceptions + def partial_update(self, request, *args, **kwargs) -> Response: + """ + Update a Collection that belongs to a Content Library + """ + content_library = self.get_content_library() + collection_key = kwargs["key"] + + update_serializer = ContentLibraryCollectionUpdateSerializer( + data=request.data, partial=True + ) + update_serializer.is_valid(raise_exception=True) + updated_collection = api.update_library_collection( + library_key=content_library.library_key, + collection_key=collection_key, + content_library=content_library, + **update_serializer.validated_data + ) + serializer = self.get_serializer(updated_collection) + + return Response(serializer.data) + + @convert_exceptions + def destroy(self, request, *args, **kwargs) -> Response: + """ + Deletes a Collection that belongs to a Content Library + + Note: (currently not allowed) + """ + # TODO: Implement the deletion logic and emit event signal + + return Response(None, status=HTTP_405_METHOD_NOT_ALLOWED) + + @convert_exceptions + @action(detail=True, methods=['delete', 'patch'], url_path='components', url_name='components-update') + def update_components(self, request, *args, **kwargs) -> Response: + """ + Adds (PATCH) or removes (DELETE) Components to/from a Collection. + + Collection and Components must all be part of the given library/learning package. + """ + content_library = self.get_content_library() + collection_key = kwargs["key"] + + serializer = ContentLibraryCollectionComponentsUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + usage_keys = serializer.validated_data["usage_keys"] + api.update_library_collection_components( + library_key=content_library.library_key, + content_library=content_library, + collection_key=collection_key, + usage_keys=usage_keys, + created_by=self.request.user.id, + remove=(request.method == "DELETE"), + ) + + return Response({'count': len(usage_keys)}) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 7fab48dea6..b5d8381561 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -93,7 +93,7 @@ libsass==0.10.0 click==8.1.6 # pinning this version to avoid updates while the library is being developed -openedx-learning==0.11.2 +openedx-learning==0.11.4 # Open AI version 1.0.0 dropped support for openai.ChatCompletion which is currently in use in enterprise. openai<=0.28.1 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 6c789e9f84..40d64855cb 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -811,7 +811,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/kernel.in openedx-django-wiki==2.1.0 # via -r requirements/edx/kernel.in -openedx-events==9.12.0 +openedx-events==9.14.0 # via # -r requirements/edx/kernel.in # edx-enterprise @@ -824,7 +824,7 @@ openedx-filters==1.9.0 # -r requirements/edx/kernel.in # lti-consumer-xblock # ora2 -openedx-learning==0.11.2 +openedx-learning==0.11.4 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 190f02b687..a18ddb26b8 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1358,7 +1358,7 @@ openedx-django-wiki==2.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -openedx-events==9.12.0 +openedx-events==9.14.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1373,7 +1373,7 @@ openedx-filters==1.9.0 # -r requirements/edx/testing.txt # lti-consumer-xblock # ora2 -openedx-learning==0.11.2 +openedx-learning==0.11.4 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index c0a7792839..00fc580ded 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -970,7 +970,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/base.txt openedx-django-wiki==2.1.0 # via -r requirements/edx/base.txt -openedx-events==9.12.0 +openedx-events==9.14.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -983,7 +983,7 @@ openedx-filters==1.9.0 # -r requirements/edx/base.txt # lti-consumer-xblock # ora2 -openedx-learning==0.11.2 +openedx-learning==0.11.4 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 391f183bf3..966bd772a8 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -1021,7 +1021,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/base.txt openedx-django-wiki==2.1.0 # via -r requirements/edx/base.txt -openedx-events==9.12.0 +openedx-events==9.14.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1034,7 +1034,7 @@ openedx-filters==1.9.0 # -r requirements/edx/base.txt # lti-consumer-xblock # ora2 -openedx-learning==0.11.2 +openedx-learning==0.11.4 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/xmodule/assets/capa/_display.scss b/xmodule/assets/capa/_display.scss index 15571b65dc..0aba39f899 100644 --- a/xmodule/assets/capa/_display.scss +++ b/xmodule/assets/capa/_display.scss @@ -50,7 +50,7 @@ $asterisk-icon: '\f069'; // .fa-asterisk // +Mixins - Status Icon - Capa // ==================== -@mixin status-icon($color: $gray, $fontAwesomeIcon: "\f00d") { +@mixin status-icon($color: var(--gray), $fontAwesomeIcon: "\f00d") { .status-icon { &::after { @extend %use-font-awesome; @@ -66,13 +66,13 @@ $asterisk-icon: '\f069'; // .fa-asterisk // ==================== h2 { margin-top: 0; - margin-bottom: ($baseline*0.75); + margin-bottom: calc((var(--baseline)*0.75)); &.problem-header { display: inline-block; section.staff { - margin-top: ($baseline*1.5); + margin-top: calc((var(--baseline)*1.5)); font-size: 80%; } } @@ -89,10 +89,10 @@ h2 { } %feedback-hint { - margin-top: ($baseline / 4); + margin-top: calc((var(--baseline) / 4)); .icon { - @include margin-right($baseline / 4); + @include margin-right(calc((var(--baseline) / 4))); } } @@ -100,7 +100,7 @@ h2 { @extend %feedback-hint; .icon { - color: $incorrect; + color: var(--incorrect); } } @@ -109,7 +109,7 @@ h2 { @extend %feedback-hint; .icon { - color: $correct; + color: var(--correct); } } @@ -143,19 +143,19 @@ iframe[seamless] { } .inline-error { - color: darken($error-color, 11%); + color: var(--error-color-dark); } div.problem-progress { display: inline-block; - color: $gray-d1; + color: var(--gray-d1); font-size: em(14); } // +Problem - Base // ==================== div.problem { - padding-top: $baseline; + padding-top: var(--baseline); @media print { display: block; @@ -176,25 +176,25 @@ div.problem { display: inline; + p { - margin-top: $baseline; + margin-top: var(--baseline); } } .question-description { - color: $gray-d1; - font-size: $small-font-size; + color: var(--gray-d1); + font-size: var(--small-font-size); } form > label, .problem-group-label { display: block; - margin-bottom: $baseline; + margin-bottom: var(--baseline); font: inherit; color: inherit; -webkit-font-smoothing: initial; } .problem-group-label + .question-description { - margin-top: -$baseline; + margin-top: calc(-1 * var(--baseline)); } } @@ -203,7 +203,7 @@ div.problem { // can not use the & + & since .problem is nested deeply in .xmodule_display.xmodule_CapaModule .wrapper-problem-response + .wrapper-problem-response, .wrapper-problem-response + p { - margin-top: ($baseline * 1.5); + margin-top: calc((var(--baseline) * 1.5)); } // Choice Group - silent class @@ -219,14 +219,14 @@ div.problem { display: inline-block; clear: both; - margin-bottom: ($baseline/2); - border: 2px solid $gray-l4; + margin-bottom: calc((var(--baseline)/2)); + border: 2px solid var(--gray-l4); border-radius: 3px; - padding: ($baseline/2); + padding: calc((var(--baseline)/2)); width: 100%; &::after { - @include margin-left($baseline*0.75); + @include margin-left(calc((var(--baseline)*0.75))); } } @@ -242,15 +242,15 @@ div.problem { input[type="radio"], input[type="checkbox"] { - @include margin($baseline/4); - @include margin-right($baseline/2); + @include margin(calc((var(--baseline)/4))); + @include margin-right(calc((var(--baseline)/2))); } input { &:focus, &:hover { & + label { - border: 2px solid $blue; + border: 2px solid var(--blue); } } @@ -258,25 +258,25 @@ div.problem { &:focus, &:hover { & + label.choicegroup_correct { - @include status-icon($correct, $checkmark-icon); + @include status-icon(var(--correct), $checkmark-icon); - border: 2px solid $correct; + border: 2px solid var(--correct); } & + label.choicegroup_partially-correct { - @include status-icon($partially-correct, $asterisk-icon); + @include status-icon(var(--partially-correct), $asterisk-icon); - border: 2px solid $partially-correct; + border: 2px solid var(--partially-correct); } & + label.choicegroup_incorrect { - @include status-icon($incorrect, $cross-icon); + @include status-icon(var(--incorrect), $cross-icon); - border: 2px solid $incorrect; + border: 2px solid var(--incorrect); } & + label.choicegroup_submitted { - border: 2px solid $submitted; + border: 2px solid var(--submitted); } } } @@ -293,11 +293,11 @@ div.problem { } label { - @include padding($baseline/2); - @include padding-left($baseline*2.3); + @include padding(calc((var(--baseline)/2))); + @include padding-left(calc((var(--baseline)*2.3))); position: relative; - font-size: $base-font-size; + font-size: var(--base-font-size); line-height: normal; cursor: pointer; } @@ -308,19 +308,19 @@ div.problem { position: absolute; top: 0.35em; - width: $baseline*1.1; - height: $baseline*1.1; + width: calc(var(--baseline)*1.1); + height: calc(var(--baseline)*1.1); z-index: 1; } legend { - margin-bottom: $baseline; + margin-bottom: var(--baseline); max-width: 100%; white-space: normal; } legend + .question-description { - margin-top: -$baseline; + margin-top: calc(-1 * var(--baseline)); max-width: 100%; white-space: normal; } @@ -332,24 +332,24 @@ div.problem { // Summary status indicators shown after the input area div.problem { .indicator-container { - @include margin-left($baseline*0.75); + @include margin-left(calc((var(--baseline)*0.75))); .status { - width: $baseline; + width: var(--baseline); // CASE: correct answer &.correct { - @include status-icon($correct, $checkmark-icon); + @include status-icon(var(--correct), $checkmark-icon); } // CASE: partially correct answer &.partially-correct { - @include status-icon($partially-correct, $asterisk-icon); + @include status-icon(var(--partially-correct), $asterisk-icon); } // CASE: incorrect answer &.incorrect { - @include status-icon($incorrect, $cross-icon); + @include status-icon(var(--incorrect), $cross-icon); } &.submitted, @@ -379,7 +379,7 @@ div.problem { .solution-span { > span { - margin: $baseline 0; + margin: var(--baseline) 0; display: block; position: relative; @@ -413,20 +413,20 @@ div.problem { font-style: normal; &:hover { - color: $blue; + color: var(--blue); } } } &.correct, &.ui-icon-check { input { - border-color: $correct; + border-color: var(--correct); } } &.partially-correct, &.ui-icon-check { input { - border-color: $partially-correct; + border-color: var(--partially-correct); } } @@ -438,25 +438,25 @@ div.problem { &.ui-icon-close { input { - border-color: $incorrect; + border-color: var(--incorrect); } } &.incorrect, &.incomplete { input { - border-color: $incorrect; + border-color: var(--incorrect); } } &.submitted, &.ui-icon-check { input { - border-color: $submitted; + border-color: var(--submitted); } } p.answer { display: inline-block; - margin-top: ($baseline / 2); + margin-top: calc((var(--baseline) / 2)); margin-bottom: 0; &::before { @@ -483,7 +483,7 @@ div.problem { } img.loading { - @include padding-left($baseline/2); + @include padding-left(calc((var(--baseline)/2))); display: inline-block; } @@ -517,7 +517,7 @@ div.problem { top: 4px; width: 14px; height: 14px; - background: url('#{$static-path}/images/unanswered-icon.png') center center no-repeat; + background: var(--icon-unanswered) center center no-repeat; } &.processing, &.ui-icon-processing { @@ -526,7 +526,7 @@ div.problem { top: 6px; width: 25px; height: 20px; - background: url('#{$static-path}/images/spinner.gif') center center no-repeat; + background: var(--icon-spinner) center center no-repeat; } &.ui-icon-check { @@ -535,7 +535,7 @@ div.problem { top: 3px; width: 25px; height: 20px; - background: url('#{$static-path}/images/correct-icon.png') center center no-repeat; + background: var(--icon-correct) center center no-repeat; } &.incomplete, &.ui-icon-close { @@ -544,24 +544,24 @@ div.problem { top: 3px; width: 20px; height: 20px; - background: url('#{$static-path}/images/incorrect-icon.png') center center no-repeat; + background: var(--icon-incorrect) center center no-repeat; } } .reload { @include float(right); - margin: ($baseline/2); + margin: calc((var(--baseline)/2)); } .grader-status { @include clearfix(); - margin: $baseline/2 0; - padding: $baseline/2; + margin: calc(var(--baseline)/2) 0; + padding: calc(var(--baseline)/2); border-radius: 5px; - background: $gray-l6; + background: var(--gray-l6); span { display: block; @@ -574,7 +574,7 @@ div.problem { .grading { margin: 0px 7px 0 0; padding-left: 25px; - background: url('#{$static-path}/images/info-icon.png') left center no-repeat; + background: var(--icon-info) left center no-repeat; text-indent: 0px; } @@ -586,11 +586,11 @@ div.problem { } &.file { - margin-top: $baseline; - padding: $baseline 0 0 0; + margin-top: var(--baseline); + padding: var(--baseline) 0 0 0; border: 0; border-top: 1px solid #eee; - background: $white; + background: var(--white); p.debug { display: none; @@ -605,13 +605,13 @@ div.problem { .evaluation { p { - margin-bottom: ($baseline/5); + margin-bottom: calc((var(--baseline)/5)); } } .feedback-on-feedback { - margin-right: $baseline; + margin-right: var(--baseline); height: 100px; } @@ -646,7 +646,7 @@ div.problem { } .submit-message-container { - margin: $baseline 0px ; + margin: var(--baseline) 0px ; } } @@ -753,17 +753,17 @@ div.problem { padding: 0px 5px; border: 1px solid #eaeaea; border-radius: 3px; - background-color: $gray-l6; + background-color: var(--gray-l6); white-space: nowrap; font-size: .9em; } pre { overflow: auto; - padding: 6px $baseline/2; - border: 1px solid $gray-l3; + padding: 6px calc(var(--baseline)/2); + border: 1px solid var(--gray-l3); border-radius: 3px; - background-color: $gray-l6; + background-color: var(--gray-l6); font-size: .9em; line-height: 1.4; @@ -784,7 +784,7 @@ div.problem { input { box-sizing: border-box; - border: 2px solid $gray-l4; + border: 2px solid var(--gray-l4); border-radius: 3px; min-width: 160px; height: 46px; @@ -792,47 +792,47 @@ div.problem { .status { display: inline-block; - margin-top: ($baseline/2); + margin-top: calc((var(--baseline)/2)); background: none; } // CASE: incorrect answer > .incorrect { input { - border: 2px solid $incorrect; + border: 2px solid var(--incorrect); } .status { - @include status-icon($incorrect, $cross-icon); + @include status-icon(var(--incorrect), $cross-icon); } } // CASE: partially correct answer > .partially-correct { input { - border: 2px solid $partially-correct; + border: 2px solid var(--partially-correct); } .status { - @include status-icon($partially-correct, $asterisk-icon); + @include status-icon(var(--partially-correct), $asterisk-icon); } } // CASE: correct answer > .correct { input { - border: 2px solid $correct; + border: 2px solid var(--correct); } .status { - @include status-icon($correct, $checkmark-icon); + @include status-icon(var(--correct), $checkmark-icon); } } // CASE: submitted, correctness withheld > .submitted { input { - border: 2px solid $submitted; + border: 2px solid var(--submitted); } .status { @@ -843,7 +843,7 @@ div.problem { // CASE: unanswered and unsubmitted > .unanswered, > .unsubmitted { input { - border: 2px solid $gray-l4; + border: 2px solid var(--gray-l4); } .status { @@ -868,7 +868,7 @@ div.problem { } .trailing_text { - @include margin-right($baseline/2); + @include margin-right(calc((var(--baseline)/2))); display: inline-block; } @@ -930,7 +930,7 @@ div.problem { visibility: hidden; width: 0; border-right: none; - border-left: 1px solid $black; + border-left: 1px solid var(--black); } } } @@ -952,14 +952,14 @@ div.problem { .capa-message { display: inline-block; - color: $gray-d1; + color: var(--gray-d1); -webkit-font-smoothing: antialiased; } // +Problem - Actions // ==================== div.problem .action { - min-height: $baseline; + min-height: var(--baseline); width: 100%; display: flex; display: -ms-flexbox; @@ -972,11 +972,11 @@ div.problem .action { display: inline-flex; justify-content: flex-end; width: 100%; - padding-bottom: $baseline; + padding-bottom: var(--baseline); } .problem-action-button-wrapper { - @include border-right(1px solid $gray-300); + @include border-right(1px solid var(--gray-300)); @include padding(0, 13px); // to create a 26px gap, which is an a11y recommendation display: inline-block; @@ -994,11 +994,11 @@ div.problem .action { &:hover, &:focus, &:active { - color: $primary !important; + color: var(--primary) !important; } .icon { - margin-bottom: $baseline / 10; + margin-bottom: calc(var(--baseline) / 10); display: block; } @@ -1008,41 +1008,41 @@ div.problem .action { } .submit-attempt-container { - padding-bottom: $baseline; + padding-bottom: var(--baseline); flex-grow: 1; display: flex; align-items: center; - @media (max-width: $bp-screen-lg) { + @media (max-width: var(--bp-screen-lg)) { max-width: 100%; - padding-bottom: $baseline; + padding-bottom: var(--baseline); } .submit { - @include margin-right($baseline / 2); + @include margin-right(calc((var(--baseline) / 2))); @include float(left); white-space: nowrap; } .submit-cta-description { - color: $primary; + color: var(--primary); font-size: small; - padding-right: $baseline / 2; + padding-right: calc(var(--baseline) / 2); } .submit-cta-link-button { - color: $primary; - padding-right: $baseline / 4; + color: var(--primary); + padding-right: calc(var(--baseline) / 4); } } .submission-feedback { - @include margin-right($baseline / 2); + @include margin-right(calc((var(--baseline) / 2))); - margin-top: $baseline / 2; + margin-top: calc(var(--baseline) / 2); display: inline-block; - color: $gray-d1; - font-size: $medium-font-size; + color: var(--gray-d1); + font-size: var(--medium-font-size); -webkit-font-smoothing: antialiased; vertical-align: middle; @@ -1082,7 +1082,7 @@ div.problem { display: block; margin: lh() 0; padding: lh(); - border: 1px solid $gray-l3; + border: 1px solid var(--gray-l3); } .message { @@ -1114,52 +1114,52 @@ div.problem { } div.capa_alert { - margin-top: $baseline; + margin-top: var(--baseline); padding: 8px 12px; - border: 1px solid $warning-color; + border: 1px solid var(--warning-color); border-radius: 3px; - background: $warning-color-accent; + background: var(--warning-color-accent); font-size: 0.9em; } .notification { @include float(left); - margin-top: $baseline / 2; - padding: ($baseline / 2.5) ($baseline / 2) ($baseline / 5) ($baseline / 2); - line-height: $base-line-height; + margin-top: calc(var(--baseline) / 2); + padding: calc((var(--baseline) / 2.5)) calc((var(--baseline) / 2)) calc((var(--baseline) / 5)) calc((var(--baseline) / 2)); + line-height: var(--base-line-height); &.success { - @include notification-by-type($success); + @include notification-by-type(var(--success)); } &.error { - @include notification-by-type($danger); + @include notification-by-type(var(--danger)); } &.warning { - @include notification-by-type($warning); + @include notification-by-type(var(--warning)); } &.general { - @include notification-by-type($general-color-accent); + @include notification-by-type(var(--general-color-accent)); } &.problem-hint { - border: 1px solid $uxpl-gray-background; + border: 1px solid var(--uxpl-gray-background); border-radius: 6px; .icon { - @include margin-right(3 * $baseline / 4); + @include margin-right(calc(3 * var(--baseline) / 4) ); - color: $uxpl-gray-dark; + color: var(--uxpl-gray-dark); } li { - color: $uxpl-gray-base; + color: var(--uxpl-gray-base); strong { - color: $uxpl-gray-dark; + color: var(--uxpl-gray-dark); } } } @@ -1168,7 +1168,7 @@ div.problem { @include float(left); position: relative; - top: $baseline / 5; + top: calc(var(--baseline) / 5); } .notification-message { @@ -1184,7 +1184,7 @@ div.problem { margin: 0; li:not(:last-child) { - margin-bottom: $baseline / 4; + margin-bottom: calc(var(--baseline) / 4); } } } @@ -1198,13 +1198,13 @@ div.problem { .notification-btn { @include float(right); - padding: ($baseline / 10) ($baseline / 4); - min-width: ($baseline * 3); + padding: calc((var(--baseline) / 10)) calc((var(--baseline) / 4)); + min-width: calc((var(--baseline) * 3)); display: block; clear: both; &:first-child { - margin-bottom: $baseline / 4; + margin-bottom: calc(var(--baseline) / 4); } } @@ -1225,26 +1225,26 @@ div.problem { &.btn-brand { &:hover { - background-color: $btn-brand-focus-background; + background-color: var(--btn-brand-focus-background); } } } .review-btn { - color: $blue; // notification type has other colors + color: var(--blue); // notification type has other colors &.sr { - color: $blue; + color: var(--blue); } } div.capa_reset { padding: 25px; - border: 1px solid $error-color; - background-color: lighten($error-color, 25%); + background-color: var(--error-color-light); + border: 1px solid var(--error-color); border-radius: 3px; font-size: 1em; - margin-top: $baseline/2; - margin-bottom: $baseline/2; + margin-top: calc(var(--baseline)/2); + margin-bottom: calc(var(--baseline)/2); } .capa_reset>h2 { @@ -1256,7 +1256,7 @@ div.problem { } .hints { - border: 1px solid $gray-l3; + border: 1px solid var(--gray-l3); h3 { @extend %t-strong; @@ -1264,7 +1264,7 @@ div.problem { padding: 9px; border-bottom: 1px solid #e3e3e3; background: #eee; - text-shadow: 0 1px 0 $white; + text-shadow: 0 1px 0 var(--white); font-size: em(16); } @@ -1283,8 +1283,8 @@ div.problem { a { display: block; padding: 9px; - background: $gray-l6; - box-shadow: inset 0 0 0 1px $white; + background: var(--gray-l6); + box-shadow: inset 0 0 0 1px var(--white); } } @@ -1311,11 +1311,11 @@ div.problem { > section { position: relative; - margin-bottom: ($baseline/2); - padding: 9px 9px $baseline; + margin-bottom: calc((var(--baseline)/2)); + padding: 9px 9px var(--baseline); border: 1px solid #ddd; border-radius: 3px; - background: $white; + background: var(--white); box-shadow: inset 0 0 0 1px #eee; p:last-of-type { @@ -1331,8 +1331,8 @@ div.problem { box-sizing: border-box; display: block; - padding: ($baseline/5); - background: $gray-l4; + padding: calc((var(--baseline)/5)); + background: var(--gray-l4); text-align: right; font-size: 1em; @@ -1349,8 +1349,8 @@ div.problem { .external-grader-message { section { - padding-top: ($baseline*1.5); - padding-left: $baseline; + padding-top: calc((var(--baseline)*1.5)); + padding-left: var(--baseline); background-color: #fafafa; color: #2c2c2c; font-size: 1em; @@ -1369,9 +1369,9 @@ div.problem { padding: 0; .result-errors { - margin: ($baseline/4); - padding: ($baseline/2) ($baseline/2) ($baseline/2) ($baseline*2); - background: url('#{$static-path}/images/incorrect-icon.png') center left no-repeat; + margin: calc((var(--baseline)/4)); + padding: calc((var(--baseline)/2)) calc((var(--baseline)/2)) calc((var(--baseline)/2)) calc((var(--baseline)*2)); + background: var(--icon-incorrect) center left no-repeat; li { color: #b00; @@ -1379,10 +1379,10 @@ div.problem { } .result-output { - margin: $baseline/4; - padding: $baseline 0 ($baseline*0.75) 50px; + margin: calc(var(--baseline)/4); + padding: var(--baseline) 0 calc((var(--baseline)*0.75)) 50px; border-top: 1px solid #ddd; - border-left: $baseline solid #fafafa; + border-left: var(--baseline) solid #fafafa; h4 { font-size: 1em; @@ -1394,7 +1394,7 @@ div.problem { } dt { - margin-top: $baseline; + margin-top: var(--baseline); } dd { @@ -1403,7 +1403,7 @@ div.problem { } .result-correct { - background: url('#{$static-path}/images/correct-icon.png') left 20px no-repeat; + background: var(--icon-correct) left 20px no-repeat; .result-actual-output { color: #090; @@ -1411,7 +1411,7 @@ div.problem { } .result-partially-correct { - background: url('#{$static-path}/images/partially-correct-icon.png') left 20px no-repeat; + background: var(--icon-partially-correct) left 20px no-repeat; .result-actual-output { color: #090; @@ -1419,7 +1419,7 @@ div.problem { } .result-incorrect { - background: url('#{$static-path}/images/incorrect-icon.png') left 20px no-repeat; + background: var(--icon-incorrect) left 20px no-repeat; .result-actual-output { color: #b00; @@ -1427,8 +1427,8 @@ div.problem { } .markup-text{ - margin: ($baseline/4); - padding: $baseline 0 15px 50px; + margin: calc((var(--baseline)/4)); + padding: var(--baseline) 0 15px 50px; border-top: 1px solid #ddd; border-left: 20px solid #fafafa; @@ -1451,19 +1451,19 @@ div.problem { div.problem { .rubric { tr { - margin: ($baseline/2) 0; + margin: calc((var(--baseline)/2)) 0; height: 100%; } td { - margin: ($baseline/2) 0; - padding: $baseline 0; + margin: calc((var(--baseline)/2)) 0; + padding: var(--baseline) 0; height: 100%; } th { - margin: ($baseline/4); - padding: ($baseline/4); + margin: calc((var(--baseline)/4)); + padding: calc((var(--baseline)/4)); } label, @@ -1471,12 +1471,12 @@ div.problem { position: relative; display: inline-block; margin: 3px; - padding: ($baseline*0.75); + padding: calc((var(--baseline)*0.75)); min-width: 50px; min-height: 50px; width: 150px; height: 100%; - background-color: $gray-l3; + background-color: var(--gray-l3); font-size: .9em; } @@ -1484,7 +1484,7 @@ div.problem { position: absolute; right: 0; bottom: 0; - margin: ($baseline/2); + margin: calc((var(--baseline)/2)); } .selected-grade { @@ -1508,14 +1508,14 @@ div.problem { div.problem { .annotation-input { margin: 0 0 1em 0; - border: 1px solid $gray-l3; + border: 1px solid var(--gray-l3); border-radius: 1em; .annotation-header { @extend %t-strong; padding: .5em 1em; - border-bottom: 1px solid $gray-l3; + border-bottom: 1px solid var(--gray-l3); } .annotation-body { padding: .5em 1em; } @@ -1557,7 +1557,7 @@ div.problem { @extend %ui-fake-link; display: inline-block; - margin-left: ($baseline*2); + margin-left: calc((var(--baseline)*2)); border: 1px solid rgb(102,102,102); &.selected { @@ -1590,13 +1590,13 @@ div.problem { .debug-value { margin: 1em 0; padding: 1em; - border: 1px solid $black; + border: 1px solid var(--black); background-color: #999; - color: $white; + color: var(--white); input[type="text"] { width: 100%; } - pre { background-color: $gray-l3; color: $black; } + pre { background-color: var(--gray-l3); color: var(--black); } &::before { @extend %t-strong; @@ -1623,7 +1623,7 @@ div.problem { @extend label.choicegroup_correct; input[type="text"] { - border-color: $correct; + border-color: var(--correct); } } @@ -1631,7 +1631,7 @@ div.problem { @extend label.choicegroup_partially-correct; input[type="text"] { - border-color: $partially-correct; + border-color: var(--partially-correct); } } @@ -1645,9 +1645,9 @@ div.problem { label.choicetextgroup_show_correct, section.choicetextgroup_show_correct { &::after { - @include margin-left($baseline*0.75); + @include margin-left(calc((var(--baseline)*0.75))); - content: url('#{$static-path}/images/correct-icon.png'); + content: var(--icon-correct); } } @@ -1682,15 +1682,15 @@ div.problem .imageinput.capa_inputtype { } .correct { - @include status-icon($correct, $checkmark-icon); + @include status-icon(var(--correct), $checkmark-icon); } .incorrect { - @include status-icon($incorrect, $cross-icon); + @include status-icon(var(--incorrect), $cross-icon); } .partially-correct { - @include status-icon($partially-correct, $asterisk-icon); + @include status-icon(var(--partially-correct), $asterisk-icon); } .submitted { @@ -1723,15 +1723,15 @@ div.problem .annotation-input { } .correct { - @include status-icon($correct, $checkmark-icon); + @include status-icon(var(--correct), $checkmark-icon); } .incorrect { - @include status-icon($incorrect, $cross-icon); + @include status-icon(var(--incorrect), $cross-icon); } .partially-correct { - @include status-icon($partially-correct, $asterisk-icon); + @include status-icon(var(--partially-correct), $asterisk-icon); } .submitted { @@ -1743,5 +1743,5 @@ div.problem .annotation-input { // ==================== .problems-wrapper .loading-spinner { text-align: center; - color: $gray-d1; + color: var(--gray-d1); } diff --git a/xmodule/assets/editor/_edit.scss b/xmodule/assets/editor/_edit.scss index 7169977652..9ecd31416c 100644 --- a/xmodule/assets/editor/_edit.scss +++ b/xmodule/assets/editor/_edit.scss @@ -18,7 +18,7 @@ @include linear-gradient(top, #d4dee8, #c9d5e2); position: relative; - padding: ($baseline/4); + padding: calc((var(--baseline)/4)); border-bottom-color: #a5aaaf; button { @@ -26,7 +26,7 @@ @include float(left); - padding: 3px ($baseline/2) 5px; + padding: 3px calc((var(--baseline)/2)) 5px; margin-left: 7px; border: 0; border-radius: 2px; @@ -53,7 +53,7 @@ li { @include float(left); - @include margin-right($baseline/4); + @include margin-right(calc((var(--baseline)/4))); &:last-child { @include margin-right(0); @@ -67,7 +67,7 @@ border: 1px solid #a5aaaf; border-radius: 3px 3px 0 0; - @include linear-gradient(top, $transparent 87%, rgba(0, 0, 0, .06)); + @include linear-gradient(top, var(--transparent) 87%, rgba(0, 0, 0, .06)); background-color: #e5ecf3; font-size: 13px; @@ -75,8 +75,8 @@ box-shadow: 1px -1px 1px rgba(0, 0, 0, .05); &.current { - background: $white; - border-bottom-color: $white; + background: var(--white); + border-bottom-color: var(--white); } } } diff --git a/xmodule/assets/html/_display.scss b/xmodule/assets/html/_display.scss index 25e2ce4fbd..beceaa1d01 100644 --- a/xmodule/assets/html/_display.scss +++ b/xmodule/assets/html/_display.scss @@ -10,8 +10,8 @@ } h1 { - color: $body-color; - font: normal 2em/1.4em $font-family-sans-serif; + color: var(--body-color); + font: normal 2em/1.4em var(--font-family-sans-serif); letter-spacing: 1px; @include margin(0, 0, 1.416em, 0); @@ -19,9 +19,9 @@ h1 { h2 { color: #646464; - font: normal 1.2em/1.2em $font-family-sans-serif; + font: normal 1.2em/1.2em var(--font-family-sans-serif); letter-spacing: 1px; - margin-bottom: ($baseline*0.75); + margin-bottom: calc((var(--baseline)*0.75)); -webkit-font-smoothing: antialiased; } @@ -29,7 +29,7 @@ h3, h4, h5, h6 { - @include margin(0, 0, ($baseline/2), 0); + @include margin(0, 0, calc((var(--baseline)/2)), 0); font-weight: 600; } @@ -54,7 +54,7 @@ p { margin-bottom: 1.416em; font-size: 1em; line-height: 1.6em !important; - color: $body-color; + color: var(--body-color); } em, @@ -78,11 +78,11 @@ b { p + p, ul + p, ol + p { - margin-top: $baseline; + margin-top: var(--baseline); } blockquote { - margin: 1em ($baseline*2); + margin: 1em calc((var(--baseline)*2)); } ol, @@ -91,7 +91,7 @@ ul { @include bi-app-compact(padding, 0, 0, 0, 1em); margin: 1em 0; - color: $body-color; + color: var(--body-color); li { margin-bottom: 0.708em; @@ -112,7 +112,7 @@ a { &:hover, &:active, &:focus { - color: $blue; + color: var(--blue); } } @@ -122,7 +122,7 @@ img { pre { margin: 1em 0; - color: $body-color; + color: var(--body-color); font-family: monospace, serif; font-size: 1em; white-space: pre-wrap; @@ -130,7 +130,7 @@ pre { } code { - color: $body-color; + color: var(--body-color); font-family: monospace, serif; background: none; padding: 0; @@ -138,15 +138,15 @@ code { table { width: 100%; - margin: $baseline 0; + margin: var(--baseline) 0; border-collapse: collapse; font-size: 16px; td, th { - margin: $baseline 0; - padding: ($baseline/2); - border: 1px solid $gray-l3; + margin: var(--baseline) 0; + padding: calc((var(--baseline)/2)); + border: 1px solid var(--gray-l3); font-size: 14px; &.cont-justified-left { @@ -179,12 +179,12 @@ th { position: absolute; display: block; - padding: ($baseline/4) 7px; + padding: calc((var(--baseline)/4)) 7px; border-radius: 5px; opacity: 0.9; - background: $white; - color: $black; - border: 2px solid $black; + background: var(--white); + color: var(--black); + border: 2px solid var(--black); .label { font-weight: bold; @@ -269,11 +269,11 @@ th { position: relative; &.action-zoom-in { - margin-right: ($baseline/4); + margin-right: calc((var(--baseline)/4)); } &.action-zoom-out { - margin-left: ($baseline/4); + margin-left: calc((var(--baseline)/4)); } &.is-disabled { diff --git a/xmodule/assets/lti/_lti.scss b/xmodule/assets/lti/_lti.scss index 4bd2c41317..9eee710f0d 100644 --- a/xmodule/assets/lti/_lti.scss +++ b/xmodule/assets/lti/_lti.scss @@ -10,7 +10,7 @@ h2.problem-header { div.problem-progress { display: inline-block; - padding-left: ($baseline/4); + padding-left: calc((var(--baseline)/4)); color: #666; font-weight: 100; font-size: em(16); @@ -24,8 +24,8 @@ div.lti { .wrapper-lti-link { @include font-size(14); - background-color: $sidebar-color; - padding: $baseline; + background-color: var(--sidebar-color); + padding: var(--baseline); .lti-link { margin-bottom: 0; @@ -58,8 +58,8 @@ div.lti { } div.problem-feedback { - margin-top: ($baseline/4); - margin-bottom: ($baseline/4); + margin-top: calc((var(--baseline)/4)); + margin-bottom: calc((var(--baseline)/4)); } } diff --git a/xmodule/assets/poll/_display.scss b/xmodule/assets/poll/_display.scss index 7c07f89376..7c9b21bf20 100644 --- a/xmodule/assets/poll/_display.scss +++ b/xmodule/assets/poll/_display.scss @@ -20,13 +20,13 @@ div.poll_question { h3 { margin-top: 0; - margin-bottom: ($baseline*0.75); + margin-bottom: calc((var(--baseline)*0.75)); color: #fe57a1; font-size: 1.9em; &.problem-header { div.staff { - margin-top: ($baseline*1.5); + margin-top: calc((var(--baseline)*1.5)); font-size: 80%; } } @@ -44,7 +44,7 @@ div.poll_question { } .poll_answer { - margin-bottom: $baseline; + margin-bottom: var(--baseline); &.short { clear: both; @@ -107,7 +107,7 @@ div.poll_question { font-weight: bold; letter-spacing: normal; line-height: 25.59375px; - margin-bottom: ($baseline*0.75); + margin-bottom: calc((var(--baseline)*0.75)); margin: 0; padding: 0px; text-align: center; @@ -145,9 +145,9 @@ div.poll_question { width: 80%; text-align: left; min-height: 30px; - margin-left: $baseline; + margin-left: var(--baseline); height: auto; - margin-bottom: $baseline; + margin-bottom: var(--baseline); &.short { width: 100px; @@ -157,7 +157,7 @@ div.poll_question { .stats { min-height: 40px; - margin-top: $baseline; + margin-top: var(--baseline); clear: both; &.short { @@ -174,7 +174,7 @@ div.poll_question { border: 1px solid black; display: inline; float: left; - margin-right: ($baseline/2); + margin-right: calc((var(--baseline)/2)); &.short { width: 65%; diff --git a/xmodule/assets/problem/_edit.scss b/xmodule/assets/problem/_edit.scss index 018a0961c2..f3fc795ec6 100644 --- a/xmodule/assets/problem/_edit.scss +++ b/xmodule/assets/problem/_edit.scss @@ -5,20 +5,20 @@ margin-top: -4px; padding: 3px 9px; font-size: 12px; - color: $link-color; + color: var(--link-color); &.current { - border: 1px solid $lightGrey !important; + border: 1px solid var(--lightGrey) !important; border-radius: 3px !important; - background: $lightGrey !important; - color: $darkGrey !important; + background: var(--lightGrey) !important; + color: var(--darkGrey) !important; pointer-events: none; cursor: none; &:hover, &:focus { box-shadow: 0 0 0 0 !important; - background-color: $white; + background-color: var(--white); } } } @@ -31,9 +31,9 @@ top: 41px; @include left(70%); width: 0; - border-left: 1px solid $gray-l2; + border-left: 1px solid var(--gray-l2); - background-color: $lightGrey; + background-color: var(--lightGrey); overflow: hidden; &.shown { @@ -76,7 +76,7 @@ margin-right: 30px; .icon { - height: ($baseline * 1.5); + height: calc((var(--baseline) * 1.5)); } } } @@ -105,5 +105,5 @@ width: 26px; height: 21px; vertical-align: middle; - color: $body-color; + color: var(--body-color); } diff --git a/xmodule/assets/sequence/_display.scss b/xmodule/assets/sequence/_display.scss index 3ddda8b37d..595b602a88 100644 --- a/xmodule/assets/sequence/_display.scss +++ b/xmodule/assets/sequence/_display.scss @@ -5,9 +5,9 @@ @import 'bootstrap/scss/mixins/breakpoints'; @import 'lms/theme/variables-v1'; -$seq-nav-border-color: $border-color !default; +$seq-nav-border-color: var(--border-color) !default; $seq-nav-hover-color: rgb(245, 245, 245) !default; -$seq-nav-link-color: $link-color !default; +$seq-nav-link-color: var(--link-color) !default; $seq-nav-icon-color: rgb(10, 10, 10) !default; $seq-nav-icon-color-muted: rgb(90, 90, 90) !default; $seq-nav-tooltip-color: rgb(51, 51, 51) !default; @@ -69,7 +69,7 @@ $seq-nav-height: 50px; .sequence-nav { @extend .topbar; - margin: 0 auto $baseline; + margin: 0 auto var(--baseline); position: relative; border-bottom: none; z-index: 0; @@ -172,14 +172,14 @@ $seq-nav-height: 50px; margin-top: 12px; background: $seq-nav-tooltip-color; - color: $white; - font-family: $font-family-sans-serif; + color: var(--white); + font-family: var(--font-family-sans-serif); line-height: lh(); right: 0; // Should not be RTLed, tooltips do not move in RTL padding: 6px; position: absolute; top: 48px; - text-shadow: 0 -1px 0 $black; + text-shadow: 0 -1px 0 var(--black); white-space: pre; pointer-events: none; @@ -239,7 +239,7 @@ $seq-nav-height: 50px; text-overflow: ellipsis; span:not(:last-child) { - @include padding-right($baseline / 2); + @include padding-right(calc((var(--baseline) / 2))); } } diff --git a/xmodule/assets/tabs/_codemirror.scss b/xmodule/assets/tabs/_codemirror.scss index 237d185033..37a894a103 100644 --- a/xmodule/assets/tabs/_codemirror.scss +++ b/xmodule/assets/tabs/_codemirror.scss @@ -9,7 +9,7 @@ height: 379px; border: 1px solid #3c3c3c; border-top: 1px solid #8891a1; - background: $white; + background: var(--white); color: #3c3c3c; } diff --git a/xmodule/assets/tabs/_tabs.scss b/xmodule/assets/tabs/_tabs.scss index ad47d915a2..4b8c2a387a 100644 --- a/xmodule/assets/tabs/_tabs.scss +++ b/xmodule/assets/tabs/_tabs.scss @@ -31,12 +31,12 @@ .edit-header { box-sizing: border-box; - padding: 18px $baseline; + padding: 18px var(--baseline); top: 0 !important; // ugly override for second level tab override right: 0; - background-color: $blue; - border-bottom: 1px solid $blue-d2; - color: $white; + background-color: var(--blue); + border-bottom: 1px solid var(--blue-d2); + color: var(--white); //Component Name .component-name { @@ -44,16 +44,16 @@ top: 0; left: 0; width: 50%; - color: $white; + color: var(--white); font-weight: 600; em { display: inline-block; - margin-right: ($baseline/4); + margin-right: calc((var(--baseline)/4)); font-weight: 400; - color: $white; + color: var(--white); } } @@ -61,9 +61,9 @@ .editor-tabs { list-style: none; right: 0; - top: ($baseline/4); + top: calc((var(--baseline)/4)); position: absolute; - padding: 12px ($baseline*0.75); + padding: 12px calc((var(--baseline)*0.75)); .inner_tab_wrap { display: inline-block; @@ -73,25 +73,25 @@ @include font-size(14); @include linear-gradient(top, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0)); - border: 1px solid $blue-d1; + border: 1px solid var(--blue-d1); border-radius: 3px; - padding: ($baseline/4) ($baseline); - background-color: $blue; + padding: calc((var(--baseline)/4)) (var(--baseline)); + background-color: var(--blue); font-weight: bold; - color: $white; + color: var(--white); &.current { - @include linear-gradient($blue, $blue); + @include linear-gradient(var(--blue), var(--blue)); - color: $blue-d1; - box-shadow: inset 0 1px 2px 1px $shadow-l1; - background-color: $blue-d4; + color: var(--blue-d1); + box-shadow: inset 0 1px 2px 1px var(--shadow-l1); + background-color: var(--blue-d4); cursor: default; } &:hover, &:focus { - box-shadow: inset 0 1px 2px 1px $shadow; + box-shadow: inset 0 1px 2px 1px var(--shadow); background-image: linear-gradient(#009fe6, #009fe6) !important; } } @@ -113,7 +113,7 @@ .comp-subtitles-import-list { > li { display: block; - margin: $baseline/2 0; + margin: calc(var(--baseline)/2) 0; } .blue-button { @@ -128,7 +128,7 @@ } .component-tab { - background: $white; + background: var(--white); position: relative; border-top: 1px solid #8891a1; diff --git a/xmodule/assets/video/_accessible_menu.scss b/xmodule/assets/video/_accessible_menu.scss index f7153fa984..d411925f23 100644 --- a/xmodule/assets/video/_accessible_menu.scss +++ b/xmodule/assets/video/_accessible_menu.scss @@ -1,11 +1,12 @@ @import 'base/mixins'; +@import 'lms/theme/variables-v1'; $a11y--gray: rgb(127, 127, 127); $a11y--blue: rgb(0, 159, 230); -$a11y--gray-d1: shade($gray, 20%); -$a11y--gray-l2: tint($gray, 40%); -$a11y--gray-l3: tint($gray, 60%); -$a11y--blue-s1: saturate($blue, 15%); +$a11y--gray-d1: var(--gray-d1); +$a11y--gray-l2: var(--gray-l2); +$a11y--gray-l3: var(--gray-l3); +$a11y--blue-s1: var(--blue-s1); %use-font-awesome { font-family: FontAwesome; @@ -32,7 +33,7 @@ $a11y--blue-s1: saturate($blue, 15%); display: none; position: absolute; list-style: none; - background-color: $white; + background-color: var(--white); border: 1px solid #eee; li { @@ -41,7 +42,7 @@ $a11y--blue-s1: saturate($blue, 15%); margin: 0; padding: 0; border-bottom: 1px solid #eee; - color: $white; + color: var(--white); a { display: block; @@ -84,23 +85,23 @@ $a11y--blue-s1: saturate($blue, 15%); &.open { > a { - background-color: $action-primary-active-bg; - color: $very-light-text; + background-color: var(--action-primary-active-bg); + color: var(--very-light-text); &::after { - color: $very-light-text; + color: var(--very-light-text); } } } > a { - @include transition(all $tmg-f2 ease-in-out 0s); + @include transition(all var(--tmg-f2) ease-in-out 0s); @include font-size(12); display: block; border-radius: 0 3px 3px 0; - background-color: $very-light-text; - padding: ($baseline*0.75) ($baseline*1.25) ($baseline*0.75) ($baseline*0.75); + background-color: var(--very-light-text); + padding: calc((var(--baseline)*0.75)) calc((var(--baseline)*1.25)) calc((var(--baseline)*0.75)) calc((var(--baseline)*0.75)); color: $a11y--gray-l2; min-width: 1.5em; line-height: 14px; @@ -113,9 +114,9 @@ $a11y--blue-s1: saturate($blue, 15%); content: "\f0d7"; position: absolute; - right: ($baseline*0.5); + right: calc((var(--baseline)*0.5)); top: 33%; - color: $lighter-base-font-color; + color: var(--lighter-base-font-color); } } @@ -144,7 +145,7 @@ $a11y--blue-s1: saturate($blue, 15%); @extend %ui-depth5; border: 1px solid #333; - background: $white; + background: var(--white); color: #333; padding: 0; margin: 0; @@ -162,8 +163,8 @@ $a11y--blue-s1: saturate($blue, 15%); .menu-item, .submenu-item { - border-top: 1px solid $gray-l3; - padding: ($baseline/4) ($baseline/2); + border-top: 1px solid var(--gray-l3); + padding: calc((var(--baseline)/4)) calc((var(--baseline)/2)); outline: none; & > span { @@ -176,17 +177,17 @@ $a11y--blue-s1: saturate($blue, 15%); &:focus { background: #333; - color: $white; + color: var(--white); & > span { - color: $white; + color: var(--white); } } } .submenu-item { position: relative; - padding: ($baseline/4) $baseline ($baseline/4) ($baseline/2); + padding: calc((var(--baseline)/4)) var(--baseline) calc((var(--baseline)/4)) calc((var(--baseline)/2)); &::after { content: '\25B6'; @@ -202,10 +203,10 @@ $a11y--blue-s1: saturate($blue, 15%); &.is-opened { background: #333; - color: $white; + color: var(--white); & > span { - color: $white; + color: var(--white); } & > .submenu { @@ -220,7 +221,7 @@ $a11y--blue-s1: saturate($blue, 15%); .is-disabled { pointer-events: none; - color: $gray-l3; + color: var(--gray-l3); } } diff --git a/xmodule/assets/video/_display.scss b/xmodule/assets/video/_display.scss index fd5cd73b21..c1f2ccee19 100644 --- a/xmodule/assets/video/_display.scss +++ b/xmodule/assets/video/_display.scss @@ -23,7 +23,7 @@ $secondary-light: rgb(219, 139, 175); // UXPL secondary light $cool-dark: rgb(79, 89, 93); // UXPL cool dark & { - margin-bottom: ($baseline*1.5); + margin-bottom: calc((var(--baseline)*1.5)); } .is-hidden { @@ -99,9 +99,9 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark .branding, .wrapper-transcript-feedback { flex: 1; - margin-top: $baseline; + margin-top: var(--baseline); - @include padding-right($baseline); + @include padding-right(var(--baseline)); vertical-align: top; } @@ -147,14 +147,14 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark left: -9999em; display: inline-block; vertical-align: middle; - color: $body-color; + color: var(--body-color); } .brand-logo { display: inline-block; max-width: 100%; - max-height: ($baseline*2); - padding: ($baseline/4) 0; + max-height: calc((var(--baseline)*2)); + padding: calc((var(--baseline)/4)) 0; vertical-align: middle; } } @@ -180,8 +180,8 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark .google-disclaimer { display: none; - margin-top: $baseline; - @include padding-right($baseline); + margin-top: var(--baseline); + @include padding-right(var(--baseline)); vertical-align: top; } @@ -246,7 +246,7 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark opacity: 0.1; &::after { - background: $white; + background: var(--white); position: absolute; width: 50%; height: 50%; @@ -271,23 +271,23 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark } .closed-captions.is-visible { - max-height: ($baseline * 3); - border-radius: ($baseline / 5); - padding: 8px ($baseline / 2) 8px ($baseline * 1.5); + max-height: calc((var(--baseline) * 3)); + border-radius: calc((var(--baseline) / 5)); + padding: 8px calc((var(--baseline) / 2)) 8px calc((var(--baseline) * 1.5)); background: rgba(0, 0, 0, 0.75); - color: $yellow; + color: var(--yellow); &::before { position: absolute; display: inline-block; top: 50%; - @include left($baseline); + @include left(var(--baseline)); margin-top: -0.6em; font-family: 'FontAwesome'; content: "\f142"; - color: $white; + color: var(--white); opacity: 0.5; } @@ -316,7 +316,7 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark .video-error, .video-hls-error { - padding: ($baseline / 5); + padding: calc((var(--baseline) / 5)); background: black; color: white !important; // the pattern library headings shim is more scoped } @@ -366,7 +366,7 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark margin: 0; border: 0; border-radius: 0; - padding: ($baseline / 2) ($baseline / 1.5); + padding: calc((var(--baseline) / 2)) calc((var(--baseline) / 1.5)); background: rgb(40, 44, 46); // UXPL grayscale-cool x-dark box-shadow: none; text-shadow: none; @@ -409,7 +409,7 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark left: 0; right: 0; z-index: 1; - height: ($baseline / 4); + height: calc((var(--baseline) / 4)); margin-left: 0; border: 1px solid $cool-dark; border-radius: 0; @@ -436,11 +436,11 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark box-sizing: border-box; top: -1px; - height: ($baseline / 4); - width: ($baseline / 4); - margin-left: -($baseline / 8); // center-center causes the control to be beyond the end of the sider + height: calc((var(--baseline) / 4)); + width: calc((var(--baseline) / 4)); + margin-left: calc(-1 * (var(--baseline) / 8)); // center-center causes the control to be beyond the end of the sider border: 1px solid $secondary-base; - border-radius: ($baseline / 5); + border-radius: calc((var(--baseline) / 5)); padding: 0; background: $secondary-base; box-shadow: none; @@ -527,7 +527,7 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark position: absolute; display: none; - bottom: ($baseline * 2); + bottom: calc((var(--baseline) * 2)); @include right(0); // right-align menus since this whole collection is on the right @@ -571,9 +571,9 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark &.is-active { .speed-option, .control-lang { - @include border-left($baseline/10 solid rgb(14, 166, 236)); + @include border-left(var(--baseline)/10 solid rgb(14, 166, 236)); - font-weight: $font-bold; + font-weight: var(--font-bold); color: rgb(14, 166, 236); // UXPL primary accent } } @@ -610,9 +610,9 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark .speed-button { .label { - @include padding(0 ($baseline/3) 0 0); + @include padding(0 calc((var(--baseline)/3)) 0 0); - font-family: $font-family-sans-serif; + font-family: var(--font-family-sans-serif); color: rgb(231, 236, 238); // UXPL grayscale-cool x-light @media (max-width: 1120px) { @@ -636,8 +636,8 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark .lang { .language-menu { - width: $baseline; - padding: ($baseline / 2) 0; + width: var(--baseline); + padding: calc((var(--baseline) / 2)) 0; } .control { @@ -685,7 +685,7 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark display: none; position: absolute; - bottom: ($baseline * 2); + bottom: calc((var(--baseline) * 2)); @include right(0); @@ -695,7 +695,7 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark .volume-slider { height: 100px; - width: ($baseline / 4); + width: calc((var(--baseline) / 4)); margin: 14px auto; box-sizing: border-box; border: 1px solid $cool-dark; @@ -704,14 +704,14 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark .ui-slider-handle { @extend %ui-fake-link; - @include transition(height $tmg-s2 ease-in-out 0s, width $tmg-s2 ease-in-out 0s); + @include transition(height var(--tmg-s2) ease-in-out 0s, width var(--tmg-s2) ease-in-out 0s); @include left(-5px); box-sizing: border-box; height: 13px; width: 13px; border: 1px solid $secondary-base; - border-radius: ($baseline / 5); + border-radius: calc((var(--baseline) / 5)); padding: 0; background: $secondary-base; box-shadow: none; @@ -763,11 +763,11 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark &:hover { .video-controls { .slider { - height: ($baseline / 1.5); + height: calc((var(--baseline) / 1.5)); .ui-slider-handle { - height: ($baseline / 1.5); - width: ($baseline / 1.5); + height: calc((var(--baseline) / 1.5)); + width: calc((var(--baseline) / 1.5)); } } } @@ -887,7 +887,7 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark bottom: 0; top: 0; width: 275px; - padding: 0 $baseline; + padding: 0 var(--baseline); display: none; } } @@ -973,14 +973,14 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark box-sizing: border-box; @include transition(none); - background: $black; + background: var(--black); visibility: visible; li { color: #aaa; &.current { - color: $white; + color: var(--white); } } } @@ -1010,17 +1010,17 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark background-position: 50% 50%; background-repeat: no-repeat; background-size: 100%; - background-color: $black; + background-color: var(--black); &.is-html5 { background-size: 15%; } .btn-play.btn-pre-roll { - padding: $baseline; + padding: var(--baseline); border: none; - border-radius: $baseline; - background: $black-t2; + border-radius: var(--baseline); + background: var(--black-t2); box-shadow: none; &::after { @@ -1030,13 +1030,13 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark } img { - height: ($baseline * 4); - width: ($baseline * 4); + height: calc((var(--baseline) * 4)); + width: calc((var(--baseline) * 4)); } &:hover, &:focus { - background: $blue; + background: var(--blue); } } }