Merge branch 'master' into kireiev/AXM-549/feat/upstream_PR_active_inactive_courses_API

This commit is contained in:
Kyrylo Kireiev
2024-10-30 17:45:15 +02:00
committed by GitHub
59 changed files with 1219 additions and 977 deletions

View File

@@ -10,6 +10,8 @@ metadata:
- url: "https://docs.openedx.org"
title: "Documentation"
icon: "Web"
annotations:
openedx.org/release: "master"
spec:
owner: group:wg-maintenance-edx-platform
type: 'service'

View File

@@ -974,3 +974,18 @@ class CourseUpdateNotificationTests(ModuleStoreTestCase):
assert Notification.objects.all().count() == 1
notification = Notification.objects.first()
assert notification.content == "<p><strong>content Sub content heading</strong></p>"
def test_if_html_unescapes(self):
"""
Tests if html unescapes when creating content of course update notification
"""
user = UserFactory()
CourseEnrollment.enroll(user=user, course_key=self.course.id)
assert Notification.objects.all().count() == 0
content = "<p>&lt;p&gt; &amp;nbsp;&lt;/p&gt;<br />"\
"&lt;p&gt;abcd&lt;/p&gt;<br />"\
"&lt;p&gt;&amp;nbsp;&lt;/p&gt;<br /></p>"
send_course_update_notification(self.course.id, content, self.user)
assert Notification.objects.all().count() == 1
notification = Notification.objects.first()
assert notification.content == "<p><strong>abcd</strong></p>"

View File

@@ -3,6 +3,7 @@ Common utility functions useful throughout the contentstore
"""
from __future__ import annotations
import configparser
import html
import logging
import re
from collections import defaultdict
@@ -2258,6 +2259,7 @@ def clean_html_body(html_body):
"""
Get html body, remove tags and limit to 500 characters
"""
html_body = html.unescape(html_body).strip()
html_body = BeautifulSoup(Truncator(html_body).chars(500, html=True), 'html.parser')
text_content = html_body.get_text(separator=" ").strip()
text_content = text_content.replace('\n', '').replace('\r', '')

View File

@@ -408,7 +408,6 @@ class ClipboardPasteFromV2LibraryTestCase(ModuleStoreTestCase):
self.store = modulestore()
self.library = library_api.create_library(
library_type=library_api.COMPLEX,
org=Organization.objects.create(name="Test Org", short_name="CL-TEST"),
slug="lib",
title="Library",

View File

@@ -558,6 +558,7 @@ def _create_block(request):
"locator": str(created_xblock.location),
"courseKey": str(created_xblock.location.course_key),
"static_file_notices": asdict(notices),
"upstreamRef": str(created_xblock.upstream),
})
category = request.json["category"]

View File

@@ -3,7 +3,7 @@ define([
], function($, CourseDetailsModel, MainView) {
'use strict';
return function(detailsUrl, showMinGradeWarning, showCertificateAvailableDate, upgradeDeadline, useV2CertDisplaySettings) {
return function(detailsUrl, showMinGradeWarning, showCertificateAvailableDate, upgradeDeadline) {
var model;
// highlighting labels when fields are focused in
$('form :input')
@@ -23,7 +23,6 @@ define([
model = new CourseDetailsModel();
model.urlRoot = detailsUrl;
model.showCertificateAvailableDate = showCertificateAvailableDate;
model.useV2CertDisplaySettings = useV2CertDisplaySettings;
model.set('upgrade_deadline', upgradeDeadline);
model.fetch({
// eslint-disable-next-line no-shadow
@@ -33,7 +32,6 @@ define([
model: model,
showMinGradeWarning: showMinGradeWarning
});
editor.useV2CertDisplaySettings = useV2CertDisplaySettings;
editor.render();
},
reset: true,

View File

@@ -84,35 +84,33 @@ function(Backbone, _, gettext, ValidationHelpers, DateUtils, StringUtils) {
);
}
if (this.useV2CertDisplaySettings) {
if (
newattrs.certificates_display_behavior
&& !(Object.values(CERTIFICATES_DISPLAY_BEHAVIOR_OPTIONS).includes(newattrs.certificates_display_behavior))
) {
errors.certificates_display_behavior = StringUtils.interpolate(
gettext(
'The certificate display behavior must be one of: {behavior_options}'
),
{
behavior_options: Object.values(CERTIFICATES_DISPLAY_BEHAVIOR_OPTIONS).join(', ')
}
);
}
if (
newattrs.certificates_display_behavior
&& !(Object.values(CERTIFICATES_DISPLAY_BEHAVIOR_OPTIONS).includes(newattrs.certificates_display_behavior))
) {
errors.certificates_display_behavior = StringUtils.interpolate(
gettext(
'The certificate display behavior must be one of: {behavior_options}'
),
{
behavior_options: Object.values(CERTIFICATES_DISPLAY_BEHAVIOR_OPTIONS).join(', ')
}
);
}
// Throw error if there's a value for certificate_available_date
if (
(newattrs.certificate_available_date && newattrs.certificates_display_behavior != CERTIFICATES_DISPLAY_BEHAVIOR_OPTIONS.END_WITH_DATE)
|| (!newattrs.certificate_available_date && newattrs.certificates_display_behavior == CERTIFICATES_DISPLAY_BEHAVIOR_OPTIONS.END_WITH_DATE)
) {
errors.certificates_display_behavior = StringUtils.interpolate(
gettext(
'The certificates display behavior must be {valid_option} if certificate available date is set.'
),
{
valid_option: CERTIFICATES_DISPLAY_BEHAVIOR_OPTIONS.END_WITH_DATE
}
);
}
// Throw error if there's a value for certificate_available_date
if (
(newattrs.certificate_available_date && newattrs.certificates_display_behavior != CERTIFICATES_DISPLAY_BEHAVIOR_OPTIONS.END_WITH_DATE)
|| (!newattrs.certificate_available_date && newattrs.certificates_display_behavior == CERTIFICATES_DISPLAY_BEHAVIOR_OPTIONS.END_WITH_DATE)
) {
errors.certificates_display_behavior = StringUtils.interpolate(
gettext(
'The certificates display behavior must be {valid_option} if certificate available date is set.'
),
{
valid_option: CERTIFICATES_DISPLAY_BEHAVIOR_OPTIONS.END_WITH_DATE
}
);
}
if (newattrs.intro_video && newattrs.intro_video !== this.get('intro_video')) {

View File

@@ -388,9 +388,6 @@ function(ValidatingView, CodeMirror, _, $, ui, DateUtils, FileUploadModel,
Hides and clears the certificate available date field if a display behavior that doesn't use it is
chosen. Because we are clearing it, toggling back to "end_with_date" will require re-entering the date
*/
if (!this.useV2CertDisplaySettings) {
return;
}
// eslint-disable-next-line prefer-const
let showDatepicker = this.model.get('certificates_display_behavior') == 'end_with_date';
// eslint-disable-next-line prefer-const

View File

@@ -1,7 +1,6 @@
"""Tests for util.db module."""
from io import StringIO
import unittest
import ddt
from django.core.management import call_command
@@ -121,7 +120,6 @@ class MigrationTests(TestCase):
Tests for migrations.
"""
@unittest.skip('Skipping temporarily to drop column in table')
@override_settings(MIGRATION_MODULES={})
def test_migrations_are_in_sync(self):
"""

View File

@@ -4,7 +4,7 @@ Basically the LMS devstack settings plus a few items needed to successfully
import all the Studio code.
"""
from textwrap import dedent
import os
from openedx.core.lib.derived import derive_settings
@@ -27,18 +27,71 @@ for key, value in FEATURES.items():
FEATURES[key] = True
# Settings that will fail if we enable them, and we don't need them for docs anyway.
FEATURES['RUN_AS_ANALYTICS_SERVER_ENABLED'] = False
FEATURES['ENABLE_SOFTWARE_SECURE_FAKE'] = False
FEATURES['ENABLE_MKTG_SITE'] = False
FEATURES["RUN_AS_ANALYTICS_SERVER_ENABLED"] = False
FEATURES["ENABLE_SOFTWARE_SECURE_FAKE"] = False
FEATURES["ENABLE_MKTG_SITE"] = False
INSTALLED_APPS.extend([
'cms.djangoapps.contentstore.apps.ContentstoreConfig',
'cms.djangoapps.course_creators',
'cms.djangoapps.xblock_config.apps.XBlockConfig',
'lms.djangoapps.lti_provider',
])
INSTALLED_APPS.extend(
[
"cms.djangoapps.contentstore.apps.ContentstoreConfig",
"cms.djangoapps.course_creators",
"cms.djangoapps.xblock_config.apps.XBlockConfig",
"lms.djangoapps.lti_provider",
]
)
# Swagger generation details
openapi_security_info_basic = (
"Obtain with a `POST` request to `/user/v1/account/login_session/`. "
"If needed, copy the cookies from the response to your new call."
)
openapi_security_info_jwt = dedent(
"""
Obtain by making a `POST` request to `/oauth2/v1/access_token`.
You will need to be logged in and have a client ID and secret already created.
Your request should have the headers
```
'Content-Type': 'application/x-www-form-urlencoded'
```
Your request should have the data payload
```
'grant_type': 'client_credentials'
'client_id': [your client ID]
'client_secret': [your client secret]
'token_type': 'jwt'
```
Your JWT will be returned in the response as `access_token`. Prefix with `JWT ` in your header.
"""
)
openapi_security_info_csrf = (
"Obtain by making a `GET` request to `/csrf/api/v1/token`. The token will be in the response cookie `csrftoken`."
)
SWAGGER_SETTINGS["SECURITY_DEFINITIONS"] = {
"Basic": {
"type": "basic",
"description": openapi_security_info_basic,
},
"jwt": {
"type": "apiKey",
"name": "Authorization",
"in": "header",
"description": openapi_security_info_jwt,
},
"csrf": {
"type": "apiKey",
"name": "X-CSRFToken",
"in": "header",
"description": openapi_security_info_csrf,
},
}
COMMON_TEST_DATA_ROOT = ''
COMMON_TEST_DATA_ROOT = ""
derive_settings(__name__)

View File

@@ -13,8 +13,44 @@ produces:
securityDefinitions:
Basic:
type: basic
description: Obtain with a `POST` request to `/user/v1/account/login_session/`. If
needed, copy the cookies from the response to your new call.
jwt:
type: apiKey
name: Authorization
in: header
description: |2
Obtain by making a `POST` request to `/oauth2/v1/access_token`.
You will need to be logged in and have a client ID and secret already created.
Your request should have the headers
```
'Content-Type': 'application/x-www-form-urlencoded'
```
Your request should have the data payload
```
'grant_type': 'client_credentials'
'client_id': [your client ID]
'client_secret': [your client secret]
'token_type': 'jwt'
```
Your JWT will be returned in the response as `access_token`. Prefix with `JWT ` in your header.
csrf:
type: apiKey
name: X-CSRFToken
in: header
description: Obtain by making a `GET` request to `/csrf/api/v1/token`. The token
will be in the response cookie `csrftoken`.
security:
- Basic: []
- csrf: []
- jwt: []
paths:
/agreements/v1/integrity_signature/{course_id}:
get:
@@ -3975,6 +4011,7 @@ paths:
"profile_name": "Jon Doe"
"verification_attempt_id": (Optional)
"proctored_exam_attempt_id": (Optional)
"platform_verification_attempt_id": (Optional)
"status": (Optional)
}
parameters:
@@ -4130,6 +4167,7 @@ paths:
"profile_name": "Jon Doe"
"verification_attempt_id": (Optional)
"proctored_exam_attempt_id": (Optional)
"platform_verification_attempt_id": (Optional)
"status": (Optional)
}
parameters:
@@ -6788,6 +6826,59 @@ paths:
in: path
required: true
type: string
/mobile/{api_version}/notifications/create-token/:
post:
operationId: mobile_notifications_create-token_create
summary: |-
**Use Case**
This endpoint allows clients to register a device for push notifications.
description: |-
If the device is already registered, the existing registration will be updated.
If setting PUSH_NOTIFICATIONS_SETTINGS is not configured, the endpoint will return a 501 error.
**Example Request**
POST /api/mobile/{version}/notifications/create-token/
**POST Parameters**
The body of the POST request can include the following parameters.
* name (optional) - A name of the device.
* registration_id (required) - The device token of the device.
* device_id (optional) - ANDROID_ID / TelephonyManager.getDeviceId() (always as hex)
* active (optional) - Whether the device is active, default is True.
If False, the device will not receive notifications.
* cloud_message_type (required) - You should choose FCM or GCM. Currently, only FCM is supported.
* application_id (optional) - Opaque application identity, should be filled in for multiple
key/certificate access. Should be equal settings.FCM_APP_NAME.
**Example Response**
```json
{
"id": 1,
"name": "My Device",
"registration_id": "fj3j4",
"device_id": 1234,
"active": true,
"date_created": "2024-04-18T07:39:37.132787Z",
"cloud_message_type": "FCM",
"application_id": "my_app_id"
}
```
parameters:
- name: data
in: body
required: true
schema:
$ref: '#/definitions/GCMDevice'
responses:
'201':
description: ''
schema:
$ref: '#/definitions/GCMDevice'
tags:
- mobile
parameters:
- name: api_version
in: path
required: true
type: string
/mobile/{api_version}/users/{username}:
get:
operationId: mobile_users_read
@@ -8849,22 +8940,6 @@ paths:
tags:
- user
parameters: []
/user/v1/accounts/verifications/{attempt_id}/:
get:
operationId: user_v1_accounts_verifications_read
description: Get IDV attempt details by attempt_id. Only accessible by global
staff.
parameters: []
responses:
'200':
description: ''
tags:
- user
parameters:
- name: attempt_id
in: path
required: true
type: string
/user/v1/accounts/{username}:
get:
operationId: user_v1_accounts_read
@@ -9423,22 +9498,57 @@ paths:
- user
post:
operationId: user_account_login_session_create
summary: Log in a user.
description: |-
See `login_user` for details.
Example Usage:
POST /api/user/v1/login_session
with POST params `email`, `password`.
200 {'success': true}
parameters: []
summary: POST /user/{api_version}/account/login_session/
description: Returns 200 on success, and a detailed error message otherwise.
parameters:
- name: data
in: body
required: true
schema:
type: object
properties:
email:
type: string
password:
type: string
responses:
'201':
'200':
description: ''
schema:
type: object
properties:
success:
type: boolean
value:
type: string
error_code:
type: string
'400':
description: ''
schema:
type: object
properties:
success:
type: boolean
value:
type: string
error_code:
type: string
'403':
description: ''
schema:
type: object
properties:
success:
type: boolean
value:
type: string
error_code:
type: string
tags:
- user
security:
- csrf: []
parameters:
- name: api_version
in: path
@@ -10047,6 +10157,7 @@ definitions:
required:
- celebrations
- course_access
- studio_access
- course_id
- is_enrolled
- is_self_paced
@@ -10084,6 +10195,9 @@ definitions:
additionalProperties:
type: string
x-nullable: true
studio_access:
title: Studio access
type: boolean
course_id:
title: Course id
type: string
@@ -11237,10 +11351,24 @@ definitions:
title: Verification attempt id
type: integer
x-nullable: true
verification_attempt_status:
title: Verification attempt status
type: string
minLength: 1
x-nullable: true
proctored_exam_attempt_id:
title: Proctored exam attempt id
type: integer
x-nullable: true
platform_verification_attempt_id:
title: Platform verification attempt id
type: integer
x-nullable: true
platform_verification_attempt_status:
title: Platform verification attempt status
type: string
minLength: 1
x-nullable: true
status:
title: Status
type: string
@@ -11277,10 +11405,24 @@ definitions:
title: Verification attempt id
type: integer
x-nullable: true
verification_attempt_status:
title: Verification attempt status
type: string
minLength: 1
x-nullable: true
proctored_exam_attempt_id:
title: Proctored exam attempt id
type: integer
x-nullable: true
platform_verification_attempt_id:
title: Platform verification attempt id
type: integer
x-nullable: true
platform_verification_attempt_status:
title: Platform verification attempt status
type: string
minLength: 1
x-nullable: true
status:
title: Status
type: string
@@ -11710,6 +11852,52 @@ definitions:
title: Enddatetime
type: string
format: date-time
GCMDevice:
required:
- registration_id
type: object
properties:
id:
title: ID
type: integer
name:
title: Name
type: string
maxLength: 255
x-nullable: true
registration_id:
title: Registration ID
type: string
minLength: 1
device_id:
title: Device id
description: 'ANDROID_ID / TelephonyManager.getDeviceId() (e.g: 0x01)'
type: integer
x-nullable: true
active:
title: Is active
description: Inactive devices will not be sent notifications
type: boolean
date_created:
title: Creation date
type: string
format: date-time
readOnly: true
x-nullable: true
cloud_message_type:
title: Cloud Message Type
description: You should choose FCM, GCM is deprecated
type: string
enum:
- FCM
- GCM
application_id:
title: Application ID
description: Opaque application identity, should be filled in for multiple
key/certificate access
type: string
maxLength: 64
x-nullable: true
mobile_api.User:
required:
- username

