Merge branch 'master' into NiedielnitsevIvan/FC-0047/feature/implement-push-notifications-chanel

This commit is contained in:
Ivan Niedielnitsev
2024-09-12 12:26:50 +03:00
committed by GitHub
33 changed files with 1759 additions and 411 deletions

View File

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

View File

@@ -233,17 +233,29 @@ Content Authoring Events
- 2023-07-20
* - `LIBRARY_BLOCK_CREATED <https://github.com/openedx/openedx-events/blob/c0eb4ba1a3d7d066d58e5c87920b8ccb0645f769/openedx_events/content_authoring/signals.py#L167>`_
- org.openedx.content_authoring.content_library.created.v1
- org.openedx.content_authoring.library_block.created.v1
- 2023-07-20
* - `LIBRARY_BLOCK_UPDATED <https://github.com/openedx/openedx-events/blob/c0eb4ba1a3d7d066d58e5c87920b8ccb0645f769/openedx_events/content_authoring/signals.py#L178>`_
- org.openedx.content_authoring.content_library.updated.v1
- org.openedx.content_authoring.library_block.updated.v1
- 2023-07-20
* - `LIBRARY_BLOCK_DELETED <https://github.com/openedx/openedx-events/blob/c0eb4ba1a3d7d066d58e5c87920b8ccb0645f769/openedx_events/content_authoring/signals.py#L189>`_
- org.openedx.content_authoring.content_library.deleted.v1
- org.openedx.content_authoring.library_block.deleted.v1
- 2023-07-20
* - `CONTENT_OBJECT_TAGS_CHANGED <https://github.com/openedx/openedx-events/blob/c0eb4ba1a3d7d066d58e5c87920b8ccb0645f769/openedx_events/content_authoring/signals.py#L207>`_
- org.openedx.content_authoring.content.object.tags.changed.v1
- 2024-03-31
* - `LIBRARY_COLLECTION_CREATED <https://github.com/openedx/openedx-events/blob/main/openedx_events/content_authoring/signals.py#L219>`_
- org.openedx.content_authoring.content_library.collection.created.v1
- 2024-08-23
* - `LIBRARY_COLLECTION_UPDATED <https://github.com/openedx/openedx-events/blob/main/openedx_events/content_authoring/signals.py#L230>`_
- org.openedx.content_authoring.content_library.collection.updated.v1
- 2024-08-23
* - `LIBRARY_COLLECTION_DELETED <https://github.com/openedx/openedx-events/blob/main/openedx_events/content_authoring/signals.py#L241>`_
- org.openedx.content_authoring.content_library.collection.deleted.v1
- 2024-08-23

View File

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

View File

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

View File

@@ -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):
"""

View File

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

View File

@@ -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': ''},

View File

@@ -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.")

View File

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

View File

@@ -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(),
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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/<str:usage_key_str>/', include([
# Get metadata about a specific XBlock in this library, or delete the block:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}

View File

@@ -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);
}
}
}

View File

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

View File

@@ -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));
}
}

View File

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

View File

@@ -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);
}

View File

@@ -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)));
}
}

View File

@@ -9,7 +9,7 @@
height: 379px;
border: 1px solid #3c3c3c;
border-top: 1px solid #8891a1;
background: $white;
background: var(--white);
color: #3c3c3c;
}

View File

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

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}