Merge branch 'master' into NiedielnitsevIvan/FC-0047/feature/implement-push-notifications-chanel
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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': ''},
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
198
openedx/core/djangoapps/content_libraries/views_collections.py
Normal file
198
openedx/core/djangoapps/content_libraries/views_collections.py
Normal 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)})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
height: 379px;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-top: 1px solid #8891a1;
|
||||
background: $white;
|
||||
background: var(--white);
|
||||
color: #3c3c3c;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user