View File

@@ -537,6 +537,11 @@ def _send_course_email(entry_id, email_id, to_list, global_email_context, subtas
email_context['course_id'] = str(course_email.course_id)
email_context['unsubscribe_link'] = get_unsubscribed_link(current_recipient['username'],
str(course_email.course_id))
email_context['unsubscribe_text'] = 'Unsubscribe from course updates for this course'
email_context['disclaimer'] = (
"You are receiving this email because you are enrolled in the "
f"{email_context['platform_name']} course {email_context['course_title']}"
)
if is_bulk_email_edx_ace_enabled():
message = ACEEmail(site, email_context)

View File

@@ -3814,6 +3814,26 @@ class TestCoursewareMFESearchAPI(SharedModuleStoreTestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(body, {'enabled': False})
@patch.dict('django.conf.settings.FEATURES', {'COURSEWARE_SEARCH_INCLUSION_DATE': '2020'})
@ddt.data(
(datetime(2013, 9, 18, 11, 30, 00), False),
(None, False),
(datetime(2024, 9, 18, 11, 30, 00), True),
)
@ddt.unpack
def test_inclusion_date_greater_than_course_start(self, start_date, expected_enabled):
course_with_start = CourseFactory.create(start=start_date)
api_url = reverse('courseware_search_enabled_view', kwargs={'course_id': str(course_with_start.id)})
user_staff = UserFactory(is_staff=True)
self.client.login(username=user_staff.username, password=TEST_PASSWORD)
response = self.client.get(api_url, content_type='application/json')
body = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, 200)
self.assertEqual(body, {'enabled': expected_enabled})
class TestCoursewareMFENavigationSidebarTogglesAPI(SharedModuleStoreTestCase):
"""

View File

@@ -2317,6 +2317,13 @@ def courseware_mfe_search_enabled(request, course_id=None):
else:
enabled = True
inclusion_date = settings.FEATURES.get('COURSEWARE_SEARCH_INCLUSION_DATE')
start_date = CourseOverview.get_from_id(course_key).start
# only include courses that have a start date later than the setting-defined inclusion date
if inclusion_date:
enabled = enabled and (start_date and start_date.strftime('%Y-%m-%d') > inclusion_date)
payload = {"enabled": courseware_mfe_search_is_enabled(course_key) if enabled else False}
return JsonResponse(payload)

View File

@@ -50,6 +50,7 @@ from path import Path as path
from django.utils.translation import gettext_lazy as _
from enterprise.constants import (
ENTERPRISE_ADMIN_ROLE,
ENTERPRISE_LEARNER_ROLE,
ENTERPRISE_CATALOG_ADMIN_ROLE,
ENTERPRISE_DASHBOARD_ADMIN_ROLE,
ENTERPRISE_ENROLLMENT_API_ADMIN_ROLE,
@@ -60,6 +61,7 @@ from enterprise.constants import (
SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE,
PROVISIONING_ENTERPRISE_CUSTOMER_ADMIN_ROLE,
PROVISIONING_PENDING_ENTERPRISE_CUSTOMER_ADMIN_ROLE,
DEFAULT_ENTERPRISE_ENROLLMENT_INTENTIONS_ROLE,
)
from openedx.core.constants import COURSE_KEY_REGEX, COURSE_KEY_PATTERN, COURSE_ID_PATTERN
@@ -4722,11 +4724,15 @@ ENTERPRISE_READONLY_ACCOUNT_FIELDS = [
ENTERPRISE_CUSTOMER_COOKIE_NAME = 'enterprise_customer_uuid'
BASE_COOKIE_DOMAIN = 'localhost'
SYSTEM_TO_FEATURE_ROLE_MAPPING = {
ENTERPRISE_LEARNER_ROLE: [
DEFAULT_ENTERPRISE_ENROLLMENT_INTENTIONS_ROLE,
],
ENTERPRISE_ADMIN_ROLE: [
ENTERPRISE_DASHBOARD_ADMIN_ROLE,
ENTERPRISE_CATALOG_ADMIN_ROLE,
ENTERPRISE_ENROLLMENT_API_ADMIN_ROLE,
ENTERPRISE_REPORTING_CONFIG_ADMIN_ROLE,
DEFAULT_ENTERPRISE_ENROLLMENT_INTENTIONS_ROLE,
],
ENTERPRISE_OPERATOR_ROLE: [
ENTERPRISE_DASHBOARD_ADMIN_ROLE,
@@ -4735,6 +4741,7 @@ SYSTEM_TO_FEATURE_ROLE_MAPPING = {
ENTERPRISE_REPORTING_CONFIG_ADMIN_ROLE,
ENTERPRISE_FULFILLMENT_OPERATOR_ROLE,
ENTERPRISE_SSO_ORCHESTRATOR_OPERATOR_ROLE,
DEFAULT_ENTERPRISE_ENROLLMENT_INTENTIONS_ROLE,
],
SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE: [
PROVISIONING_ENTERPRISE_CUSTOMER_ADMIN_ROLE,

View File

@@ -1,14 +0,0 @@
# This file describes this Open edX repo, as described in OEP-2:
# https://open-edx-proposals.readthedocs.io/en/latest/oep-0002-bp-repo-metadata.html#specification
nick: edx
openedx-release: {ref: "2u/release"}
oeps:
oep-2: true
oep-18: true
oep-7: true
tags:
- core
- webservice

View File

@@ -115,7 +115,6 @@ class TestSearchApi(ModuleStoreTestCase):
# Create a content library:
self.library = library_api.create_library(
library_type=library_api.COMPLEX,
org=OrganizationFactory.create(short_name="org1"),
slug="lib",
title="Library",

View File

@@ -114,7 +114,7 @@ from openedx.core.lib.xblock_serializer.api import serialize_modulestore_block_f
from xmodule.modulestore.django import modulestore
from . import permissions, tasks
from .constants import ALL_RIGHTS_RESERVED, COMPLEX
from .constants import ALL_RIGHTS_RESERVED
from .models import ContentLibrary, ContentLibraryPermission, ContentLibraryBlockImportTask
log = logging.getLogger(__name__)
@@ -176,7 +176,6 @@ class ContentLibraryMetadata:
description = attr.ib("")
num_blocks = attr.ib(0)
version = attr.ib(0)
type = attr.ib(default=COMPLEX)
last_published = attr.ib(default=None, type=datetime)
last_draft_created = attr.ib(default=None, type=datetime)
last_draft_created_by = attr.ib(default=None, type=datetime)
@@ -306,15 +305,13 @@ class LibraryXBlockType:
# ============
def get_libraries_for_user(user, org=None, library_type=None, text_search=None, order=None):
def get_libraries_for_user(user, org=None, text_search=None, order=None):
"""
Return content libraries that the user has permission to view.
"""
filter_kwargs = {}
if org:
filter_kwargs['org__short_name'] = org
if library_type:
filter_kwargs['type'] = library_type
qs = ContentLibrary.objects.filter(**filter_kwargs) \
.select_related('learning_package', 'org') \
.order_by('org__short_name', 'slug')
@@ -361,7 +358,6 @@ def get_metadata(queryset, text_search=None):
ContentLibraryMetadata(
key=lib.library_key,
title=lib.learning_package.title if lib.learning_package else "",
type=lib.type,
description="",
version=0,
allow_public_learning=lib.allow_public_learning,
@@ -446,7 +442,6 @@ def get_library(library_key):
return ContentLibraryMetadata(
key=library_key,
title=learning_package.title,
type=ref.type,
description=ref.learning_package.description,
num_blocks=num_blocks,
version=version,
@@ -474,7 +469,6 @@ def create_library(
allow_public_learning=False,
allow_public_read=False,
library_license=ALL_RIGHTS_RESERVED,
library_type=COMPLEX,
):
"""
Create a new content library.
@@ -491,8 +485,6 @@ def create_library(
allow_public_read: Allow anyone to view blocks (including source) in Studio?
library_type: Deprecated parameter, not really used. Set to COMPLEX.
Returns a ContentLibraryMetadata instance.
"""
assert isinstance(org, Organization)
@@ -502,7 +494,6 @@ def create_library(
ref = ContentLibrary.objects.create(
org=org,
slug=slug,
type=library_type,
allow_public_learning=allow_public_learning,
allow_public_read=allow_public_read,
license=library_license,
@@ -526,7 +517,6 @@ def create_library(
return ContentLibraryMetadata(
key=ref.library_key,
title=title,
type=library_type,
description=description,
num_blocks=0,
version=0,
@@ -611,7 +601,6 @@ def update_library(
description=None,
allow_public_learning=None,
allow_public_read=None,
library_type=None,
library_license=None,
):
"""
@@ -621,7 +610,7 @@ def update_library(
A value of None means "don't change".
"""
lib_obj_fields = [
allow_public_learning, allow_public_read, library_type, library_license
allow_public_learning, allow_public_read, library_license
]
lib_obj_changed = any(field is not None for field in lib_obj_fields)
learning_pkg_changed = any(field is not None for field in [title, description])
@@ -640,10 +629,6 @@ def update_library(
content_lib.allow_public_learning = allow_public_learning
if allow_public_read is not None:
content_lib.allow_public_read = allow_public_read
if library_type is not None:
# TODO: Get rid of this field entirely, and remove library_type
# from any functions that take it as an argument.
content_lib.library_type = library_type
if library_license is not None:
content_lib.library_license = library_license
content_lib.save()
@@ -856,13 +841,6 @@ def validate_can_add_block_to_library(
"""
assert isinstance(library_key, LibraryLocatorV2)
content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined]
if content_library.type != COMPLEX:
if block_type != content_library.type:
raise IncompatibleTypesError(
_('Block type "{block_type}" is not compatible with library type "{library_type}".').format(
block_type=block_type, library_type=content_library.type,
)
)
# If adding a component would take us over our max, return an error.
component_count = authoring_api.get_all_drafts(content_library.learning_package.id).count()
@@ -1288,10 +1266,7 @@ def get_allowed_block_types(library_key): # pylint: disable=unused-argument
# TODO: return support status and template options
# See cms/djangoapps/contentstore/views/component.py
block_types = sorted(name for name, class_ in XBlock.load_classes())
lib = get_library(library_key)
if lib.type != COMPLEX:
# Problem and Video libraries only permit XBlocks of the same name.
block_types = (name for name in block_types if name == lib.type)
info = []
for block_type in block_types:
# TODO: unify the contentstore helper with the xblock.api version of

View File

@@ -2,16 +2,6 @@
from django.utils.translation import gettext_lazy as _
VIDEO = 'video'
COMPLEX = 'complex'
PROBLEM = 'problem'
LIBRARY_TYPES = (
(VIDEO, _('Video')),
(COMPLEX, _('Complex')),
(PROBLEM, _('Problem')),
)
# These are all the licenses we support so far.
ALL_RIGHTS_RESERVED = ''
CC_4_BY = 'CC:4.0:BY'

View File

@@ -0,0 +1,21 @@
# Generated by Django 4.2.16 on 2024-10-24 20:21
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('content_libraries', '0010_contentlibrary_learning_package_and_more'),
]
operations = [
migrations.RemoveField(
model_name='contentlibrary',
name='bundle_uuid',
),
migrations.RemoveField(
model_name='contentlibrary',
name='type',
),
]

View File

@@ -54,8 +54,7 @@ from pylti1p3.grade import Grade
from opaque_keys.edx.django.models import UsageKeyField
from openedx.core.djangoapps.content_libraries.constants import (
LIBRARY_TYPES, COMPLEX, LICENSE_OPTIONS,
ALL_RIGHTS_RESERVED,
LICENSE_OPTIONS, ALL_RIGHTS_RESERVED,
)
from openedx_learning.api.authoring_models import LearningPackage
from organizations.models import Organization # lint-amnesty, pylint: disable=wrong-import-order
@@ -101,18 +100,6 @@ class ContentLibrary(models.Model):
org = models.ForeignKey(Organization, on_delete=models.PROTECT, null=False)
slug = models.SlugField(allow_unicode=True)
# We no longer use the ``bundle_uuid`` and ``type`` fields, but we'll leave
# them in the model until after the Redwood release, just in case someone
# out there was using v2 libraries. We don't expect this, since it wasn't in
# a usable state, but there's always a chance someone managed to do it and
# is still using it. By keeping the schema backwards compatible, the thought
# is that they would update to the latest version, notice their libraries
# aren't working correctly, and still have the ability to recover their data
# if the code was rolled back.
# TODO: Remove these fields after the Redwood release is cut.
bundle_uuid = models.UUIDField(unique=True, null=True, default=None)
type = models.CharField(max_length=25, default=COMPLEX, choices=LIBRARY_TYPES)
license = models.CharField(max_length=25, default=ALL_RIGHTS_RESERVED, choices=LICENSE_OPTIONS)
learning_package = models.OneToOneField(
LearningPackage,

View File

@@ -11,8 +11,6 @@ from opaque_keys import InvalidKeyError
from openedx_learning.api.authoring_models import Collection
from openedx.core.djangoapps.content_libraries.constants import (
LIBRARY_TYPES,
COMPLEX,
ALL_RIGHTS_RESERVED,
LICENSE_OPTIONS,
)
@@ -37,10 +35,8 @@ class ContentLibraryMetadataSerializer(serializers.Serializer):
# begins with 'lib:'. (The numeric ID of the ContentLibrary object in MySQL
# is not exposed via this API.)
id = serializers.CharField(source="key", read_only=True)
type = serializers.ChoiceField(choices=LIBRARY_TYPES, default=COMPLEX)
org = serializers.SlugField(source="key.org")
slug = serializers.CharField(source="key.slug", validators=(validate_unicode_slug, ))
bundle_uuid = serializers.UUIDField(format='hex_verbose', read_only=True)
title = serializers.CharField()
description = serializers.CharField(allow_blank=True)
num_blocks = serializers.IntegerField(read_only=True)
@@ -86,7 +82,6 @@ class ContentLibraryUpdateSerializer(serializers.Serializer):
description = serializers.CharField()
allow_public_learning = serializers.BooleanField()
allow_public_read = serializers.BooleanField()
type = serializers.ChoiceField(choices=LIBRARY_TYPES)
license = serializers.ChoiceField(choices=LICENSE_OPTIONS)
@@ -118,7 +113,7 @@ class ContentLibraryPermissionSerializer(ContentLibraryPermissionLevelSerializer
group_name = serializers.CharField(source="group.name", allow_null=True, allow_blank=False, default=None)
class BaseFilterSerializer(serializers.Serializer):
class ContentLibraryFilterSerializer(serializers.Serializer):
"""
Base serializer for filtering listings on the content library APIs.
"""
@@ -127,13 +122,6 @@ class BaseFilterSerializer(serializers.Serializer):
order = serializers.CharField(default=None, required=False)
class ContentLibraryFilterSerializer(BaseFilterSerializer):
"""
Serializer for filtering library listings.
"""
type = serializers.ChoiceField(choices=LIBRARY_TYPES, default=None, required=False)
class CollectionMetadataSerializer(serializers.Serializer):
"""
Serializer for CollectionMetadata

View File

@@ -9,7 +9,7 @@ from organizations.models import Organization
from rest_framework.test import APITransactionTestCase, APIClient
from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangoapps.content_libraries.constants import COMPLEX, ALL_RIGHTS_RESERVED
from openedx.core.djangoapps.content_libraries.constants import ALL_RIGHTS_RESERVED
from openedx.core.djangolib.testing.utils import skip_unless_cms
# Define the URLs here - don't use reverse() because we want to detect
@@ -124,7 +124,7 @@ class ContentLibrariesRestApiTest(APITransactionTestCase):
self.client = old_client # pylint: disable=attribute-defined-outside-init
def _create_library(
self, slug, title, description="", org=None, library_type=COMPLEX,
self, slug, title, description="", org=None,
license_type=ALL_RIGHTS_RESERVED, expect_response=200,
):
""" Create a library """
@@ -135,7 +135,6 @@ class ContentLibrariesRestApiTest(APITransactionTestCase):
"slug": slug,
"title": title,
"description": description,
"type": library_type,
"license": license_type,
}, expect_response)

View File

@@ -25,7 +25,7 @@ from organizations.models import Organization
from rest_framework.test import APITestCase
from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangoapps.content_libraries.constants import CC_4_BY, COMPLEX, PROBLEM, VIDEO
from openedx.core.djangoapps.content_libraries.constants import CC_4_BY
from openedx.core.djangoapps.content_libraries.tests.base import (
URL_BLOCK_GET_HANDLER_URL,
URL_BLOCK_METADATA_URL,
@@ -100,7 +100,6 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix
"title": "A Tést Lꜟطrary",
"description": "Just Téstꜟng",
"version": 0,
"type": COMPLEX,
"license": CC_4_BY,
"has_unpublished_changes": False,
"has_unpublished_deletes": False,
@@ -199,13 +198,13 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix
Test the filters in the list libraries API
"""
self._create_library(
slug="test-lib-filter-1", title="Fob", description="Bar", library_type=VIDEO,
slug="test-lib-filter-1", title="Fob", description="Bar",
)
self._create_library(
slug="test-lib-filter-2", title="Library-Title-2", description="Bar-2",
)
self._create_library(
slug="l3", title="Library-Title-3", description="Description", library_type=VIDEO,
slug="l3", title="Library-Title-3", description="Description",
)
Organization.objects.get_or_create(
@@ -215,7 +214,6 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix
self._create_library(
slug="l4", title="Library-Title-4",
description="Library-Description", org='org-test',
library_type=VIDEO,
)
self._create_library(
slug="l5", title="Library-Title-5", description="Library-Description",
@@ -225,14 +223,11 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix
assert len(self._list_libraries()) == 5
assert len(self._list_libraries({'org': 'org-test'})) == 2
assert len(self._list_libraries({'text_search': 'test-lib-filter'})) == 2
assert len(self._list_libraries({'text_search': 'test-lib-filter', 'type': VIDEO})) == 1
assert len(self._list_libraries({'text_search': 'library-title'})) == 4
assert len(self._list_libraries({'text_search': 'library-title', 'type': VIDEO})) == 2
assert len(self._list_libraries({'text_search': 'bar'})) == 2
assert len(self._list_libraries({'text_search': 'org-test'})) == 2
assert len(self._list_libraries({'org': 'org-test',
'text_search': 'library-title-4'})) == 1
assert len(self._list_libraries({'type': VIDEO})) == 3
self.assertOrderEqual(
self._list_libraries({'order': 'title'}),
@@ -496,27 +491,6 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix
assert len(self._get_library_blocks(lib['id'], {'block_type': 'problem'})['results']) == 3
assert len(self._get_library_blocks(lib['id'], {'block_type': 'squirrel'})['results']) == 0
@ddt.data(
('video-problem', VIDEO, 'problem', 400),
('video-video', VIDEO, 'video', 200),
('problem-problem', PROBLEM, 'problem', 200),
('problem-video', PROBLEM, 'video', 400),
('complex-video', COMPLEX, 'video', 200),
('complex-problem', COMPLEX, 'problem', 200),
)
@ddt.unpack
def test_library_blocks_type_constrained(self, slug, library_type, block_type, expect_response):
"""
Test that type-constrained libraries enforce their constraint when adding an XBlock.
"""
lib = self._create_library(
slug=slug, title="A Test Library", description="Testing XBlocks", library_type=library_type,
)
lib_id = lib["id"]
# Add a 'problem' XBlock to the library:
self._add_block_to_library(lib_id, block_type, 'test-block', expect_response=expect_response)
def test_library_not_found(self):
"""Test that requests fail with 404 when the library does not exist"""
valid_not_found_key = 'lb:valid:key:video:1'
@@ -755,24 +729,6 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix
# Second block should throw error
self._add_block_to_library(lib_id, "problem", "problem1", expect_response=400)
@ddt.data(
('complex-types', COMPLEX, False),
('video-types', VIDEO, True),
('problem-types', PROBLEM, True),
)
@ddt.unpack
def test_block_types(self, slug, library_type, constrained):
"""
Test that the permitted block types listing for a library change based on type.
"""
lib = self._create_library(slug=slug, title='Test Block Types', library_type=library_type)
types = self._get_library_block_types(lib['id'])
if constrained:
assert len(types) == 1
assert types[0]['block_type'] == library_type
else:
assert len(types) > 1
def test_content_library_create_event(self):
"""
Check that CONTENT_LIBRARY_CREATED event is sent when a content library is created.

View File

@@ -15,7 +15,6 @@ from organizations.models import Organization
from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2
from ..models import ALL_RIGHTS_RESERVED
from ..models import COMPLEX
from ..models import ContentLibrary
from ..models import LtiGradedResource
from ..models import LtiProfile
@@ -35,7 +34,6 @@ class ContentLibraryTest(TestCase):
return ContentLibrary.objects.create(
org=org,
slug='foobar',
type=COMPLEX,
allow_public_learning=False,
allow_public_read=False,
license=ALL_RIGHTS_RESERVED,

View File

@@ -21,7 +21,7 @@ from openedx.core.djangoapps.content_libraries.tests.base import (
URL_BLOCK_FIELDS_URL,
)
from openedx.core.djangoapps.content_libraries.tests.user_state_block import UserStateTestBlock
from openedx.core.djangoapps.content_libraries.constants import COMPLEX, ALL_RIGHTS_RESERVED
from openedx.core.djangoapps.content_libraries.constants import ALL_RIGHTS_RESERVED
from openedx.core.djangoapps.dark_lang.models import DarkLangConfig
from openedx.core.djangoapps.xblock import api as xblock_api
from openedx.core.djangolib.testing.utils import skip_unless_lms, skip_unless_cms
@@ -50,7 +50,6 @@ class ContentLibraryContentTestMixin:
_, slug = self.id().rsplit('.', 1)
with transaction.atomic():
self.library = library_api.create_library(
library_type=COMPLEX,
org=self.organization,
slug=slugify(slug),
title=(f"{slug} Test Lib"),

View File

@@ -5,8 +5,6 @@ Tests for LTI views.
from django.conf import settings
from django.test import TestCase, override_settings
from openedx.core.djangoapps.content_libraries.constants import PROBLEM
from .base import (
ContentLibrariesRestApiTest,
URL_LIB_LTI_JWKS,
@@ -60,10 +58,10 @@ class LibraryBlockLtiUrlViewTestMixin:
"""
library = self._create_library(
slug="libgg", title="A Test Library", description="Testing library", library_type=PROBLEM,
slug="libgg", title="A Test Library", description="Testing library",
)
block = self._add_block_to_library(library['id'], PROBLEM, PROBLEM)
block = self._add_block_to_library(library['id'], 'problem', 'problem')
usage_key = str(block['id'])
url = f'/api/libraries/v2/blocks/{usage_key}/lti/'

View File

@@ -227,14 +227,12 @@ class LibraryRootView(GenericAPIView):
serializer = ContentLibraryFilterSerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
org = serializer.validated_data['org']
library_type = serializer.validated_data['type']
text_search = serializer.validated_data['text_search']
order = serializer.validated_data['order']
queryset = api.get_libraries_for_user(
request.user,
org=org,
library_type=library_type,
text_search=text_search,
order=order,
)
@@ -259,7 +257,6 @@ class LibraryRootView(GenericAPIView):
data = dict(serializer.validated_data)
# Converting this over because using the reserved names 'type' and 'license' would shadow the built-in
# definitions elsewhere.
data['library_type'] = data.pop('type')
data['library_license'] = data.pop('license')
key_data = data.pop("key")
# Move "slug" out of the "key.slug" pseudo-field that the serializer added:
@@ -313,8 +310,6 @@ class LibraryDetailsView(APIView):
serializer.is_valid(raise_exception=True)
data = dict(serializer.validated_data)
# Prevent ourselves from shadowing global names.
if 'type' in data:
data['library_type'] = data.pop('type')
if 'license' in data:
data['library_license'] = data.pop('license')
try:

View File

@@ -16,7 +16,6 @@ from openedx.core.djangoapps.notifications.base_notification import (
COURSE_NOTIFICATION_TYPES,
)
from openedx.core.djangoapps.notifications.config.waffle import ENABLE_EMAIL_NOTIFICATIONS
from openedx.core.djangoapps.notifications.email_notifications import EmailCadence
from openedx.core.djangoapps.notifications.models import CourseNotificationPreference, Notification
from openedx.core.djangoapps.notifications.email.utils import (
add_additional_attributes_to_notifications,
@@ -320,9 +319,7 @@ class TestUpdatePreferenceFromPatch(ModuleStoreTestCase):
if channel == param_channel:
assert type_prefs[channel] == new_value
if channel == 'email':
cadence_value = EmailCadence.NEVER
if new_value:
cadence_value = self.get_default_cadence_value(app_name, noti_type)
cadence_value = self.get_default_cadence_value(app_name, noti_type)
assert type_prefs['email_cadence'] == cadence_value
else:
default_app_json = self.default_json[app_name]
@@ -381,9 +378,7 @@ class TestUpdatePreferenceFromPatch(ModuleStoreTestCase):
if app_name == param_app_name:
assert type_prefs[channel] == new_value
if channel == 'email':
cadence_value = EmailCadence.NEVER
if new_value:
cadence_value = self.get_default_cadence_value(app_name, noti_type)
cadence_value = self.get_default_cadence_value(app_name, noti_type)
assert type_prefs['email_cadence'] == cadence_value
else:
default_app_json = self.default_json[app_name]
@@ -415,9 +410,7 @@ class TestUpdatePreferenceFromPatch(ModuleStoreTestCase):
if noti_type == param_notification_type:
assert type_prefs[channel] == new_value
if channel == 'email':
cadence_value = EmailCadence.NEVER
if new_value:
cadence_value = self.get_default_cadence_value(app_name, noti_type)
cadence_value = self.get_default_cadence_value(app_name, noti_type)
assert type_prefs['email_cadence'] == cadence_value
else:
default_app_json = self.default_json[app_name]

View File

@@ -406,9 +406,7 @@ def update_user_preferences_from_patch(encrypted_username, encrypted_patch):
continue
if is_editable(app_name, noti_type, channel):
type_prefs[channel] = pref_value
if channel == 'email':
cadence_value = get_default_cadence_value(app_name, noti_type)\
if pref_value else EmailCadence.NEVER
type_prefs['email_cadence'] = cadence_value
if channel == 'email' and pref_value and type_prefs.get('email_cadence') == EmailCadence.NEVER:
type_prefs['email_cadence'] = get_default_cadence_value(app_name, noti_type)
preference.save()
notification_preference_unsubscribe_event(user)

View File

@@ -28,8 +28,9 @@
style="max-height: 28px; max-width: 28px; margin: 0.75rem 1rem 0.75rem 0"
/>
</td>
<td class="notification-content" width="100%" align="left" valign="top" style=" padding: 1rem 1rem 1rem 0.5rem">
<td class="notification-content" width="100%" align="left" valign="top" style="padding: 1rem 1rem 1rem 0.5rem;">
<blockquote style="font-size: 0.875rem; font-weight:400; line-height:24px; color:#454545; margin: 0 0 0.5rem 0;">
<style> strong {color: #00262B; font-weight:500} </style>
{{ notification.email_content | truncatechars_html:600 | safe }}
</blockquote>
{% if notification.details %}
@@ -37,7 +38,7 @@
{{ notification.details | safe }}
</blockquote>
{% endif %}
<blockquote style="color:#707070; margin: 0">
<blockquote style="color:#707070; margin: 0; font-size: 14px; font-style: normal; font-weight: 400; line-height: 24px;">
<span style="float: left">
<span>{{ notification.course_name }}</span>
<span style="padding: 0 0.375rem">{{ "&middot;"|safe }}</span>

View File

@@ -1,4 +1,8 @@
<div style="margin:0; padding:0; min-width: 100%; background-color:#C9C9C9">
<head>
<link href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap'" rel="stylesheet" type="text/css">
</head>
<div style="margin:0; padding:0; min-width: 100%; background-color:#C9C9C9; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; font-smooth: never;">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%" style="line-height:1.5; max-width:600px; font-family:Inter">
<tbody style="background-color:#f5f5f5">
<tr>

View File

@@ -27,7 +27,6 @@ from openedx.core.djangoapps.django_comment_common.models import (
FORUM_ROLE_MODERATOR
)
from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS
from openedx.core.djangoapps.notifications.email_notifications import EmailCadence
from openedx.core.djangoapps.notifications.models import (
CourseNotificationPreference,
Notification,
@@ -936,7 +935,6 @@ class UpdatePreferenceFromEncryptedDataView(ModuleStoreTestCase):
for app_name, app_prefs in config.items():
for type_prefs in app_prefs['notification_types'].values():
assert type_prefs['email'] is False
assert type_prefs['email_cadence'] == EmailCadence.NEVER
def test_if_config_version_is_updated(self):
"""

View File

@@ -23,11 +23,14 @@ from django.views.decorators.csrf import csrf_exempt, csrf_protect, ensure_csrf_
from django.views.decorators.debug import sensitive_post_parameters
from django.views.decorators.http import require_http_methods
from django_ratelimit.decorators import ratelimit
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from edx_django_utils.monitoring import set_custom_attribute
from eventtracking import tracker
from openedx_events.learning.data import UserData, UserPersonalData
from openedx_events.learning.signals import SESSION_LOGIN_COMPLETED
from openedx_filters.learning.filters import StudentLoginRequested
from rest_framework import status
from rest_framework.views import APIView
from common.djangoapps import third_party_auth
@@ -49,7 +52,7 @@ from openedx.core.djangoapps.user_authn.exceptions import AuthFailedError, Vulne
from openedx.core.djangoapps.user_authn.tasks import check_pwned_password_and_send_track_event
from openedx.core.djangoapps.user_authn.toggles import (
is_require_third_party_auth_enabled,
should_redirect_to_authn_microfrontend
should_redirect_to_authn_microfrontend,
)
from openedx.core.djangoapps.user_authn.views.login_form import get_login_session_form
from openedx.core.djangoapps.user_authn.views.password_reset import send_password_reset_email_for_user
@@ -62,7 +65,7 @@ from openedx.features.enterprise_support.api import activate_learner_enterprise,
log = logging.getLogger("edx.student")
AUDIT_LOG = logging.getLogger("audit")
USER_MODEL = get_user_model()
PASSWORD_RESET_INITIATED = 'edx.user.passwordreset.initiated'
PASSWORD_RESET_INITIATED = "edx.user.passwordreset.initiated"
def _do_third_party_auth(request):
@@ -70,9 +73,9 @@ def _do_third_party_auth(request):
User is already authenticated via 3rd party, now try to find and return their associated Django user.
"""
running_pipeline = pipeline.get(request)
username = running_pipeline['kwargs'].get('username')
backend_name = running_pipeline['backend']
third_party_uid = running_pipeline['kwargs']['uid']
username = running_pipeline["kwargs"].get("username")
backend_name = running_pipeline["backend"]
third_party_uid = running_pipeline["kwargs"]["uid"]
requested_provider = provider.Registry.get_from_pipeline(running_pipeline)
platform_name = configuration_helpers.get_value("platform_name", settings.PLATFORM_NAME)
@@ -81,26 +84,25 @@ def _do_third_party_auth(request):
except USER_MODEL.DoesNotExist:
AUDIT_LOG.info(
"Login failed - user with username {username} has no social auth "
"with backend_name {backend_name}".format(
username=username, backend_name=backend_name)
"with backend_name {backend_name}".format(username=username, backend_name=backend_name)
)
message = Text(_(
"You've successfully signed in to your {provider_name} account, "
"but this account isn't linked with your {platform_name} account yet. {blank_lines}"
"Use your {platform_name} username and password to sign in to {platform_name} below, "
"and then link your {platform_name} account with {provider_name} from your dashboard. {blank_lines}"
"If you don't have an account on {platform_name} yet, "
"click {register_label_strong} at the top of the page."
)).format(
blank_lines=HTML('<br/><br/>'),
message = Text(
_(
"You've successfully signed in to your {provider_name} account, "
"but this account isn't linked with your {platform_name} account yet. {blank_lines}"
"Use your {platform_name} username and password to sign in to {platform_name} below, "
"and then link your {platform_name} account with {provider_name} from your dashboard. {blank_lines}"
"If you don't have an account on {platform_name} yet, "
"click {register_label_strong} at the top of the page."
)
).format(
blank_lines=HTML("<br/><br/>"),
platform_name=platform_name,
provider_name=requested_provider.name,
register_label_strong=HTML('<strong>{register_text}</strong>').format(
register_text=_('Register')
)
register_label_strong=HTML("<strong>{register_text}</strong>").format(register_text=_("Register")),
)
raise AuthFailedError(message, error_code='third-party-auth-with-no-linked-account') # lint-amnesty, pylint: disable=raise-missing-from
raise AuthFailedError(message, error_code="third-party-auth-with-no-linked-account") # lint-amnesty, pylint: disable=raise-missing-from
def _get_user_by_email(email):
@@ -128,14 +130,14 @@ def _get_user_by_email_or_username(request, api_version):
Finds a user object in the database based on the given request, ignores all fields except for email and username.
"""
is_api_v2 = api_version != API_V1
login_fields = ['email', 'password']
login_fields = ["email", "password"]
if is_api_v2:
login_fields = ['email_or_username', 'password']
login_fields = ["email_or_username", "password"]
if any(f not in request.POST.keys() for f in login_fields):
raise AuthFailedError(_('There was an error receiving your login information. Please email us.'))
raise AuthFailedError(_("There was an error receiving your login information. Please email us."))
email_or_username = request.POST.get('email', None) or request.POST.get('email_or_username', None)
email_or_username = request.POST.get("email", None) or request.POST.get("email_or_username", None)
user = _get_user_by_email(email_or_username)
if not user and is_api_v2:
@@ -143,7 +145,7 @@ def _get_user_by_email_or_username(request, api_version):
user = _get_user_by_username(email_or_username)
if not user:
digest = hashlib.shake_128(email_or_username.encode('utf-8')).hexdigest(16)
digest = hashlib.shake_128(email_or_username.encode("utf-8")).hexdigest(16)
AUDIT_LOG.warning(f"Login failed - Unknown user email or username {digest}")
return user
@@ -165,27 +167,30 @@ def _generate_locked_out_error_message():
"""
locked_out_period_in_sec = settings.MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS
error_message = Text(_('To protect your account, its been temporarily '
'locked. Try again in {locked_out_period} minutes.'
'{li_start}To be on the safe side, you can reset your '
'password {link_start}here{link_end} before you try again.')).format(
link_start=HTML('<a http="#login" class="form-toggle" data-type="password-reset">'),
link_end=HTML('</a>'),
li_start=HTML('<li>'),
li_end=HTML('</li>'),
locked_out_period=int(locked_out_period_in_sec / 60))
error_message = Text(
_(
"To protect your account, its been temporarily "
"locked. Try again in {locked_out_period} minutes."
"{li_start}To be on the safe side, you can reset your "
"password {link_start}here{link_end} before you try again."
)
).format(
link_start=HTML('<a http="#login" class="form-toggle" data-type="password-reset">'),
link_end=HTML("</a>"),
li_start=HTML("<li>"),
li_end=HTML("</li>"),
locked_out_period=int(locked_out_period_in_sec / 60),
)
raise AuthFailedError(
error_message,
error_code='account-locked-out',
context={
'locked_out_period': int(locked_out_period_in_sec / 60)
}
error_code="account-locked-out",
context={"locked_out_period": int(locked_out_period_in_sec / 60)},
)
def _enforce_password_policy_compliance(request, user): # lint-amnesty, pylint: disable=missing-function-docstring
try:
password_policy_compliance.enforce_compliance_on_login(user, request.POST.get('password'))
password_policy_compliance.enforce_compliance_on_login(user, request.POST.get("password"))
except password_policy_compliance.NonCompliantPasswordWarning as e:
# Allow login, but warn the user that they will be required to reset their password soon.
PageLevelMessages.register_warning_message(request, HTML(str(e)))
@@ -201,7 +206,7 @@ def _enforce_password_policy_compliance(request, user): # lint-amnesty, pylint:
{
"user_id": user.id,
"source": "Policy Compliance",
}
},
)
send_password_reset_email_for_user(user, request)
@@ -214,19 +219,17 @@ def _log_and_raise_inactive_user_auth_error(unauthenticated_user):
Depending on Django version we can get here a couple of ways, but this takes care of logging an auth attempt
by an inactive user, re-sending the activation email, and raising an error with the correct message.
"""
AUDIT_LOG.warning(
f"Login failed - Account not active for user.id: {unauthenticated_user.id}, resending activation"
)
AUDIT_LOG.warning(f"Login failed - Account not active for user.id: {unauthenticated_user.id}, resending activation")
profile = UserProfile.objects.get(user=unauthenticated_user)
compose_and_send_activation_email(unauthenticated_user, profile)
raise AuthFailedError(
error_code='inactive-user',
error_code="inactive-user",
context={
'platformName': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME),
'supportLink': configuration_helpers.get_value('SUPPORT_SITE_LINK', settings.SUPPORT_SITE_LINK)
}
"platformName": configuration_helpers.get_value("PLATFORM_NAME", settings.PLATFORM_NAME),
"supportLink": configuration_helpers.get_value("SUPPORT_SITE_LINK", settings.SUPPORT_SITE_LINK),
},
)
@@ -234,9 +237,11 @@ def _authenticate_first_party(request, unauthenticated_user, third_party_auth_re
"""
Use Django authentication on the given request, using rate limiting if configured
"""
should_be_rate_limited = getattr(request, 'limited', False)
should_be_rate_limited = getattr(request, "limited", False)
if should_be_rate_limited:
raise AuthFailedError(_('Too many failed login attempts. Try again later.')) # lint-amnesty, pylint: disable=raise-missing-from
raise AuthFailedError(
_("Too many failed login attempts. Try again later.")
) # lint-amnesty, pylint: disable=raise-missing-from
# If the user doesn't exist, we want to set the username to an invalid username so that authentication is guaranteed
# to fail and we can take advantage of the ratelimited backend
@@ -248,12 +253,8 @@ def _authenticate_first_party(request, unauthenticated_user, third_party_auth_re
if not third_party_auth_requested:
_check_user_auth_flow(request.site, unauthenticated_user)
password = normalize_password(request.POST['password'])
return authenticate(
username=username,
password=password,
request=request
)
password = normalize_password(request.POST["password"])
return authenticate(username=username, password=password, request=request)
def _handle_failed_authentication(user, authenticated_user):
@@ -279,34 +280,37 @@ def _handle_failed_authentication(user, authenticated_user):
if not LoginFailures.is_user_locked_out(user):
max_failures_allowed = settings.MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED
remaining_attempts = max_failures_allowed - failure_count
error_message = Text(_('Email or password is incorrect.'
'{li_start}You have {remaining_attempts} more sign-in '
'attempts before your account is temporarily locked.{li_end}'
'{li_start}If you\'ve forgotten your password, click '
'{link_start}here{link_end} to reset.{li_end}')).format(
link_start=HTML(
'<a http="#login" class="form-toggle" data-type="password-reset">'
),
link_end=HTML('</a>'),
li_start=HTML('<li>'),
li_end=HTML('</li>'),
remaining_attempts=remaining_attempts)
error_message = Text(
_(
"Email or password is incorrect."
"{li_start}You have {remaining_attempts} more sign-in "
"attempts before your account is temporarily locked.{li_end}"
"{li_start}If you've forgotten your password, click "
"{link_start}here{link_end} to reset.{li_end}"
)
).format(
link_start=HTML('<a http="#login" class="form-toggle" data-type="password-reset">'),
link_end=HTML("</a>"),
li_start=HTML("<li>"),
li_end=HTML("</li>"),
remaining_attempts=remaining_attempts,
)
raise AuthFailedError(
error_message,
error_code='failed-login-attempt',
error_code="failed-login-attempt",
context={
'remaining_attempts': remaining_attempts,
'allowed_failure_attempts': max_failures_allowed,
'failure_count': failure_count,
}
"remaining_attempts": remaining_attempts,
"allowed_failure_attempts": max_failures_allowed,
"failure_count": failure_count,
},
)
_generate_locked_out_error_message()
raise AuthFailedError(
_('Email or password is incorrect.'),
error_code='incorrect-email-or-password',
context={'failure_count': failure_count},
_("Email or password is incorrect."),
error_code="incorrect-email-or-password",
context={"failure_count": failure_count},
)
@@ -352,25 +356,18 @@ def _track_user_login(user, request):
# .. pii_retirement: third_party
segment.identify(
user.id,
{
'email': user.email,
'username': user.username
},
{"email": user.email, "username": user.username},
{
# Disable MailChimp because we don't want to update the user's email
# and username in MailChimp on every page load. We only need to capture
# this data on registration/activation.
'MailChimp': False
}
"MailChimp": False
},
)
segment.track(
user.id,
"edx.bi.user.account.authenticated",
{
'category': "conversion",
'label': request.POST.get('course_id'),
'provider': None
},
{"category": "conversion", "label": request.POST.get("course_id"), "provider": None},
)
@@ -380,20 +377,22 @@ def _create_message(site, root_url, allowed_domain):
to an allowed domain and not whitelisted then ask such users to login
through allowed domain SSO provider.
"""
msg = Text(_(
'As {allowed_domain} user, You must login with your {allowed_domain} '
'{link_start}{provider} account{link_end}.'
)).format(
msg = Text(
_(
"As {allowed_domain} user, You must login with your {allowed_domain} "
"{link_start}{provider} account{link_end}."
)
).format(
allowed_domain=allowed_domain,
link_start=HTML("<a href='{root_url}{tpa_provider_link}'>").format(
root_url=root_url if root_url else '',
tpa_provider_link='{dashboard_url}?tpa_hint={tpa_hint}'.format(
dashboard_url=reverse('dashboard'),
tpa_hint=site.configuration.get_value('THIRD_PARTY_AUTH_ONLY_HINT'),
)
root_url=root_url if root_url else "",
tpa_provider_link="{dashboard_url}?tpa_hint={tpa_hint}".format(
dashboard_url=reverse("dashboard"),
tpa_hint=site.configuration.get_value("THIRD_PARTY_AUTH_ONLY_HINT"),
),
),
provider=site.configuration.get_value('THIRD_PARTY_AUTH_ONLY_PROVIDER'),
link_end=HTML("</a>")
provider=site.configuration.get_value("THIRD_PARTY_AUTH_ONLY_PROVIDER"),
link_end=HTML("</a>"),
)
return msg
@@ -404,13 +403,13 @@ def _check_user_auth_flow(site, user):
then ask user to login through allowed domain SSO provider.
"""
if user and ENABLE_LOGIN_USING_THIRDPARTY_AUTH_ONLY.is_enabled():
allowed_domain = site.configuration.get_value('THIRD_PARTY_AUTH_ONLY_DOMAIN', '').lower()
email_parts = user.email.split('@')
allowed_domain = site.configuration.get_value("THIRD_PARTY_AUTH_ONLY_DOMAIN", "").lower()
email_parts = user.email.split("@")
if len(email_parts) != 2:
# User has a nonstandard email so we record their id.
# we don't record their e-mail in case there is sensitive info accidentally
# in there.
set_custom_attribute('login_tpa_domain_shortcircuit_user_id', user.id)
set_custom_attribute("login_tpa_domain_shortcircuit_user_id", user.id)
log.warning("User %s has nonstandard e-mail. Shortcircuiting THIRD_PART_AUTH_ONLY_DOMAIN check.", user.id)
return
user_domain = email_parts[1].strip().lower()
@@ -422,19 +421,19 @@ def _check_user_auth_flow(site, user):
raise AuthFailedError(msg)
raise AuthFailedError(
error_code='allowed-domain-login-error',
error_code="allowed-domain-login-error",
context={
'allowed_domain': allowed_domain,
'provider': site.configuration.get_value('THIRD_PARTY_AUTH_ONLY_PROVIDER'),
'tpa_hint': site.configuration.get_value('THIRD_PARTY_AUTH_ONLY_HINT'),
}
"allowed_domain": allowed_domain,
"provider": site.configuration.get_value("THIRD_PARTY_AUTH_ONLY_PROVIDER"),
"tpa_hint": site.configuration.get_value("THIRD_PARTY_AUTH_ONLY_HINT"),
},
)
@login_required
@require_http_methods(['GET'])
@require_http_methods(["GET"])
def finish_auth(request):
""" Following logistration (1st or 3rd party), handle any special query string params.
"""Following logistration (1st or 3rd party), handle any special query string params.
See FinishAuthView.js for details on the query string params.
@@ -459,10 +458,13 @@ def finish_auth(request):
GET /account/finish_auth/?course_id=course-v1:blah&enrollment_action=enroll
"""
return render_to_response('student_account/finish_auth.html', {
'disable_courseware_js': True,
'disable_footer': True,
})
return render_to_response(
"student_account/finish_auth.html",
{
"disable_courseware_js": True,
"disable_footer": True,
},
)
def enterprise_selection_page(request, user, next_url):
@@ -478,14 +480,14 @@ def enterprise_selection_page(request, user, next_url):
response = get_enterprise_learner_data_from_api(user)
if response and len(response) > 1:
redirect_url = reverse('enterprise_select_active') + '/?success_url=' + urllib.parse.quote(next_url)
redirect_url = reverse("enterprise_select_active") + "/?success_url=" + urllib.parse.quote(next_url)
# Check to see if next url has an enterprise in it. In this case if user is associated with
# that enterprise, activate that enterprise and bypass the selection page.
if re.match(ENTERPRISE_ENROLLMENT_URL_REGEX, urllib.parse.unquote(next_url)):
enterprise_in_url = re.search(UUID4_REGEX, next_url).group(0)
for enterprise in response:
if enterprise_in_url == str(enterprise['enterprise_customer']['uuid']):
if enterprise_in_url == str(enterprise["enterprise_customer"]["uuid"]):
is_activated_successfully = activate_learner_enterprise(request, user, enterprise_in_url)
if is_activated_successfully:
redirect_url = next_url
@@ -495,20 +497,20 @@ def enterprise_selection_page(request, user, next_url):
@ensure_csrf_cookie
@require_http_methods(['POST'])
@require_http_methods(["POST"])
@ratelimit(
key='openedx.core.djangoapps.util.ratelimit.request_post_email_or_username',
key="openedx.core.djangoapps.util.ratelimit.request_post_email_or_username",
rate=settings.LOGISTRATION_PER_EMAIL_RATELIMIT_RATE,
method='POST',
method="POST",
block=False,
) # lint-amnesty, pylint: disable=too-many-statements
@ratelimit(
key='openedx.core.djangoapps.util.ratelimit.real_ip',
key="openedx.core.djangoapps.util.ratelimit.real_ip",
rate=settings.LOGISTRATION_RATELIMIT_RATE,
method='POST',
method="POST",
block=False,
) # lint-amnesty, pylint: disable=too-many-statements
def login_user(request, api_version='v1'): # pylint: disable=too-many-statements
def login_user(request, api_version="v1"): # pylint: disable=too-many-statements
"""
AJAX request to log in the user.
@@ -542,10 +544,10 @@ def login_user(request, api_version='v1'): # pylint: disable=too-many-statement
_parse_analytics_param_for_course_id(request)
third_party_auth_requested = third_party_auth.is_enabled() and pipeline.running(request)
first_party_auth_requested = any(bool(request.POST.get(p)) for p in ['email', 'email_or_username', 'password'])
first_party_auth_requested = any(bool(request.POST.get(p)) for p in ["email", "email_or_username", "password"])
is_user_third_party_authenticated = False
set_custom_attribute('login_user_course_id', request.POST.get('course_id'))
set_custom_attribute("login_user_course_id", request.POST.get("course_id"))
if is_require_third_party_auth_enabled() and not third_party_auth_requested:
return HttpResponseForbidden(
@@ -564,12 +566,12 @@ def login_user(request, api_version='v1'): # pylint: disable=too-many-statement
try:
user = _do_third_party_auth(request)
is_user_third_party_authenticated = True
set_custom_attribute('login_user_tpa_success', True)
set_custom_attribute("login_user_tpa_success", True)
except AuthFailedError as e:
set_custom_attribute('login_user_tpa_success', False)
set_custom_attribute('login_user_tpa_failure_msg', e.value)
set_custom_attribute("login_user_tpa_success", False)
set_custom_attribute("login_user_tpa_failure_msg", e.value)
if e.error_code:
set_custom_attribute('login_error_code', e.error_code)
set_custom_attribute("login_error_code", e.error_code)
# user successfully authenticated with a third party provider, but has no linked Open edX account
response_content = e.get_response()
@@ -585,7 +587,10 @@ def login_user(request, api_version='v1'): # pylint: disable=too-many-statement
possibly_authenticated_user = StudentLoginRequested.run_filter(user=possibly_authenticated_user)
except StudentLoginRequested.PreventLogin as exc:
raise AuthFailedError(
str(exc), redirect_url=exc.redirect_to, error_code=exc.error_code, context=exc.context,
str(exc),
redirect_url=exc.redirect_to,
error_code=exc.error_code,
context=exc.context,
) from exc
if not is_user_third_party_authenticated:
@@ -599,82 +604,82 @@ def login_user(request, api_version='v1'): # pylint: disable=too-many-statement
):
_handle_failed_authentication(user, possibly_authenticated_user)
pwned_properties = check_pwned_password_and_send_track_event(
user_id=user.id,
password=request.POST.get('password'),
internal_user=user.is_staff,
request_page='login'
) if not is_user_third_party_authenticated else {}
# Set default for third party login
password_frequency = pwned_properties.get('frequency', -1)
if (
settings.ENABLE_AUTHN_LOGIN_BLOCK_HIBP_POLICY and
password_frequency >= settings.HIBP_LOGIN_BLOCK_PASSWORD_FREQUENCY_THRESHOLD
):
raise VulnerablePasswordError(
accounts.AUTHN_LOGIN_BLOCK_HIBP_POLICY_MSG,
'require-password-change'
pwned_properties = (
check_pwned_password_and_send_track_event(
user_id=user.id,
password=request.POST.get("password"),
internal_user=user.is_staff,
request_page="login",
)
if not is_user_third_party_authenticated
else {}
)
# Set default for third party login
password_frequency = pwned_properties.get("frequency", -1)
if (
settings.ENABLE_AUTHN_LOGIN_BLOCK_HIBP_POLICY
and password_frequency >= settings.HIBP_LOGIN_BLOCK_PASSWORD_FREQUENCY_THRESHOLD
):
raise VulnerablePasswordError(accounts.AUTHN_LOGIN_BLOCK_HIBP_POLICY_MSG, "require-password-change")
_handle_successful_authentication_and_login(possibly_authenticated_user, request)
# The AJAX method calling should know the default destination upon success
redirect_url, finish_auth_url = None, ''
redirect_url, finish_auth_url = None, ""
if third_party_auth_requested:
running_pipeline = pipeline.get(request)
finish_auth_url = pipeline.get_complete_url(backend_name=running_pipeline['backend'])
finish_auth_url = pipeline.get_complete_url(backend_name=running_pipeline["backend"])
if is_user_third_party_authenticated:
redirect_url = finish_auth_url
elif should_redirect_to_authn_microfrontend():
next_url, root_url = get_next_url_for_login_page(request, include_host=True)
redirect_url = get_redirect_url_with_host(
root_url,
enterprise_selection_page(request, possibly_authenticated_user, finish_auth_url or next_url)
root_url, enterprise_selection_page(request, possibly_authenticated_user, finish_auth_url or next_url)
)
if (
settings.ENABLE_AUTHN_LOGIN_NUDGE_HIBP_POLICY and
0 <= password_frequency <= settings.HIBP_LOGIN_NUDGE_PASSWORD_FREQUENCY_THRESHOLD
settings.ENABLE_AUTHN_LOGIN_NUDGE_HIBP_POLICY
and 0 <= password_frequency <= settings.HIBP_LOGIN_NUDGE_PASSWORD_FREQUENCY_THRESHOLD
):
raise VulnerablePasswordError(
accounts.AUTHN_LOGIN_NUDGE_HIBP_POLICY_MSG,
'nudge-password-change',
redirect_url
accounts.AUTHN_LOGIN_NUDGE_HIBP_POLICY_MSG, "nudge-password-change", redirect_url
)
response = JsonResponse({
'success': True,
'redirect_url': redirect_url,
})
response = JsonResponse(
{
"success": True,
"redirect_url": redirect_url,
}
)
# Ensure that the external marketing site can
# detect that the user is logged in.
response = set_logged_in_cookies(request, response, possibly_authenticated_user)
set_custom_attribute('login_user_auth_failed_error', False)
set_custom_attribute('login_user_response_status', response.status_code)
set_custom_attribute('login_user_redirect_url', redirect_url)
set_custom_attribute("login_user_auth_failed_error", False)
set_custom_attribute("login_user_response_status", response.status_code)
set_custom_attribute("login_user_redirect_url", redirect_url)
mark_user_change_as_expected(user.id)
return response
except AuthFailedError as error:
response_content = error.get_response()
log.exception(response_content)
error_code = response_content.get('error_code')
error_code = response_content.get("error_code")
if error_code:
set_custom_attribute('login_error_code', error_code)
email_or_username_key = 'email' if api_version == API_V1 else 'email_or_username'
set_custom_attribute("login_error_code", error_code)
email_or_username_key = "email" if api_version == API_V1 else "email_or_username"
email_or_username = request.POST.get(email_or_username_key, None)
email_or_username = possibly_authenticated_user.email if possibly_authenticated_user else email_or_username
response_content['email'] = email_or_username
response_content["email"] = email_or_username
except VulnerablePasswordError as error:
response_content = error.get_response()
log.exception(response_content)
response = JsonResponse(response_content, status=400)
set_custom_attribute('login_user_auth_failed_error', True)
set_custom_attribute('login_user_response_status', response.status_code)
set_custom_attribute("login_user_auth_failed_error", True)
set_custom_attribute("login_user_response_status", response.status_code)
return response
@@ -683,10 +688,10 @@ def login_user(request, api_version='v1'): # pylint: disable=too-many-statement
# to get a CSRF token before we need to refresh adds too much
# complexity.
@csrf_exempt
@require_http_methods(['POST'])
@require_http_methods(["POST"])
def login_refresh(request): # lint-amnesty, pylint: disable=missing-function-docstring
if not request.user.is_authenticated or request.user.is_anonymous:
return JsonResponse('Unauthorized', status=401)
return JsonResponse("Unauthorized", status=401)
try:
return get_response_with_refreshed_jwt_cookies(request, request.user)
@@ -700,33 +705,57 @@ def redirect_to_lms_login(request):
This view redirect the admin/login url to the site's login page if
waffle switch is on otherwise returns the admin site's login view.
"""
return redirect('/login?next=/admin')
return redirect("/login?next=/admin")
login_user_schema = openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"email": openapi.Schema(type=openapi.TYPE_STRING),
"password": openapi.Schema(type=openapi.TYPE_STRING),
},
)
login_user_return_schema = openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"success": openapi.Schema(type=openapi.TYPE_BOOLEAN),
"value": openapi.Schema(type=openapi.TYPE_STRING),
"error_code": openapi.Schema(type=openapi.TYPE_STRING),
},
)
class LoginSessionView(APIView):
"""HTTP end-points for logging in users. """
"""HTTP end-points for logging in users."""
# This end-point is available to anonymous users,
# so do not require authentication.
authentication_classes = []
login_user_responses = {
status.HTTP_200_OK: login_user_return_schema,
status.HTTP_400_BAD_REQUEST: login_user_return_schema,
status.HTTP_403_FORBIDDEN: login_user_return_schema,
}
@method_decorator(ensure_csrf_cookie)
def get(self, request, *args, **kwargs):
return HttpResponse(get_login_session_form(request).to_json(), content_type="application/json") # lint-amnesty, pylint: disable=http-response-with-content-type-json
@swagger_auto_schema(
request_body=login_user_schema,
responses=login_user_responses,
security=[
{"csrf": []},
],
)
@method_decorator(csrf_protect)
def post(self, request, api_version):
"""Log in a user.
See `login_user` for details.
Example Usage:
POST /api/user/v1/login_session
with POST params `email`, `password`.
200 {'success': true}
"""
POST /user/{api_version}/account/login_session/
Returns 200 on success, and a detailed error message otherwise.
"""
return login_user(request, api_version)
@@ -736,19 +765,19 @@ class LoginSessionView(APIView):
def _parse_analytics_param_for_course_id(request):
""" If analytics request param is found, parse and add course id as a new request param. """
"""If analytics request param is found, parse and add course id as a new request param."""
# Make a copy of the current POST request to modify.
modified_request = request.POST.copy()
if isinstance(request, HttpRequest):
# Works for an HttpRequest but not a rest_framework.request.Request.
# Note: This case seems to be used for tests only.
request.POST = modified_request
set_custom_attribute('login_user_request_type', 'django')
set_custom_attribute("login_user_request_type", "django")
else:
# The request must be a rest_framework.request.Request.
# Note: Only DRF seems to be used in Production.
request._data = modified_request # pylint: disable=protected-access
set_custom_attribute('login_user_request_type', 'drf')
set_custom_attribute("login_user_request_type", "drf")
# Include the course ID if it's specified in the analytics info
# so it can be included in analytics events.
@@ -758,9 +787,5 @@ def _parse_analytics_param_for_course_id(request):
if "enroll_course_id" in analytics:
modified_request["course_id"] = analytics.get("enroll_course_id")
except (ValueError, TypeError):
set_custom_attribute('shim_analytics_course_id', 'parse-error')
log.error(
"Could not parse analytics object sent to user API: {analytics}".format(
analytics=analytics
)
)
set_custom_attribute("shim_analytics_course_id", "parse-error")
log.error("Could not parse analytics object sent to user API: {analytics}".format(analytics=analytics))

View File

@@ -32,3 +32,7 @@ elasticsearch<7.14.0
# This can be unpinned once https://github.com/openedx/edx-platform/issues/34586
# has been resolved and edx-platform is running with pymongo>=4.4.0
# Cause: https://github.com/openedx/edx-lint/issues/458
# This can be unpinned once https://github.com/openedx/edx-lint/issues/459 has been resolved.
pip<24.3

View File

@@ -82,7 +82,7 @@ django-storages<1.14.4
# The team that owns this package will manually bump this package rather than having it pulled in automatically.
# This is to allow them to better control its deployment and to do it in a process that works better
# for them.
edx-enterprise==4.28.3
edx-enterprise==4.30.0
# Date: 2024-05-09
# This has to be constrained as well because newer versions of edx-i18n-tools need the

View File

@@ -86,5 +86,5 @@ sympy==1.13.3
# via
# -r requirements/edx-sandbox/base.in
# openedx-calc
tqdm==4.66.5
tqdm==4.66.6
# via nltk

View File

@@ -70,13 +70,13 @@ bleach[css]==6.1.0
# xblock-poll
boto==2.49.0
# via -r requirements/edx/kernel.in
boto3==1.35.46
boto3==1.35.50
# via
# -r requirements/edx/kernel.in
# django-ses
# fs-s3fs
# ora2
botocore==1.35.46
botocore==1.35.50
# via
# -r requirements/edx/kernel.in
# boto3
@@ -144,7 +144,7 @@ code-annotations==1.8.0
# edx-toggles
codejail-includes==1.0.0
# via -r requirements/edx/kernel.in
crowdsourcehinter-xblock==0.7
crowdsourcehinter-xblock==0.8
# via -r requirements/edx/bundled.in
cryptography==43.0.3
# via
@@ -455,7 +455,7 @@ edx-django-utils==7.0.0
# openedx-events
# ora2
# super-csv
edx-drf-extensions==10.4.0
edx-drf-extensions==10.5.0
# via
# -r requirements/edx/kernel.in
# edx-completion
@@ -467,11 +467,11 @@ edx-drf-extensions==10.4.0
# edx-when
# edxval
# openedx-learning
edx-enterprise==4.28.3
edx-enterprise==4.30.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/kernel.in
edx-event-bus-kafka==5.8.1
edx-event-bus-kafka==6.0.0
# via -r requirements/edx/kernel.in
edx-event-bus-redis==0.5.1
# via -r requirements/edx/kernel.in
@@ -584,7 +584,7 @@ geoip2==4.8.0
# via -r requirements/edx/kernel.in
glob2==0.7
# via -r requirements/edx/kernel.in
google-api-core[grpc]==2.21.0
google-api-core[grpc]==2.22.0
# via
# firebase-admin
# google-api-python-client
@@ -747,7 +747,7 @@ markupsafe==3.0.2
# xblock
maxminddb==2.6.2
# via geoip2
meilisearch==0.31.5
meilisearch==0.31.6
# via
# -r requirements/edx/kernel.in
# edx-search
@@ -1038,9 +1038,9 @@ pyyaml==6.0.2
# xblock
random2==1.0.2
# via -r requirements/edx/kernel.in
recommender-xblock==2.2.1
recommender-xblock==3.0.0
# via -r requirements/edx/bundled.in
redis==5.1.1
redis==5.2.0
# via
# -r requirements/edx/kernel.in
# walrus
@@ -1144,7 +1144,7 @@ slumber==0.7.1
# -r requirements/edx/kernel.in
# edx-bulk-grades
# edx-enterprise
snowflake-connector-python==3.12.2
snowflake-connector-python==3.12.3
# via edx-enterprise
social-auth-app-django==5.4.1
# via
@@ -1191,7 +1191,7 @@ tinycss2==1.2.1
# via bleach
tomlkit==0.13.2
# via snowflake-connector-python
tqdm==4.66.5
tqdm==4.66.6
# via
# nltk
# openai
@@ -1254,7 +1254,7 @@ webencodings==0.5.1
# bleach
# html5lib
# tinycss2
webob==1.8.8
webob==1.8.9
# via
# -r requirements/edx/kernel.in
# xblock
@@ -1291,7 +1291,7 @@ xmlsec==1.3.13
# python3-saml
xss-utils==0.6.0
# via -r requirements/edx/kernel.in
yarl==1.16.0
yarl==1.17.0
# via aiohttp
zipp==3.20.2
# via importlib-metadata

View File

@@ -140,14 +140,14 @@ boto==2.49.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
boto3==1.35.46
boto3==1.35.50
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# django-ses
# fs-s3fs
# ora2
botocore==1.35.46
botocore==1.35.50
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -282,7 +282,7 @@ coverage[toml]==7.6.4
# via
# -r requirements/edx/testing.txt
# pytest-cov
crowdsourcehinter-xblock==0.7
crowdsourcehinter-xblock==0.8
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -576,7 +576,7 @@ django-stubs==1.16.0
# -c requirements/edx/../constraints.txt
# -r requirements/edx/development.in
# djangorestframework-stubs
django-stubs-ext==5.1.0
django-stubs-ext==5.1.1
# via django-stubs
django-user-tasks==3.2.0
# via
@@ -728,7 +728,7 @@ edx-django-utils==7.0.0
# openedx-events
# ora2
# super-csv
edx-drf-extensions==10.4.0
edx-drf-extensions==10.5.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -741,12 +741,12 @@ edx-drf-extensions==10.4.0
# edx-when
# edxval
# openedx-learning
edx-enterprise==4.28.3
edx-enterprise==4.30.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
edx-event-bus-kafka==5.8.1
edx-event-bus-kafka==6.0.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -760,7 +760,7 @@ edx-i18n-tools==1.5.0
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# ora2
edx-lint==5.4.0
edx-lint==5.4.1
# via -r requirements/edx/testing.txt
edx-milestones==0.6.0
# via
@@ -879,11 +879,11 @@ execnet==2.1.1
# pytest-xdist
factory-boy==3.3.1
# via -r requirements/edx/testing.txt
faker==30.8.0
faker==30.8.1
# via
# -r requirements/edx/testing.txt
# factory-boy
fastapi==0.115.3
fastapi==0.115.4
# via
# -r requirements/edx/testing.txt
# pact-python
@@ -943,7 +943,7 @@ glob2==0.7
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
google-api-core[grpc]==2.21.0
google-api-core[grpc]==2.22.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1236,7 +1236,7 @@ mccabe==0.7.0
# via
# -r requirements/edx/testing.txt
# pylint
meilisearch==0.31.5
meilisearch==0.31.6
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1712,7 +1712,7 @@ pytest-metadata==1.8.0
# via
# -r requirements/edx/testing.txt
# pytest-json-report
pytest-randomly==3.15.0
pytest-randomly==3.16.0
# via -r requirements/edx/testing.txt
pytest-xdist[psutil]==3.6.1
# via -r requirements/edx/testing.txt
@@ -1800,11 +1800,11 @@ random2==1.0.2
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
recommender-xblock==2.2.1
recommender-xblock==3.0.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
redis==5.1.1
redis==5.2.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1958,7 +1958,7 @@ snowballstemmer==2.2.0
# via
# -r requirements/edx/doc.txt
# sphinx
snowflake-connector-python==3.12.2
snowflake-connector-python==3.12.3
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -2053,7 +2053,7 @@ staff-graded-xblock==2.3.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
starlette==0.41.0
starlette==0.41.2
# via
# -r requirements/edx/testing.txt
# fastapi
@@ -2101,7 +2101,7 @@ tomlkit==0.13.2
# snowflake-connector-python
tox==4.23.2
# via -r requirements/edx/testing.txt
tqdm==4.66.5
tqdm==4.66.6
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -2180,7 +2180,7 @@ vine==5.1.0
# amqp
# celery
# kombu
virtualenv==20.27.0
virtualenv==20.27.1
# via
# -r requirements/edx/testing.txt
# tox
@@ -2222,7 +2222,7 @@ webencodings==0.5.1
# bleach
# html5lib
# tinycss2
webob==1.8.8
webob==1.8.9
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -2280,7 +2280,7 @@ xss-utils==0.6.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
yarl==1.16.0
yarl==1.17.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt

View File

@@ -102,13 +102,13 @@ bleach[css]==6.1.0
# xblock-poll
boto==2.49.0
# via -r requirements/edx/base.txt
boto3==1.35.46
boto3==1.35.50
# via
# -r requirements/edx/base.txt
# django-ses
# fs-s3fs
# ora2
botocore==1.35.46
botocore==1.35.50
# via
# -r requirements/edx/base.txt
# boto3
@@ -194,7 +194,7 @@ code-annotations==1.8.0
# edx-toggles
codejail-includes==1.0.0
# via -r requirements/edx/base.txt
crowdsourcehinter-xblock==0.7
crowdsourcehinter-xblock==0.8
# via -r requirements/edx/base.txt
cryptography==43.0.3
# via
@@ -535,7 +535,7 @@ edx-django-utils==7.0.0
# openedx-events
# ora2
# super-csv
edx-drf-extensions==10.4.0
edx-drf-extensions==10.5.0
# via
# -r requirements/edx/base.txt
# edx-completion
@@ -547,11 +547,11 @@ edx-drf-extensions==10.4.0
# edx-when
# edxval
# openedx-learning
edx-enterprise==4.28.3
edx-enterprise==4.30.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
edx-event-bus-kafka==5.8.1
edx-event-bus-kafka==6.0.0
# via -r requirements/edx/base.txt
edx-event-bus-redis==0.5.1
# via -r requirements/edx/base.txt
@@ -683,7 +683,7 @@ gitpython==3.1.43
# via -r requirements/edx/doc.in
glob2==0.7
# via -r requirements/edx/base.txt
google-api-core[grpc]==2.21.0
google-api-core[grpc]==2.22.0
# via
# -r requirements/edx/base.txt
# firebase-admin
@@ -891,7 +891,7 @@ maxminddb==2.6.2
# via
# -r requirements/edx/base.txt
# geoip2
meilisearch==0.31.5
meilisearch==0.31.6
# via
# -r requirements/edx/base.txt
# edx-search
@@ -1247,9 +1247,9 @@ pyyaml==6.0.2
# xblock
random2==1.0.2
# via -r requirements/edx/base.txt
recommender-xblock==2.2.1
recommender-xblock==3.0.0
# via -r requirements/edx/base.txt
redis==5.1.1
redis==5.2.0
# via
# -r requirements/edx/base.txt
# walrus
@@ -1371,7 +1371,7 @@ smmap==5.0.1
# via gitdb
snowballstemmer==2.2.0
# via sphinx
snowflake-connector-python==3.12.2
snowflake-connector-python==3.12.3
# via
# -r requirements/edx/base.txt
# edx-enterprise
@@ -1472,7 +1472,7 @@ tomlkit==0.13.2
# via
# -r requirements/edx/base.txt
# snowflake-connector-python
tqdm==4.66.5
tqdm==4.66.6
# via
# -r requirements/edx/base.txt
# nltk
@@ -1547,7 +1547,7 @@ webencodings==0.5.1
# bleach
# html5lib
# tinycss2
webob==1.8.8
webob==1.8.9
# via
# -r requirements/edx/base.txt
# xblock
@@ -1586,7 +1586,7 @@ xmlsec==1.3.13
# python3-saml
xss-utils==0.6.0
# via -r requirements/edx/base.txt
yarl==1.16.0
yarl==1.17.0
# via
# -r requirements/edx/base.txt
# aiohttp

View File

@@ -116,7 +116,7 @@ ruamel-yaml==0.17.40
# via semgrep
ruamel-yaml-clib==0.2.12
# via ruamel-yaml
semgrep==1.92.0
semgrep==1.93.0
# via -r requirements/edx/semgrep.in
tomli==2.0.2
# via semgrep

View File

@@ -102,13 +102,13 @@ bleach[css]==6.1.0
# xblock-poll
boto==2.49.0
# via -r requirements/edx/base.txt
boto3==1.35.46
boto3==1.35.50
# via
# -r requirements/edx/base.txt
# django-ses
# fs-s3fs
# ora2
botocore==1.35.46
botocore==1.35.50
# via
# -r requirements/edx/base.txt
# boto3
@@ -213,7 +213,7 @@ coverage[toml]==7.6.4
# via
# -r requirements/edx/coverage.txt
# pytest-cov
crowdsourcehinter-xblock==0.7
crowdsourcehinter-xblock==0.8
# via -r requirements/edx/base.txt
cryptography==43.0.3
# via
@@ -559,7 +559,7 @@ edx-django-utils==7.0.0
# openedx-events
# ora2
# super-csv
edx-drf-extensions==10.4.0
edx-drf-extensions==10.5.0
# via
# -r requirements/edx/base.txt
# edx-completion
@@ -571,11 +571,11 @@ edx-drf-extensions==10.4.0
# edx-when
# edxval
# openedx-learning
edx-enterprise==4.28.3
edx-enterprise==4.30.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
edx-event-bus-kafka==5.8.1
edx-event-bus-kafka==6.0.0
# via -r requirements/edx/base.txt
edx-event-bus-redis==0.5.1
# via -r requirements/edx/base.txt
@@ -584,7 +584,7 @@ edx-i18n-tools==1.5.0
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
# ora2
edx-lint==5.4.0
edx-lint==5.4.1
# via -r requirements/edx/testing.in
edx-milestones==0.6.0
# via -r requirements/edx/base.txt
@@ -674,9 +674,9 @@ execnet==2.1.1
# via pytest-xdist
factory-boy==3.3.1
# via -r requirements/edx/testing.in
faker==30.8.0
faker==30.8.1
# via factory-boy
fastapi==0.115.3
fastapi==0.115.4
# via pact-python
fastavro==1.9.7
# via
@@ -717,7 +717,7 @@ geoip2==4.8.0
# via -r requirements/edx/base.txt
glob2==0.7
# via -r requirements/edx/base.txt
google-api-core[grpc]==2.21.0
google-api-core[grpc]==2.22.0
# via
# -r requirements/edx/base.txt
# firebase-admin
@@ -944,7 +944,7 @@ maxminddb==2.6.2
# geoip2
mccabe==0.7.0
# via pylint
meilisearch==0.31.5
meilisearch==0.31.6
# via
# -r requirements/edx/base.txt
# edx-search
@@ -1295,7 +1295,7 @@ pytest-metadata==1.8.0
# via
# -r requirements/edx/testing.in
# pytest-json-report
pytest-randomly==3.15.0
pytest-randomly==3.16.0
# via -r requirements/edx/testing.in
pytest-xdist[psutil]==3.6.1
# via -r requirements/edx/testing.in
@@ -1365,9 +1365,9 @@ pyyaml==6.0.2
# xblock
random2==1.0.2
# via -r requirements/edx/base.txt
recommender-xblock==2.2.1
recommender-xblock==3.0.0
# via -r requirements/edx/base.txt
redis==5.1.1
redis==5.2.0
# via
# -r requirements/edx/base.txt
# walrus
@@ -1490,7 +1490,7 @@ slumber==0.7.1
# edx-enterprise
sniffio==1.3.1
# via anyio
snowflake-connector-python==3.12.2
snowflake-connector-python==3.12.3
# via
# -r requirements/edx/base.txt
# edx-enterprise
@@ -1522,7 +1522,7 @@ sqlparse==0.5.1
# django
staff-graded-xblock==2.3.0
# via -r requirements/edx/base.txt
starlette==0.41.0
starlette==0.41.2
# via fastapi
stevedore==5.3.0
# via
@@ -1560,7 +1560,7 @@ tomlkit==0.13.2
# snowflake-connector-python
tox==4.23.2
# via -r requirements/edx/testing.in
tqdm==4.66.5
tqdm==4.66.6
# via
# -r requirements/edx/base.txt
# nltk
@@ -1614,7 +1614,7 @@ vine==5.1.0
# amqp
# celery
# kombu
virtualenv==20.27.0
virtualenv==20.27.1
# via tox
voluptuous==0.15.2
# via
@@ -1644,7 +1644,7 @@ webencodings==0.5.1
# bleach
# html5lib
# tinycss2
webob==1.8.8
webob==1.8.9
# via
# -r requirements/edx/base.txt
# xblock
@@ -1685,7 +1685,7 @@ xmlsec==1.3.13
# python3-saml
xss-utils==0.6.0
# via -r requirements/edx/base.txt
yarl==1.16.0
yarl==1.17.0
# via
# -r requirements/edx/base.txt
# aiohttp

View File

@@ -9,6 +9,8 @@ wheel==0.44.0
# The following packages are considered to be unsafe in a requirements file:
pip==24.2
# via -r requirements/pip.in
# via
# -c requirements/common_constraints.txt
# -r requirements/pip.in
setuptools==75.2.0
# via -r requirements/pip.in

View File

@@ -10,9 +10,9 @@ attrs==24.2.0
# via zeep
backoff==2.2.1
# via -r scripts/user_retirement/requirements/base.in
boto3==1.35.46
boto3==1.35.50
# via -r scripts/user_retirement/requirements/base.in
botocore==1.35.46
botocore==1.35.50
# via
# boto3
# s3transfer
@@ -50,7 +50,7 @@ edx-django-utils==7.0.0
# via edx-rest-api-client
edx-rest-api-client==6.0.0
# via -r scripts/user_retirement/requirements/base.in
google-api-core==2.21.0
google-api-core==2.22.0
# via google-api-python-client
google-api-python-client==2.149.0
# via -r scripts/user_retirement/requirements/base.in

View File

@@ -14,11 +14,11 @@ attrs==24.2.0
# zeep
backoff==2.2.1
# via -r scripts/user_retirement/requirements/base.txt
boto3==1.35.46
boto3==1.35.50
# via
# -r scripts/user_retirement/requirements/base.txt
# moto
botocore==1.35.46
botocore==1.35.50
# via
# -r scripts/user_retirement/requirements/base.txt
# boto3
@@ -72,7 +72,7 @@ edx-django-utils==7.0.0
# edx-rest-api-client
edx-rest-api-client==6.0.0
# via -r scripts/user_retirement/requirements/base.txt
google-api-core==2.21.0
google-api-core==2.22.0
# via
# -r scripts/user_retirement/requirements/base.txt
# google-api-python-client
@@ -272,7 +272,7 @@ urllib3==1.26.20
# botocore
# requests
# responses
werkzeug==3.0.4
werkzeug==3.0.6
# via moto
xmltodict==0.14.2
# via moto

View File

@@ -11,7 +11,7 @@ from xblock.fields import Scope, String
from openedx.core.djangolib.markup import HTML, Text
from xmodule.editing_block import EditingMixin
from xmodule.raw_block import RawMixin
from xmodule.util.builtin_assets import add_webpack_js_to_fragment, add_sass_to_fragment
from xmodule.util.builtin_assets import add_webpack_js_to_fragment, add_css_to_fragment
from xmodule.xml_block import XmlMixin
from xmodule.x_module import (
ResourceTemplates,
@@ -180,7 +180,7 @@ class AnnotatableBlock(
"""
fragment = Fragment()
fragment.add_content(self.get_html())
add_sass_to_fragment(fragment, 'AnnotatableBlockDisplay.scss')
add_css_to_fragment(fragment, 'AnnotatableBlockDisplay.css')
add_webpack_js_to_fragment(fragment, 'AnnotatableBlockDisplay')
shim_xmodule_js(fragment, 'Annotatable')
@@ -193,7 +193,7 @@ class AnnotatableBlock(
fragment = Fragment(
self.runtime.service(self, 'mako').render_cms_template(self.mako_template, self.get_context())
)
add_sass_to_fragment(fragment, 'AnnotatableBlockEditor.scss')
add_css_to_fragment(fragment, 'AnnotatableBlockEditor.css')
add_webpack_js_to_fragment(fragment, 'AnnotatableBlockEditor')
shim_xmodule_js(fragment, self.studio_js_module_name)
return fragment

View File

@@ -1,3 +0,0 @@
.xmodule_display.xmodule_AnnotatableBlock {
@import "annotatable/display.scss";
}

View File

@@ -1,3 +0,0 @@
.xmodule_edit.xmodule_AnnotatableBlock {
@import "codemirror/codemirror.scss";
}

View File

@@ -1,3 +0,0 @@
.xmodule_display.xmodule_PollBlock {
@import "poll/display.scss";
}

View File

@@ -1,3 +0,0 @@
.xmodule_display.xmodule_WordCloudBlock {
@import "word_cloud/display.scss";
}

View File

@@ -1,197 +0,0 @@
/* TODO: move top-level variables to a common _variables.scss.
* NOTE: These variables were only added here because when this was integrated with the CMS,
* SASS compilation errors were triggered because the CMS didn't have the same variables defined
* that the LMS did, so the quick fix was to localize the LMS variables not shared by the CMS.
* -Abarrett and Vshnayder
*/
@import 'bourbon/bourbon';
@import 'lms/theme/variables';
@import 'bootstrap/scss/variables';
@import 'lms/theme/variables-v1';
$annotatable--border-color: var(--gray-l3);
$annotatable--body-font-size: em(14);
.annotatable-wrapper {
position: relative;
}
.annotatable-header {
margin-bottom: 0.5em;
}
.annotatable-section {
position: relative;
padding: 0.5em 1em;
border: 1px solid $annotatable--border-color;
border-radius: 0.5em;
margin-bottom: 0.5em;
&.shaded { background-color: #ededed; }
.annotatable-section-title {
font-weight: bold;
a { font-weight: normal; }
}
.annotatable-section-body {
border-top: 1px solid $annotatable--border-color;
margin-top: 0.5em;
padding-top: 0.5em;
@include clearfix();
}
ul.instructions-template {
list-style: disc;
margin-left: 4em;
b { font-weight: bold; }
i { font-style: italic; }
code {
display: inline;
white-space: pre;
font-family: Courier New, monospace;
}
}
}
.annotatable-toggle {
position: absolute;
right: 0;
margin: 2px 1em 2px 0;
&.expanded::after { content: " \2191"; }
&.collapsed::after { content: " \2193"; }
}
.annotatable-span {
@extend %ui-fake-link;
display: inline;
$highlight_index: 0;
@each $highlight in (
(yellow rgba(255, 255,10, 0.3) rgba(255, 255,10, 0.9)),
(red rgba(178,19,16,0.3) rgba(178,19,16,0.9)),
(orange rgba(255,165,0, 0.3) rgba(255,165,0, 0.9)),
(green rgba(25,255,132,0.3) rgba(25,255,132,0.9)),
(blue rgba(35,163,255,0.3) rgba(35,163,255,0.9)),
(purple rgba(115,9,178,0.3) rgba(115,9,178,0.9))) {
$highlight_index: $highlight_index + 1;
$marker: nth($highlight, 1);
$color: nth($highlight, 2);
$selected_color: nth($highlight, 3);
@if $highlight_index == 1 {
&.highlight {
background-color: $color;
&.selected { background-color: $selected_color; }
}
}
&.highlight-#{$marker} {
background-color: $color;
&.selected { background-color: $selected_color; }
}
}
&.hide {
cursor: none;
background-color: inherit;
.annotatable-icon {
display: none;
}
}
.annotatable-comment {
display: none;
}
}
.ui-tooltip.qtip.ui-tooltip {
font-size: $annotatable--body-font-size;
border: 1px solid #333;
border-radius: 1em;
background-color: rgba(0, 0, 0, 0.85);
color: var(--white);
-webkit-font-smoothing: antialiased;
.ui-tooltip-titlebar {
font-size: em(16);
color: inherit;
background-color: transparent;
padding: calc((var(--baseline)/4)) calc((var(--baseline)/2));
border: none;
.ui-tooltip-title {
padding: calc((var(--baseline)/4)) 0;
border-bottom: 2px solid #333;
font-weight: bold;
}
.ui-tooltip-icon {
right: 10px;
background: #333;
}
.ui-state-hover {
color: inherit;
border: 1px solid var(--gray-l3);
}
}
.ui-tooltip-content {
color: inherit;
font-size: em(14);
text-align: left;
font-weight: 400;
padding: 0 calc((var(--baseline)/2)) calc((var(--baseline)/2)) calc((var(--baseline)/2));
background-color: transparent;
border-color: transparent;
}
p {
color: inherit;
line-height: normal;
}
}
.ui-tooltip.qtip.ui-tooltip-annotatable {
max-width: 375px;
.ui-tooltip-content {
padding: 0 calc((var(--baseline)/2));
.annotatable-comment {
display: block;
margin: 0 0 calc((var(--baseline)/2)) 0;
max-height: 225px;
overflow: auto;
line-height: normal;
}
.annotatable-reply {
display: block;
border-top: 2px solid #333;
padding: calc((var(--baseline)/4)) 0;
margin: 0;
text-align: center;
}
}
&::after {
content: '';
display: inline-block;
position: absolute;
bottom: -20px;
left: 50%;
height: 0;
width: 0;
margin-left: calc(-1 * (var(--baseline) / 4));
border: 10px solid transparent;
border-top-color: rgba(0, 0, 0, 0.85);
}
}

View File

@@ -1,226 +0,0 @@
@import 'bourbon/bourbon';
@import 'lms/theme/variables';
@import 'bootstrap/scss/variables';
@import 'lms/theme/variables-v1';
div.poll_question {
@media print {
display: block;
width: auto;
padding: 0;
canvas, img {
page-break-inside: avoid;
}
}
.inline {
display: inline;
}
h3 {
margin-top: 0;
margin-bottom: ($baseline*0.75);
color: #fe57a1;
font-size: 1.9em;
&.problem-header {
div.staff {
margin-top: ($baseline*1.5);
font-size: 80%;
}
}
@media print {
display: block;
width: auto;
border-right: 0;
}
}
p {
text-align: justify;
font-weight: bold;
}
.poll_answer {
margin-bottom: $baseline;
&.short {
clear: both;
}
.question {
height: auto;
clear: both;
min-height: 30px;
&.short {
clear: none;
width: 30%;
display: inline;
float: left;
}
.button {
@extend %ui-fake-link;
-webkit-appearance: none;
-webkit-background-clip: padding-box;
-webkit-border-image: none;
-webkit-box-align: center;
-webkit-box-shadow: rgb(255, 255, 255) 0px 1px 0px 0px inset;
-webkit-font-smoothing: antialiased;
-webkit-rtl-ordering: logical;
-webkit-user-select: text;
-webkit-writing-mode: horizontal-tb;
background-clip: padding-box;
background-color: rgb(238, 238, 238);
background-image: -webkit-linear-gradient(top, rgb(238, 238, 238), rgb(210, 210, 210));
border-bottom-color: rgb(202, 202, 202);
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
border-bottom-style: solid;
border-bottom-width: 1px;
border-left-color: rgb(202, 202, 202);
border-left-style: solid;
border-left-width: 1px;
border-right-color: rgb(202, 202, 202);
border-right-style: solid;
border-right-width: 1px;
border-top-color: rgb(202, 202, 202);
border-top-left-radius: 3px;
border-top-right-radius: 3px;
border-top-style: solid;
border-top-width: 1px;
box-shadow: rgb(255, 255, 255) 0px 1px 0px 0px inset;
box-sizing: border-box;
color: rgb(51, 51, 51);
/* display: inline-block; */
display: inline;
float: left;
font-family: 'Open Sans', Verdana, Geneva, sans-serif;
font-size: 13px;
font-style: normal;
font-variant: normal;
font-weight: bold;
letter-spacing: normal;
line-height: 25.59375px;
margin-bottom: ($baseline*0.75);
margin: 0;
padding: 0px;
text-align: center;
text-decoration: none;
text-indent: 0px;
text-shadow: rgb(248, 248, 248) 0px 1px 0px;
text-transform: none;
vertical-align: top;
white-space: pre-line;
width: 25px;
height: 25px;
word-spacing: 0px;
writing-mode: lr-tb;
}
.button.answered {
-webkit-box-shadow: rgb(97, 184, 225) 0px 1px 0px 0px inset;
background-color: rgb(29, 157, 217);
background-image: -webkit-linear-gradient(top, rgb(29, 157, 217), rgb(14, 124, 176));
border-bottom-color: rgb(13, 114, 162);
border-left-color: rgb(13, 114, 162);
border-right-color: rgb(13, 114, 162);
border-top-color: rgb(13, 114, 162);
box-shadow: rgb(97, 184, 225) 0px 1px 0px 0px inset;
color: rgb(255, 255, 255);
text-shadow: rgb(7, 103, 148) 0px 1px 0px;
background-image: none;
}
.text {
@extend %ui-fake-link;
display: inline;
float: left;
width: 80%;
text-align: left;
min-height: 30px;
margin-left: $baseline;
height: auto;
margin-bottom: $baseline;
&.short {
width: 100px;
}
}
}
.stats {
min-height: 40px;
margin-top: $baseline;
clear: both;
&.short {
margin-top: 0;
clear: none;
display: inline;
float: right;
width: 70%;
}
.bar {
width: 75%;
height: 20px;
border: 1px solid black;
display: inline;
float: left;
margin-right: ($baseline/2);
&.short {
width: 65%;
height: 20px;
margin-top: 3px;
}
.percent {
background-color: gray;
width: 0;
height: 20px;
&.short { }
}
}
.number {
width: 80px;
display: inline;
float: right;
height: 28px;
text-align: right;
&.short {
width: 120px;
height: auto;
}
}
}
}
.poll_answer.answered {
-webkit-box-shadow: rgb(97, 184, 225) 0 1px 0 0 inset;
background-color: rgb(29, 157, 217);
background-image: -webkit-linear-gradient(top, rgb(29, 157, 217), rgb(14, 124, 176));
border-bottom-color: rgb(13, 114, 162);
border-left-color: rgb(13, 114, 162);
border-right-color: rgb(13, 114, 162);
border-top-color: rgb(13, 114, 162);
box-shadow: rgb(97, 184, 225) 0 1px 0 0 inset;
color: rgb(255, 255, 255);
text-shadow: rgb(7, 103, 148) 0 1px 0;
}
.button.reset-button {
clear: both;
float: right;
}
}

View File

@@ -1,30 +0,0 @@
@import 'bourbon/bourbon';
@import 'lms/theme/variables';
@import 'bootstrap/scss/variables';
@import 'lms/theme/variables-v1';
.input-cloud {
margin: calc((var(--baseline)/4));
}
.result_cloud_section {
display: none;
width: 0px;
height: 0px;
}
.result_cloud_section.active {
display: block;
width: 100%;
height: auto;
margin-top: 1em;
h3 {
font-size: 100%;
}
}
.your_words{
font-size: 0.85em;
display: block;
}

View File

@@ -21,7 +21,7 @@ from xblock.fields import Boolean, Dict, List, Scope, String # lint-amnesty, py
from openedx.core.djangolib.markup import Text, HTML
from xmodule.mako_block import MakoTemplateBlockBase
from xmodule.stringify import stringify_children
from xmodule.util.builtin_assets import add_webpack_js_to_fragment, add_sass_to_fragment
from xmodule.util.builtin_assets import add_webpack_js_to_fragment, add_css_to_fragment
from xmodule.x_module import (
ResourceTemplates,
shim_xmodule_js,
@@ -136,7 +136,7 @@ class PollBlock(
'configuration_json': self.dump_poll(),
}
fragment.add_content(self.runtime.service(self, 'mako').render_lms_template('poll.html', params))
add_sass_to_fragment(fragment, 'PollBlockDisplay.scss')
add_css_to_fragment(fragment, 'PollBlockDisplay.css')
add_webpack_js_to_fragment(fragment, 'PollBlockDisplay')
shim_xmodule_js(fragment, 'Poll')
return fragment

View File

@@ -0,0 +1,243 @@
@import url("https://fonts.googleapis.com/css?family=Open+Sans:300,400,400i,600,700");
.xmodule_display.xmodule_AnnotatableBlock {
/* TODO: move top-level variables to a common _variables.scss.
* NOTE: These variables were only added here because when this was integrated with the CMS,
* SASS compilation errors were triggered because the CMS didn't have the same variables defined
* that the LMS did, so the quick fix was to localize the LMS variables not shared by the CMS.
* -Abarrett and Vshnayder
*/
/* stylelint-disable-line */
/* stylelint-disable-line */
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-wrapper {
position: relative;
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-header {
margin-bottom: 0.5em;
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-section {
position: relative;
padding: 0.5em 1em;
border: 1px solid var(--gray-l3);
border-radius: 0.5em;
margin-bottom: 0.5em;
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-section.shaded {
background-color: #ededed;
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-section .annotatable-section-title {
font-weight: bold;
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-section .annotatable-section-title a {
font-weight: normal;
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-section .annotatable-section-body {
border-top: 1px solid var(--gray-l3);
margin-top: 0.5em;
padding-top: 0.5em;
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-section .annotatable-section-body:after {
content: "";
display: table;
clear: both;
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-section ul.instructions-template {
list-style: disc;
margin-left: 4em;
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-section ul.instructions-template b {
font-weight: bold;
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-section ul.instructions-template i {
font-style: italic;
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-section ul.instructions-template code {
display: inline;
white-space: pre;
font-family: Courier New, monospace;
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-toggle {
position: absolute;
right: 0;
margin: 2px 1em 2px 0;
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-toggle.expanded::after {
content: " \2191";
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-toggle.collapsed::after {
content: " \2193";
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-span {
display: inline;
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-span.highlight {
background-color: rgba(255, 255, 10, 0.3);
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-span.highlight.selected {
background-color: rgba(255, 255, 10, 0.9);
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-span.highlight-yellow {
background-color: rgba(255, 255, 10, 0.3);
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-span.highlight-yellow.selected {
background-color: rgba(255, 255, 10, 0.9);
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-span.highlight-red {
background-color: rgba(178, 19, 16, 0.3);
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-span.highlight-red.selected {
background-color: rgba(178, 19, 16, 0.9);
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-span.highlight-orange {
background-color: rgba(255, 165, 0, 0.3);
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-span.highlight-orange.selected {
background-color: rgba(255, 165, 0, 0.9);
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-span.highlight-green {
background-color: rgba(25, 255, 132, 0.3);
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-span.highlight-green.selected {
background-color: rgba(25, 255, 132, 0.9);
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-span.highlight-blue {
background-color: rgba(35, 163, 255, 0.3);
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-span.highlight-blue.selected {
background-color: rgba(35, 163, 255, 0.9);
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-span.highlight-purple {
background-color: rgba(115, 9, 178, 0.3);
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-span.highlight-purple.selected {
background-color: rgba(115, 9, 178, 0.9);
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-span.hide {
cursor: none;
background-color: inherit;
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-span.hide .annotatable-icon {
display: none;
}
.xmodule_display.xmodule_AnnotatableBlock .annotatable-span .annotatable-comment {
display: none;
}
.xmodule_display.xmodule_AnnotatableBlock .ui-tooltip.qtip.ui-tooltip {
font-size: 0.875em;
border: 1px solid #333;
border-radius: 1em;
background-color: rgba(0, 0, 0, 0.85);
color: var(--white);
-webkit-font-smoothing: antialiased;
}
.xmodule_display.xmodule_AnnotatableBlock .ui-tooltip.qtip.ui-tooltip .ui-tooltip-titlebar {
font-size: 1em;
color: inherit;
background-color: transparent;
padding: calc((var(--baseline) / 4)) calc((var(--baseline) / 2));
border: none;
}
.xmodule_display.xmodule_AnnotatableBlock .ui-tooltip.qtip.ui-tooltip .ui-tooltip-titlebar .ui-tooltip-title {
padding: calc((var(--baseline) / 4)) 0;
border-bottom: 2px solid #333;
font-weight: bold;
}
.xmodule_display.xmodule_AnnotatableBlock .ui-tooltip.qtip.ui-tooltip .ui-tooltip-titlebar .ui-tooltip-icon {
right: 10px;
background: #333;
}
.xmodule_display.xmodule_AnnotatableBlock .ui-tooltip.qtip.ui-tooltip .ui-tooltip-titlebar .ui-state-hover {
color: inherit;
border: 1px solid var(--gray-l3);
}
.xmodule_display.xmodule_AnnotatableBlock .ui-tooltip.qtip.ui-tooltip .ui-tooltip-content {
color: inherit;
font-size: 0.875em;
text-align: left;
font-weight: 400;
padding: 0 calc((var(--baseline) / 2)) calc((var(--baseline) / 2)) calc((var(--baseline) / 2));
background-color: transparent;
border-color: transparent;
}
.xmodule_display.xmodule_AnnotatableBlock .ui-tooltip.qtip.ui-tooltip p {
color: inherit;
line-height: normal;
}
.xmodule_display.xmodule_AnnotatableBlock .ui-tooltip.qtip.ui-tooltip-annotatable {
max-width: 375px;
}
.xmodule_display.xmodule_AnnotatableBlock .ui-tooltip.qtip.ui-tooltip-annotatable .ui-tooltip-content {
padding: 0 calc((var(--baseline) / 2));
}
.xmodule_display.xmodule_AnnotatableBlock .ui-tooltip.qtip.ui-tooltip-annotatable .ui-tooltip-content .annotatable-comment {
display: block;
margin: 0 0 calc((var(--baseline) / 2)) 0;
max-height: 225px;
overflow: auto;
line-height: normal;
}
.xmodule_display.xmodule_AnnotatableBlock .ui-tooltip.qtip.ui-tooltip-annotatable .ui-tooltip-content .annotatable-reply {
display: block;
border-top: 2px solid #333;
padding: calc((var(--baseline) / 4)) 0;
margin: 0;
text-align: center;
}
.xmodule_display.xmodule_AnnotatableBlock .ui-tooltip.qtip.ui-tooltip-annotatable::after {
content: '';
display: inline-block;
position: absolute;
bottom: -20px;
left: 50%;
height: 0;
width: 0;
margin-left: calc(-1 * (var(--baseline) / 4));
border: 10px solid transparent;
border-top-color: rgba(0, 0, 0, 0.85);
}

View File

@@ -0,0 +1,5 @@
.xmodule_edit.xmodule_AnnotatableBlock .CodeMirror {
background: #fff;
font-size: 13px;
color: #3c3c3c;
}

View File

@@ -0,0 +1,221 @@
@import url("https://fonts.googleapis.com/css?family=Open+Sans:300,400,400i,600,700");
.xmodule_display.xmodule_PollBlock {
/* stylelint-disable-line */
/* stylelint-disable-line */
}
@media print {
.xmodule_display.xmodule_PollBlock div.poll_question {
display: block;
width: auto;
padding: 0;
}
.xmodule_display.xmodule_PollBlock div.poll_question canvas, .xmodule_display.xmodule_PollBlock div.poll_question img {
page-break-inside: avoid;
}
}
.xmodule_display.xmodule_PollBlock div.poll_question .inline {
display: inline;
}
.xmodule_display.xmodule_PollBlock div.poll_question h3 {
margin-top: 0;
margin-bottom: calc((var(--baseline) * 0.75));
color: #fe57a1;
font-size: 1.9em;
}
.xmodule_display.xmodule_PollBlock div.poll_question h3.problem-header div.staff {
margin-top: calc((var(--baseline) * 1.5));
font-size: 80%;
}
@media print {
.xmodule_display.xmodule_PollBlock div.poll_question h3 {
display: block;
width: auto;
border-right: 0;
}
}
.xmodule_display.xmodule_PollBlock div.poll_question p {
text-align: justify;
font-weight: bold;
}
.xmodule_display.xmodule_PollBlock div.poll_question .poll_answer {
margin-bottom: var(--baseline);
}
.xmodule_display.xmodule_PollBlock div.poll_question .poll_answer.short {
clear: both;
}
.xmodule_display.xmodule_PollBlock div.poll_question .poll_answer .question {
height: auto;
clear: both;
min-height: 30px;
}
.xmodule_display.xmodule_PollBlock div.poll_question .poll_answer .question.short {
clear: none;
width: 30%;
display: inline;
float: left;
}
.xmodule_display.xmodule_PollBlock div.poll_question .poll_answer .question .button {
-webkit-appearance: none;
-webkit-background-clip: padding-box;
-webkit-border-image: none;
-webkit-box-align: center;
-webkit-box-shadow: white 0px 1px 0px 0px inset;
-webkit-font-smoothing: antialiased;
-webkit-rtl-ordering: logical;
-webkit-user-select: text;
-webkit-writing-mode: horizontal-tb;
background-clip: padding-box;
background-color: #eeeeee;
background-image: -webkit-linear-gradient(top, #eeeeee, #d2d2d2);
border-bottom-color: #cacaca;
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
border-bottom-style: solid;
border-bottom-width: 1px;
border-left-color: #cacaca;
border-left-style: solid;
border-left-width: 1px;
border-right-color: #cacaca;
border-right-style: solid;
border-right-width: 1px;
border-top-color: #cacaca;
border-top-left-radius: 3px;
border-top-right-radius: 3px;
border-top-style: solid;
border-top-width: 1px;
box-shadow: white 0px 1px 0px 0px inset;
box-sizing: border-box;
color: #333333;
/* display: inline-block; */
display: inline;
float: left;
font-family: 'Open Sans', Verdana, Geneva, sans-serif;
font-size: 13px;
font-style: normal;
font-variant: normal;
font-weight: bold;
letter-spacing: normal;
line-height: 25.59375px;
margin-bottom: calc((var(--baseline) * 0.75));
margin: 0;
padding: 0px;
text-align: center;
text-decoration: none;
text-indent: 0px;
text-shadow: #f8f8f8 0px 1px 0px;
text-transform: none;
vertical-align: top;
white-space: pre-line;
width: 25px;
height: 25px;
word-spacing: 0px;
writing-mode: lr-tb;
}
.xmodule_display.xmodule_PollBlock div.poll_question .poll_answer .question .button.answered {
-webkit-box-shadow: #61b8e1 0px 1px 0px 0px inset;
background-color: #1d9dd9;
background-image: -webkit-linear-gradient(top, #1d9dd9, #0e7cb0);
border-bottom-color: #0d72a2;
border-left-color: #0d72a2;
border-right-color: #0d72a2;
border-top-color: #0d72a2;
box-shadow: #61b8e1 0px 1px 0px 0px inset;
color: white;
text-shadow: #076794 0px 1px 0px;
background-image: none;
}
.xmodule_display.xmodule_PollBlock div.poll_question .poll_answer .question .text {
display: inline;
float: left;
width: 80%;
text-align: left;
min-height: 30px;
margin-left: var(--baseline);
height: auto;
margin-bottom: var(--baseline);
}
.xmodule_display.xmodule_PollBlock div.poll_question .poll_answer .question .text.short {
width: 100px;
}
.xmodule_display.xmodule_PollBlock div.poll_question .poll_answer .stats {
min-height: 40px;
margin-top: var(--baseline);
clear: both;
}
.xmodule_display.xmodule_PollBlock div.poll_question .poll_answer .stats.short {
margin-top: 0;
clear: none;
display: inline;
float: right;
width: 70%;
}
.xmodule_display.xmodule_PollBlock div.poll_question .poll_answer .stats .bar {
width: 75%;
height: 20px;
border: 1px solid black;
display: inline;
float: left;
margin-right: calc((var(--baseline) / 2));
}
.xmodule_display.xmodule_PollBlock div.poll_question .poll_answer .stats .bar.short {
width: 65%;
height: 20px;
margin-top: 3px;
}
.xmodule_display.xmodule_PollBlock div.poll_question .poll_answer .stats .bar .percent {
background-color: gray;
width: 0;
height: 20px;
}
.xmodule_display.xmodule_PollBlock div.poll_question .poll_answer .stats .number {
width: 80px;
display: inline;
float: right;
height: 28px;
text-align: right;
}
.xmodule_display.xmodule_PollBlock div.poll_question .poll_answer .stats .number.short {
width: 120px;
height: auto;
}
.xmodule_display.xmodule_PollBlock div.poll_question .poll_answer.answered {
-webkit-box-shadow: #61b8e1 0 1px 0 0 inset;
background-color: #1d9dd9;
background-image: -webkit-linear-gradient(top, #1d9dd9, #0e7cb0);
border-bottom-color: #0d72a2;
border-left-color: #0d72a2;
border-right-color: #0d72a2;
border-top-color: #0d72a2;
box-shadow: #61b8e1 0 1px 0 0 inset;
color: white;
text-shadow: #076794 0 1px 0;
}
.xmodule_display.xmodule_PollBlock div.poll_question .button.reset-button {
clear: both;
float: right;
}

View File

@@ -0,0 +1,32 @@
@import url("https://fonts.googleapis.com/css?family=Open+Sans:300,400,400i,600,700");
.xmodule_display.xmodule_WordCloudBlock {
/* stylelint-disable-line */
/* stylelint-disable-line */
}
.xmodule_display.xmodule_WordCloudBlock .input-cloud {
margin: calc((var(--baseline) / 4));
}
.xmodule_display.xmodule_WordCloudBlock .result_cloud_section {
display: none;
width: 0px;
height: 0px;
}
.xmodule_display.xmodule_WordCloudBlock .result_cloud_section.active {
display: block;
width: 100%;
height: auto;
margin-top: 1em;
}
.xmodule_display.xmodule_WordCloudBlock .result_cloud_section.active h3 {
font-size: 100%;
}
.xmodule_display.xmodule_WordCloudBlock .your_words {
font-size: 0.85em;
display: block;
}

View File

@@ -15,7 +15,7 @@ from xblock.core import XBlock
from xblock.fields import Boolean, Dict, Integer, List, Scope, String
from xmodule.editing_block import EditingMixin
from xmodule.raw_block import EmptyDataRawMixin
from xmodule.util.builtin_assets import add_webpack_js_to_fragment, add_sass_to_fragment
from xmodule.util.builtin_assets import add_webpack_js_to_fragment, add_css_to_fragment
from xmodule.xml_block import XmlMixin
from xmodule.x_module import (
ResourceTemplates,
@@ -262,7 +262,7 @@ class WordCloudBlock( # pylint: disable=abstract-method
'num_inputs': self.num_inputs,
'submitted': self.submitted,
}))
add_sass_to_fragment(fragment, 'WordCloudBlockDisplay.scss')
add_css_to_fragment(fragment, 'WordCloudBlockDisplay.css')
add_webpack_js_to_fragment(fragment, 'WordCloudBlockDisplay')
shim_xmodule_js(fragment, 'WordCloud')