diff --git a/assets/constants.ts b/assets/constants.ts
index d112708d036ee..cdf93f1131b56 100644
--- a/assets/constants.ts
+++ b/assets/constants.ts
@@ -6074,6 +6074,19 @@ export default {
// eslint-disable-next-line max-len
"Refer to Glossary - https://docs.google.com/spreadsheets/d/13NMEnYqLZuMbeX1Z6XXG-femHkKNAN8KwjhaC67EkxI/edit#gid=0"
],
+ // Hinglish.
+ "hi-en": [
+ // eslint-disable-next-line max-len
+ "For mathematical terms in Hinglish, use the English words (e.g. numerator, denominator) instead of their Hindi equivalents (e.g. Bhinn, Ansh).",
+ // eslint-disable-next-line max-len
+ "Use respectful pronouns (like “Aap” instead of “Tum/Tu”) and a corresponding respectful tone like “kariye, karenge”.",
+ // eslint-disable-next-line max-len
+ "Feel free to change the voice and sequence of phrases to make the sentence more readable.",
+ // eslint-disable-next-line max-len
+ "Preserve punctuation and bolding. If the original content has bold text, make sure it is bold in Hinglish as well. If there are bullet points, double quotes, etc., make sure that the translated content also has bullet points and double quotes.",
+ // eslint-disable-next-line max-len
+ "If the original card has “components” (such as pictures, links, and equations), these need to be added to the translated content. You can use the “Copy tool” for this -- click on the Copy tool and then click on the component you want to carry over. Also, double-click on the image and translate the alt text (and caption, if any)."
+ ],
// Spanish.
"es": [
"Include proper punctuation, ¡blank!, ¿question? and accent marks.",
diff --git a/assets/i18n/en.json b/assets/i18n/en.json
index 58ca96d94e51c..f10536b2d1636 100644
--- a/assets/i18n/en.json
+++ b/assets/i18n/en.json
@@ -199,6 +199,12 @@
"I18N_ASSESSMENT_INTERRUPT_SESSION_ENDED": "Your assessment session ended before it was submitted.",
"I18N_ASSESSMENT_INTERRUPT_TITLE": "Assessment Interrupted",
"I18N_ASSESSMENT_START_BUTTON": "Start Assessment",
+ "I18N_ASSESSMENT_UNAVAILABLE_ALERTED_CREATOR_MESSAGE": "We've alerted the creator team about this issue.",
+ "I18N_ASSESSMENT_UNAVAILABLE_GO_TO_CERTIFICATES_BUTTON": "Go to available certificates",
+ "I18N_ASSESSMENT_UNAVAILABLE_MESSAGE": "Sorry, this assessment isn't available.",
+ "I18N_ASSESSMENT_UNAVAILABLE_NOTE_TEXT": "Because this assessment is currently unavailable, you cannot start or submit this assessment.",
+ "I18N_ASSESSMENT_UNAVAILABLE_TITLE": "Assessment Unavailable",
+ "I18N_ASSESSMENT_UNAVAILABLE_TRY_OTHER_MESSAGE": "In the meantime, you can try a different assessment from the available certificates section.",
"I18N_ASSIGNED_STORIES_AND_SKILLS": "Assigned Stories and Skills",
"I18N_ASSIGNED_STORIES_AND_SKILLS_EMPTY_MESSAGE": "No stories or skills have been assigned to learners in this group.",
"I18N_ATTRIBUTION_HTML_STEP_ONE": "Copy and paste the HTML",
@@ -277,6 +283,7 @@
"I18N_CERTIFICATE_ASSESSMENT_CONVERSATION_SKIN_CURRENT_PROGRESS": "Current Progress: <[progressPercentage]>%",
"I18N_CERTIFICATE_ASSESSMENT_CONVERSATION_SKIN_NEXT_BUTTON": "Next",
"I18N_CERTIFICATE_ASSESSMENT_CONVERSATION_SKIN_SUBMIT_ASSESSMENT_BUTTON": "Submit Assessment",
+ "I18N_CERTIFICATE_ASSESSMENT_COOLDOWN_ERROR": "You just started an assessment before this time. Please try again in <[remainingMinutes]> minute(s).",
"I18N_CERTIFICATE_ASSESSMENT_DEMONSTRATES_HEADING": "What this certificate demonstrates",
"I18N_CERTIFICATE_ASSESSMENT_EXIT_BUTTON": "Exit",
"I18N_CERTIFICATE_ASSESSMENT_RESULT_FAILED_HEADING": "Don't give up! You can try again",
@@ -1681,18 +1688,35 @@
"I18N_TOPIC_LANDING_PAGE_TITLE": "<[topicTitle]> | <[topicTagline]> | Oppia",
"I18N_TOPIC_LEARN": "Learn",
"I18N_TOPIC_TITLE": "Topic",
+ "I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_ALL_COMPLETE_SUBTITLE": "You've mastered all available adventures. Keep the momentum going!",
+ "I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_CONTINUE_BUTTON": "Continue to Topic",
+ "I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_MOMENTUM_SUBTITLE": "Keep the momentum going!",
+ "I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_NUMBER_TITLE": "Adventure <[adventureNumber]> Mastered!",
+ "I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_TITLE": "Adventure Mastered!",
+ "I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_UNLOCKED_SUBTITLE": "You've unlocked Adventure <[adventureNumber]>. Keep the momentum going!",
+ "I18N_TOPIC_VIEWER_ADVENTURE_NUMBER_LABEL": "Adventure <[adventureNumber]>",
+ "I18N_TOPIC_VIEWER_ADVENTURE_RESUME_BUTTON": "Resume",
+ "I18N_TOPIC_VIEWER_ADVENTURE_START_BUTTON": "Start",
+ "I18N_TOPIC_VIEWER_ARC_SKIP_CONFIRMATION_MESSAGE": "{count, plural, one{Adventure <[adventureNumbers]> will be marked as skipped, but you can return to it at any time.} other{Adventures <[adventureNumbers]> will be marked as skipped, but you can return to them at any time.}}",
+ "I18N_TOPIC_VIEWER_ARC_SKIP_CONFIRMATION_TITLE": "Skip to <[adventureLabel]>?",
"I18N_TOPIC_VIEWER_CHAPTER": "Chapter",
"I18N_TOPIC_VIEWER_CHAPTERS": "{count, plural, one{1 chapter} other{# chapters}}",
+ "I18N_TOPIC_VIEWER_CLOSE_BUTTON": "Close",
"I18N_TOPIC_VIEWER_COMING_SOON": "Coming Soon!",
- "I18N_TOPIC_VIEWER_COMING_SOON_CHAPTERS": "COMING SOON CHAPTERS",
+ "I18N_TOPIC_VIEWER_COMING_SOON_CHAPTERS_ARIA_LABEL": "Coming soon chapters",
+ "I18N_TOPIC_VIEWER_COMING_SOON_CHAPTERS_HEADING": "COMING SOON CHAPTERS",
"I18N_TOPIC_VIEWER_COMING_SOON_LESSONS": "Come back later when lessons are available for this topic.",
"I18N_TOPIC_VIEWER_COMING_SOON_PRACTICE": "Come back later when practice questions are available for this topic.",
"I18N_TOPIC_VIEWER_LEARN": "Learn",
"I18N_TOPIC_VIEWER_LESSON_COMING_SOON_LABEL": "COMING SOON",
+ "I18N_TOPIC_VIEWER_LESSON_COMPLETED_LABEL": "Completed",
"I18N_TOPIC_VIEWER_LESSON_NEW_LABEL": "NEW",
+ "I18N_TOPIC_VIEWER_LESSON_PLAY_AGAIN_BUTTON": "Play Again",
"I18N_TOPIC_VIEWER_MASTERY_CHALLENGE_BUTTON": "Mastery Challenge",
"I18N_TOPIC_VIEWER_MASTERY_CHALLENGE_DESCRIPTION": "Test your knowledge with a challenge that covers everything you have learned in this topic.",
"I18N_TOPIC_VIEWER_MASTERY_CHALLENGE_TITLE": "Mastery Challenge",
+ "I18N_TOPIC_VIEWER_MASTERY_CHALLENGE_TOOLTIP_DESCRIPTION": "Finish all chapters in order to unlock the Mastery Challenge and prove your mastery of this topic.",
+ "I18N_TOPIC_VIEWER_MASTERY_CHALLENGE_TOOLTIP_TITLE": "Complete all chapters to unlock",
"I18N_TOPIC_VIEWER_MASTER_SKILLS": "Master Skills for <[topicName]>",
"I18N_TOPIC_VIEWER_NAVIGATION_HINT": "Click to navigate through lessons and tests",
"I18N_TOPIC_VIEWER_NO_QUESTION_WARNING": "There are no questions created yet for the selected subtopic(s).",
@@ -1705,6 +1729,9 @@
"I18N_TOPIC_VIEWER_PRACTICE_SESSION_BETA_TAG": "Beta",
"I18N_TOPIC_VIEWER_PRACTICE_TEST_BUTTON": "Practice Test",
"I18N_TOPIC_VIEWER_SELECT_SKILLS": "Select the skills from the <[topicName]> lessons you would like to practice.",
+ "I18N_TOPIC_VIEWER_SKIPPED_ADVENTURE_ARIA_LABEL": "<[adventureTitle]> was skipped. Expand to review.",
+ "I18N_TOPIC_VIEWER_SKIPPED_ADVENTURE_BADGE": "SKIPPED",
+ "I18N_TOPIC_VIEWER_SKIPPED_ADVENTURE_MESSAGE": "This adventure was skipped. You can revisit it anytime.",
"I18N_TOPIC_VIEWER_START_PRACTICE": "Start",
"I18N_TOPIC_VIEWER_STORIES_HEAD": "Stories you can play",
"I18N_TOPIC_VIEWER_STUDY": "Study",
@@ -1767,7 +1794,7 @@
"I18N_TOTAL_SUBSCRIBERS_TEXT": "You have a total of <[totalSubscribers]> subscribers.",
"I18N_UNANSWERED_QUESTION_MODAL_CLOSE_BUTTON": "Close",
"I18N_UNANSWERED_QUESTION_MODAL_GO_BACK_BUTTON_ALT": "Go back to assessment",
- "I18N_UNANSWERED_QUESTION_MODAL_MESSAGE": "You have {{unansweredQuestionCount}} unanswered questions. Unanswered questions will be marked incorrect.",
+ "I18N_UNANSWERED_QUESTION_MODAL_MESSAGE": "You have <[unansweredQuestionCount]> unanswered questions. Unanswered questions will be marked incorrect.",
"I18N_UNANSWERED_QUESTION_MODAL_PRESS_PROMPT_AFTER": "to go back to the assessment or submit anyway.",
"I18N_UNANSWERED_QUESTION_MODAL_PRESS_PROMPT_BEFORE": "Press",
"I18N_UNANSWERED_QUESTION_MODAL_SUBMIT_BUTTON": "Submit Assessment",
diff --git a/assets/i18n/qqq.json b/assets/i18n/qqq.json
index 48a55d2cfce00..2e7af694db15c 100644
--- a/assets/i18n/qqq.json
+++ b/assets/i18n/qqq.json
@@ -199,6 +199,12 @@
"I18N_ASSESSMENT_INTERRUPT_SESSION_ENDED": "Message informing the user that their assessment session ended before it was submitted.",
"I18N_ASSESSMENT_INTERRUPT_TITLE": "Title displayed on the assessment interruption card when a certificate assessment session is interrupted.",
"I18N_ASSESSMENT_START_BUTTON": "Label for the button that starts the assessment.",
+ "I18N_ASSESSMENT_UNAVAILABLE_ALERTED_CREATOR_MESSAGE": "Message informing the user that the creator team has been alerted about the unavailable assessment.",
+ "I18N_ASSESSMENT_UNAVAILABLE_GO_TO_CERTIFICATES_BUTTON": "Text on the button that navigates the user to the available certificates section.",
+ "I18N_ASSESSMENT_UNAVAILABLE_MESSAGE": "Message informing the user that the assessment is not available.",
+ "I18N_ASSESSMENT_UNAVAILABLE_NOTE_TEXT": "Note explaining that the assessment cannot be started or submitted because it is currently unavailable.",
+ "I18N_ASSESSMENT_UNAVAILABLE_TITLE": "Title displayed on the modal shown when a certificate assessment is unavailable.",
+ "I18N_ASSESSMENT_UNAVAILABLE_TRY_OTHER_MESSAGE": "Message encouraging the user to try a different assessment from the available certificates section.",
"I18N_ASSIGNED_STORIES_AND_SKILLS": "Syllabus tab title on the edit learner group page.",
"I18N_ASSIGNED_STORIES_AND_SKILLS_EMPTY_MESSAGE": "Text displayed to user when no skills or stories are assigned to the learner under group syllabus.",
"I18N_ATTRIBUTION_HTML_STEP_ONE": "Step one of attributing Oppia in HTML",
@@ -277,6 +283,7 @@
"I18N_CERTIFICATE_ASSESSMENT_CONVERSATION_SKIN_CURRENT_PROGRESS": "Text showing the learner's progress through the certificate assessment questions.",
"I18N_CERTIFICATE_ASSESSMENT_CONVERSATION_SKIN_NEXT_BUTTON": "Label for the next button in the certificate assessment question view.",
"I18N_CERTIFICATE_ASSESSMENT_CONVERSATION_SKIN_SUBMIT_ASSESSMENT_BUTTON": "Label for the button that submits the certificate assessment.",
+ "I18N_CERTIFICATE_ASSESSMENT_COOLDOWN_ERROR": "Warning message shown when the learner tries to start a new certificate assessment attempt before the cooldown window after their previous attempt has elapsed. <[remainingMinutes]> is replaced with the whole number of minutes they must wait; it is always at least one.",
"I18N_CERTIFICATE_ASSESSMENT_DEMONSTRATES_HEADING": "Heading displayed on the certificate introduction card that explains what knowledge and skills the certificate demonstrates.",
"I18N_CERTIFICATE_ASSESSMENT_EXIT_BUTTON": "Text displayed on the exit button in the certificate assessment header. Clicking the button exits the certificate assessment flow.",
"I18N_CERTIFICATE_ASSESSMENT_RESULT_FAILED_HEADING": "Heading text shown when a learner does not pass the certificate assessment result card.",
@@ -1681,18 +1688,35 @@
"I18N_TOPIC_LANDING_PAGE_TITLE": "Text displayed on the browser's tab when on the topic landing page.",
"I18N_TOPIC_LEARN": "The title of the column in the learner dashboard that allows learner to mark a topic as a goal.\n{{Identical|Learn}}",
"I18N_TOPIC_TITLE": "The title of the column in the learner dashboard that displays topic names.\n\n{{Identical|Topic}}",
+ "I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_ALL_COMPLETE_SUBTITLE": "Subtitle shown in the adventure mastered modal when the learner has completed every available adventure.",
+ "I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_CONTINUE_BUTTON": "Label for the button that closes the adventure mastered modal and returns the learner to the topic page.",
+ "I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_MOMENTUM_SUBTITLE": "Subtitle shown in the adventure mastered modal when no specific adventure was just mastered.",
+ "I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_NUMBER_TITLE": "Title shown in the adventure mastered modal, where <[adventureNumber]> is the 1-based position of the adventure the learner just mastered.",
+ "I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_TITLE": "Title shown in the adventure mastered modal when no specific adventure was just mastered.",
+ "I18N_TOPIC_VIEWER_ADVENTURE_MASTERED_UNLOCKED_SUBTITLE": "Subtitle shown in the adventure mastered modal when mastering the adventure unlocked the next one, where <[adventureNumber]> is the 1-based position of the unlocked adventure.",
+ "I18N_TOPIC_VIEWER_ADVENTURE_NUMBER_LABEL": "Label used to refer to an adventure by its 1-based position, where <[adventureNumber]> is the position of the adventure.",
+ "I18N_TOPIC_VIEWER_ADVENTURE_RESUME_BUTTON": "Label for the button that resumes a previously started skipped adventure.",
+ "I18N_TOPIC_VIEWER_ADVENTURE_START_BUTTON": "Label for the button that starts a skipped adventure that was never started.",
+ "I18N_TOPIC_VIEWER_ARC_SKIP_CONFIRMATION_MESSAGE": "Message shown in the confirmation modal when the learner tries to navigate to a later adventure while skipping earlier unfinished ones, where <[adventureNumbers]> is the human-readable list of skipped adventure positions (for example '2', '1 and 2', or '1, 2, and 3').\n\nOnly translate the words inside the 'one' and 'other' branches (keeping the '<[adventureNumbers]>' placeholder and the '{count, plural, ...}' syntax unchanged).",
+ "I18N_TOPIC_VIEWER_ARC_SKIP_CONFIRMATION_TITLE": "Title shown in the confirmation modal when the learner tries to skip to a later adventure, where <[adventureLabel]> is the label of the target adventure (for example 'Adventure 3').",
"I18N_TOPIC_VIEWER_CHAPTER": "Text to be displayed on each story summary tile in the topic viewer indicating the chapter count in the story.",
"I18N_TOPIC_VIEWER_CHAPTERS": "Text to be displayed on each topic summary tile with singular/plural adjustments.\n\nOnly translate \"1 chapter\" and \"# chapters\" (keep a \"#\" as the placeholder for the actual replaced number). The rest should remain as is, including curly braces (\"{\", \"}\"), plural keywords \"count\", \"one\" or \"other\" (in English), and comma separators (\",\").\n\nIn languages that don't need to mark different forms for the plural, you may just keep the \"other\" option (used as the default value for the result of the \"{count...}\" expression), and then translate just \"# chapters\".",
+ "I18N_TOPIC_VIEWER_CLOSE_BUTTON": "ARIA label for the close button that dismisses a dialog.\n{{Identical|Close}}",
"I18N_TOPIC_VIEWER_COMING_SOON": "Text displayed as a heading when no content is available to show in respective tabs.",
- "I18N_TOPIC_VIEWER_COMING_SOON_CHAPTERS": "Heading displayed above the list of chapters that are not yet published and cannot be accessed by the learner.",
+ "I18N_TOPIC_VIEWER_COMING_SOON_CHAPTERS_ARIA_LABEL": "ARIA label for the section listing chapters that are not yet available.",
+ "I18N_TOPIC_VIEWER_COMING_SOON_CHAPTERS_HEADING": "Heading shown above the list of chapters that are not yet available.",
"I18N_TOPIC_VIEWER_COMING_SOON_LESSONS": "Text displayed as a descriptive message when no content is available to show in lessons tab.",
"I18N_TOPIC_VIEWER_COMING_SOON_PRACTICE": "Text displayed as a descriptive message when no content is available to show in Practice tab.",
"I18N_TOPIC_VIEWER_LEARN": "Title shown in a tab header in the topic's viewer page. - Clicking on this shows a list of the lessons that lie within the topic",
"I18N_TOPIC_VIEWER_LESSON_COMING_SOON_LABEL": "Label badge displayed on lesson cards that are coming soon.",
+ "I18N_TOPIC_VIEWER_LESSON_COMPLETED_LABEL": "Label badge displayed on lesson cards that have been completed.",
"I18N_TOPIC_VIEWER_LESSON_NEW_LABEL": "Label badge displayed on new lesson cards.",
+ "I18N_TOPIC_VIEWER_LESSON_PLAY_AGAIN_BUTTON": "Label for the button that lets a learner replay a completed lesson.",
"I18N_TOPIC_VIEWER_MASTERY_CHALLENGE_BUTTON": "Label for the mastery challenge button that navigates to the challenge.",
"I18N_TOPIC_VIEWER_MASTERY_CHALLENGE_DESCRIPTION": "Description for the mastery challenge card explaining what the challenge covers.",
"I18N_TOPIC_VIEWER_MASTERY_CHALLENGE_TITLE": "Title for the mastery challenge card at the end of a story section.",
+ "I18N_TOPIC_VIEWER_MASTERY_CHALLENGE_TOOLTIP_DESCRIPTION": "Description text for helper tooltip shown when the learner clicks mastery challenge before completing all chapters.",
+ "I18N_TOPIC_VIEWER_MASTERY_CHALLENGE_TOOLTIP_TITLE": "Heading text for helper tooltip shown when mastery challenge is still locked.",
"I18N_TOPIC_VIEWER_MASTER_SKILLS": "Title shown in the 'Practice' tab in the topic viewer, where the user can select skills to tackle questions on them.",
"I18N_TOPIC_VIEWER_NAVIGATION_HINT": "Hint text below the adventure navigation to guide users on how to navigate.",
"I18N_TOPIC_VIEWER_NO_QUESTION_WARNING": "Text displayed below the start button in 'practice' tab, when user select a subtopic which does not contain any questions attached to it.",
@@ -1705,6 +1729,9 @@
"I18N_TOPIC_VIEWER_PRACTICE_SESSION_BETA_TAG": "Text enclosed in parentheses meant to indicate that a practice session is in beta",
"I18N_TOPIC_VIEWER_PRACTICE_TEST_BUTTON": "Label for the practice test button in the adventure end test card.",
"I18N_TOPIC_VIEWER_SELECT_SKILLS": "Subtitle shown in the 'Practice' tab in the topic viewer, where the user can select skills to tackle questions on them.",
+ "I18N_TOPIC_VIEWER_SKIPPED_ADVENTURE_ARIA_LABEL": "ARIA label for a card representing an adventure the learner skipped, where <[adventureTitle]> is the title of the skipped adventure.",
+ "I18N_TOPIC_VIEWER_SKIPPED_ADVENTURE_BADGE": "Badge text displayed on cards of adventures that the learner skipped.",
+ "I18N_TOPIC_VIEWER_SKIPPED_ADVENTURE_MESSAGE": "Message displayed on cards of adventures that the learner skipped, reassuring them that they can revisit the adventure later.",
"I18N_TOPIC_VIEWER_START_PRACTICE": "Text displayed on the start button in the 'Practice' tab in the topic viewer. Clicking this, the user can start a practice session on skills selected by the user.\n{{Identical|Start}}",
"I18N_TOPIC_VIEWER_STORIES_HEAD": "Text displayed above all stories specifically in mobile view.",
"I18N_TOPIC_VIEWER_STUDY": "Title shown in a tab header in the topic's viewer page. - Clicking on this shows a list of the study guides that lie within the topic",
diff --git a/core/controllers/access_validators.py b/core/controllers/access_validators.py
index b3aedf43d35bd..4c6ea3983a8f2 100644
--- a/core/controllers/access_validators.py
+++ b/core/controllers/access_validators.py
@@ -421,19 +421,19 @@ def _validate_node_id(
) -> None:
"""Validates that the given node ID exists in the first story.
- The node_id parameter is a 1-based index that maps to the nth node
- in the first published story of the topic.
+ The node_id parameter maps to a node by its ID suffix (e.g., '1'
+ maps to the node whose id is 'node_1').
Args:
topic: Topic. The topic object.
- node_id: str. The node ID (1-based index) to validate.
+ node_id: str. The node ID suffix (e.g., '1') to validate.
Raises:
NotFoundException. The node ID was not found.
"""
all_nodes = self._get_all_nodes_for_topic(topic)
- valid_indices = {node.id.replace('node_', '') for node in all_nodes}
- if node_id not in valid_indices:
+ target_node_id = 'node_%s' % node_id
+ if not any(node.id == target_node_id for node in all_nodes):
raise self.NotFoundException(
'Node with id %s is not part of this topic.' % node_id
)
@@ -441,19 +441,23 @@ def _validate_node_id(
def _validate_arc_id(self, topic: topic_domain.Topic, arc_id: str) -> None:
"""Validates that the given arc ID exists in the first story.
- The arc_id parameter is a 1-based index that maps to the nth arc
- in the first published story of the topic.
+ The arc_id parameter is a 1-based index that maps to the nth arc in
+ the first published story of the topic (e.g., '1' maps to the first
+ arc).
Args:
topic: Topic. The topic object.
- arc_id: str. The arc index (e.g., '1') to validate.
+ arc_id: str. The arc ID (1-based index) to validate.
Raises:
NotFoundException. The arc ID was not found.
"""
all_arcs = self._get_all_arcs_for_topic(topic)
- valid_indices = {arc.id.replace('arc_', '') for arc in all_arcs}
- if arc_id not in valid_indices:
+ if (
+ not arc_id.isascii()
+ or not arc_id.isdigit()
+ or not 1 <= int(arc_id) <= len(all_arcs)
+ ):
raise self.NotFoundException(
'Arc with id %s is not part of this topic.' % arc_id
)
diff --git a/core/controllers/access_validators_test.py b/core/controllers/access_validators_test.py
index fefa8096ff1a1..d60f02d5283a6 100644
--- a/core/controllers/access_validators_test.py
+++ b/core/controllers/access_validators_test.py
@@ -668,6 +668,97 @@ def test_any_user_can_access_end_of_arc_page(self) -> None:
expected_status_int=200,
)
+ def test_any_user_can_access_end_of_arc_page_with_arc_position(
+ self,
+ ) -> None:
+ story_id = 'story_id_2'
+ exp_id = 'exp_2'
+ self.save_new_valid_exploration(exp_id, self.admin_id)
+ self.publish_exploration(self.admin_id, exp_id)
+ self.save_new_story(story_id, self.admin_id, self.topic_id)
+ topic_services.add_canonical_story(
+ self.admin_id, self.topic_id, story_id
+ )
+ story_services.update_story(
+ self.admin_id,
+ story_id,
+ [
+ story_domain.StoryChange(
+ {
+ 'cmd': story_domain.CMD_ADD_STORY_NODE,
+ 'node_id': 'node_1',
+ 'title': 'Chapter 1',
+ }
+ ),
+ story_domain.StoryChange(
+ {
+ 'cmd': story_domain.CMD_UPDATE_STORY_NODE_PROPERTY,
+ 'property_name': (
+ story_domain.STORY_NODE_PROPERTY_EXPLORATION_ID
+ ),
+ 'node_id': 'node_1',
+ 'old_value': None,
+ 'new_value': exp_id,
+ }
+ ),
+ ],
+ 'Added node.',
+ )
+ topic_services.publish_story(self.topic_id, story_id, self.admin_id)
+ story_services.update_story(
+ self.admin_id,
+ story_id,
+ [
+ story_domain.StoryChange(
+ {
+ 'cmd': story_domain.CMD_UPDATE_STORY_NODE_PROPERTY,
+ 'property_name': (
+ story_domain.STORY_NODE_PROPERTY_ACQUIRED_SKILL_IDS
+ ),
+ 'node_id': 'node_1',
+ # Here we use cast because the empty list's type cannot
+ # be inferred, and List[str] is needed to match
+ # AcceptableChangeDictTypes.
+ 'old_value': cast(List[str], []),
+ 'new_value': [self.skill_id1],
+ }
+ ),
+ story_domain.StoryChange(
+ {
+ 'cmd': story_domain.CMD_CREATE_ARC,
+ 'arc_id': 'default_arc',
+ 'title': 'Default Arc',
+ 'description': 'Default arc',
+ 'node_ids': ['node_1'],
+ }
+ ),
+ ],
+ 'Added acquired skill IDs and arc.',
+ )
+
+ self.get_html_response(
+ '%s/can_access_practice_session_page/%s/%s/test/arc/%s'
+ % (
+ ACCESS_VALIDATION_HANDLER_PREFIX,
+ 'math',
+ 'public-topic-name',
+ '1',
+ ),
+ expected_status_int=200,
+ )
+
+ def test_end_of_arc_page_with_out_of_range_arc_position(self) -> None:
+ self.get_html_response(
+ '%s/can_access_practice_session_page/%s/%s/test/arc/%s'
+ % (
+ ACCESS_VALIDATION_HANDLER_PREFIX,
+ 'math',
+ 'public-topic-name',
+ '999',
+ ),
+ expected_status_int=404,
+ )
+
def test_lesson_practice_with_invalid_node_id(self) -> None:
self.get_html_response(
'%s/can_access_practice_session_page/%s/%s/practice/%s'
@@ -692,6 +783,30 @@ def test_end_of_arc_page_with_invalid_arc_id(self) -> None:
expected_status_int=404,
)
+ def test_end_of_arc_page_with_non_ascii_arc_id(self) -> None:
+ self.get_html_response(
+ '%s/can_access_practice_session_page/%s/%s/test/arc/%s'
+ % (
+ ACCESS_VALIDATION_HANDLER_PREFIX,
+ 'math',
+ 'public-topic-name',
+ '%D9%A1',
+ ),
+ expected_status_int=404,
+ )
+
+ def test_end_of_arc_page_with_emoji_arc_id(self) -> None:
+ self.get_html_response(
+ '%s/can_access_practice_session_page/%s/%s/test/arc/%s'
+ % (
+ ACCESS_VALIDATION_HANDLER_PREFIX,
+ 'math',
+ 'public-topic-name',
+ '%F0%9F%98%80',
+ ),
+ expected_status_int=404,
+ )
+
def test_lesson_practice_with_story_that_does_not_exist(self) -> None:
story_id = 'story_id'
deleted_story_id = 'del_story'
diff --git a/core/controllers/certificate_assessment.py b/core/controllers/certificate_assessment.py
index b25b5ad0cdffc..fc05f08a08159 100644
--- a/core/controllers/certificate_assessment.py
+++ b/core/controllers/certificate_assessment.py
@@ -456,6 +456,21 @@ def post(self) -> None:
self.normalized_payload['certificate_id'], self.user_id
)
)
+ except (
+ certificate_assessment_services.CertificateAssessmentAttemptCooldownException
+ ) as e:
+ # Surface a structured error so the frontend can translate it via
+ # its translate pipes, instead of a hardcoded English string.
+ self.error(429)
+ self.render_json(
+ {
+ 'error': ('I18N_CERTIFICATE_ASSESSMENT_COOLDOWN_ERROR'),
+ 'error_type': 'cooldown',
+ 'remaining_minutes': e.remaining_minutes,
+ 'status_code': 429,
+ }
+ )
+ return
except utils.ValidationError as e:
raise self.InvalidInputException(e) from e
except (
diff --git a/core/controllers/certificate_assessment_test.py b/core/controllers/certificate_assessment_test.py
index fc8b5c4fd631e..beaca1ea2925a 100644
--- a/core/controllers/certificate_assessment_test.py
+++ b/core/controllers/certificate_assessment_test.py
@@ -949,6 +949,41 @@ def test_post_raises_invalid_input_on_invalidated_offering(self) -> None:
):
handler.post()
+ def test_post_returns_cooldown_error_with_remaining_minutes(self) -> None:
+ handler = (
+ certificate_assessment.StartCertificateAssessmentHandler.__new__(
+ certificate_assessment.StartCertificateAssessmentHandler
+ )
+ )
+ handler.user_id = 'user_id_1'
+ handler.normalized_payload = {'certificate_id': 'cert_1'}
+ with mock.patch.object(
+ certificate_assessment_services,
+ 'start_certificate_assessment_attempt',
+ side_effect=(
+ certificate_assessment_services.CertificateAssessmentAttemptCooldownException(
+ 6
+ )
+ ),
+ ), mock.patch.object(
+ certificate_assessment.StartCertificateAssessmentHandler,
+ 'error',
+ ) as error_mock, mock.patch.object(
+ certificate_assessment.StartCertificateAssessmentHandler,
+ 'render_json',
+ ) as render_json_mock:
+ handler.post()
+
+ error_mock.assert_called_once_with(429)
+ render_json_mock.assert_called_once_with(
+ {
+ 'error': 'I18N_CERTIFICATE_ASSESSMENT_COOLDOWN_ERROR',
+ 'error_type': 'cooldown',
+ 'remaining_minutes': 6,
+ 'status_code': 429,
+ }
+ )
+
class SubmitCertificateAssessmentHandlerUnitTests(test_utils.GenericTestBase):
"""Tests for the submit certificate assessment handler."""
diff --git a/core/controllers/concept_card_viewer.py b/core/controllers/concept_card_viewer.py
index 5edfca5dc4fc7..bc7a66dbf3b3b 100644
--- a/core/controllers/concept_card_viewer.py
+++ b/core/controllers/concept_card_viewer.py
@@ -18,12 +18,24 @@
from core import feconf
from core.controllers import acl_decorators, base
-from core.domain import skill_fetchers
+from core.domain import skill_fetchers, skill_services
-from typing import Dict, List
+from typing import Dict, List, Optional, TypedDict
-class ConceptCardDataHandler(base.BaseHandler[Dict[str, str], Dict[str, str]]):
+class ConceptCardDataHandlerNormalizedRequestDict(TypedDict):
+ """Dict representation of ConceptCardDataHandler's GET normalized
+ request dictionary.
+ """
+
+ language_code: Optional[str]
+
+
+class ConceptCardDataHandler(
+ base.BaseHandler[
+ Dict[str, str], ConceptCardDataHandlerNormalizedRequestDict
+ ]
+):
"""A card that shows the explanation of a skill's concept."""
GET_HANDLER_ERROR_RETURN_TYPE = feconf.HANDLER_TYPE_JSON
@@ -32,7 +44,17 @@ class ConceptCardDataHandler(base.BaseHandler[Dict[str, str], Dict[str, str]]):
'schema': {'type': 'custom', 'obj_type': 'JsonEncodedInString'}
}
}
- HANDLER_ARGS_SCHEMAS: Dict[str, Dict[str, str]] = {'GET': {}}
+ HANDLER_ARGS_SCHEMAS = {
+ 'GET': {
+ 'language_code': {
+ 'schema': {
+ 'type': 'basestring',
+ 'validators': [{'id': 'is_supported_audio_language_code'}],
+ },
+ 'default_value': None,
+ }
+ }
+ }
@acl_decorators.can_view_skills
def get(self, selected_skill_ids: List[str]) -> None:
@@ -41,12 +63,14 @@ def get(self, selected_skill_ids: List[str]) -> None:
Args:
selected_skill_ids: list(str). List of skill ids.
"""
+ assert self.normalized_request is not None
+ language_code = self.normalized_request.get('language_code')
skills = skill_fetchers.get_multi_skills(selected_skill_ids)
- concept_card_dicts = []
- for skill in skills:
- concept_card_dicts.append(skill.skill_contents.to_dict())
+ concept_card_dicts = skill_services.get_concept_card_dicts(
+ skills, language_code
+ )
self.values.update({'concept_card_dicts': concept_card_dicts})
diff --git a/core/controllers/concept_card_viewer_test.py b/core/controllers/concept_card_viewer_test.py
index 9d70c25ae7122..4de79d9da4fc0 100644
--- a/core/controllers/concept_card_viewer_test.py
+++ b/core/controllers/concept_card_viewer_test.py
@@ -21,9 +21,11 @@
from core import feconf
from core.domain import (
skill_domain,
+ skill_fetchers,
skill_services,
state_domain,
translation_domain,
+ translation_services,
user_services,
)
from core.tests import test_utils
@@ -128,6 +130,169 @@ def test_get_concept_cards(self) -> None:
json_response['concept_card_dicts'][1]['explanation']['html'],
)
+ def _add_explanation_translation(
+ self,
+ skill_id: str,
+ language_code: str,
+ translation_html: str,
+ needs_update: bool = False,
+ ) -> None:
+ """Adds a translation of the skill's explanation.
+
+ Args:
+ skill_id: str. The ID of the skill to translate.
+ language_code: str. The language to translate into.
+ translation_html: str. The translated explanation.
+ needs_update: bool. Whether the translation is stale.
+ """
+ skill = skill_fetchers.get_skill_by_id(skill_id)
+ translation_services.add_new_translation(
+ feconf.TranslatableEntityType.SKILL,
+ skill_id,
+ skill.version,
+ language_code,
+ skill.skill_contents.explanation.content_id,
+ translation_domain.TranslatedContent(
+ translation_html,
+ translation_domain.TranslatableContentFormat.HTML,
+ needs_update,
+ ),
+ )
+
+ def test_get_concept_cards_in_a_translated_language(self) -> None:
+ self._add_explanation_translation(
+ self.skill_id, 'es', '
Explicación de la habilidad
'
+ )
+
+ json_response = self.get_json(
+ '%s/%s'
+ % (
+ feconf.CONCEPT_CARD_DATA_URL_PREFIX,
+ json.dumps([self.skill_id, self.skill_id_1]),
+ ),
+ params={'language_code': 'es'},
+ )
+
+ self.assertEqual(
+ 'Explicación de la habilidad
',
+ json_response['concept_card_dicts'][0]['explanation']['html'],
+ )
+ # The second skill has no translation, so it stays in English.
+ self.assertEqual(
+ 'Skill Explanation 1
',
+ json_response['concept_card_dicts'][1]['explanation']['html'],
+ )
+
+ def test_get_concept_cards_translates_the_skill_description(self) -> None:
+ skill = skill_fetchers.get_skill_by_id(self.skill_id)
+ translation_services.add_new_translation(
+ feconf.TranslatableEntityType.SKILL,
+ self.skill_id,
+ skill.version,
+ 'es',
+ feconf.SKILL_DESCRIPTION_CONTENT_ID,
+ translation_domain.TranslatedContent(
+ 'Descripcion de la habilidad',
+ translation_domain.TranslatableContentFormat.UNICODE_STRING,
+ False,
+ ),
+ )
+
+ json_response = self.get_json(
+ '%s/%s'
+ % (
+ feconf.CONCEPT_CARD_DATA_URL_PREFIX,
+ json.dumps([self.skill_id, self.skill_id_1]),
+ ),
+ params={'language_code': 'es'},
+ )
+
+ self.assertEqual(
+ 'Descripcion de la habilidad',
+ json_response['concept_card_dicts'][0]['skill_description'],
+ )
+ # The second skill has no translated description, so it stays in
+ # English.
+ self.assertEqual(
+ skill_fetchers.get_skill_by_id(self.skill_id_1).description,
+ json_response['concept_card_dicts'][1]['skill_description'],
+ )
+
+ def test_get_concept_cards_falls_back_to_english_when_untranslated(
+ self,
+ ) -> None:
+ json_response = self.get_json(
+ '%s/%s'
+ % (
+ feconf.CONCEPT_CARD_DATA_URL_PREFIX,
+ json.dumps([self.skill_id]),
+ ),
+ params={'language_code': 'es'},
+ )
+
+ self.assertEqual(
+ 'Skill Explanation
',
+ json_response['concept_card_dicts'][0]['explanation']['html'],
+ )
+
+ def test_get_concept_cards_ignores_a_stale_translation(self) -> None:
+ self._add_explanation_translation(
+ self.skill_id,
+ 'es',
+ 'Explicación desactualizada
',
+ needs_update=True,
+ )
+
+ json_response = self.get_json(
+ '%s/%s'
+ % (
+ feconf.CONCEPT_CARD_DATA_URL_PREFIX,
+ json.dumps([self.skill_id]),
+ ),
+ params={'language_code': 'es'},
+ )
+
+ # A translation flagged as needing an update is out of date with the
+ # English content, so English is shown instead.
+ self.assertEqual(
+ 'Skill Explanation
',
+ json_response['concept_card_dicts'][0]['explanation']['html'],
+ )
+
+ def test_get_concept_cards_in_english_returns_original_content(
+ self,
+ ) -> None:
+ self._add_explanation_translation(
+ self.skill_id, 'es', 'Explicación de la habilidad
'
+ )
+
+ json_response = self.get_json(
+ '%s/%s'
+ % (
+ feconf.CONCEPT_CARD_DATA_URL_PREFIX,
+ json.dumps([self.skill_id]),
+ ),
+ params={'language_code': 'en'},
+ )
+
+ self.assertEqual(
+ 'Skill Explanation
',
+ json_response['concept_card_dicts'][0]['explanation']['html'],
+ )
+
+ def test_get_concept_cards_with_invalid_language_code(self) -> None:
+ json_response = self.get_json(
+ '%s/%s'
+ % (
+ feconf.CONCEPT_CARD_DATA_URL_PREFIX,
+ json.dumps([self.skill_id]),
+ ),
+ params={'language_code': 'invalid_language_code'},
+ expected_status_int=400,
+ )
+
+ self.assertIn('language_code', json_response['error'])
+
def test_get_concept_cards_fails_when_skill_doesnt_exist(self) -> None:
self.get_json(
'%s/%s'
diff --git a/core/controllers/library.py b/core/controllers/library.py
index c430a7b7af2c2..938f8ebf3c4e0 100644
--- a/core/controllers/library.py
+++ b/core/controllers/library.py
@@ -35,12 +35,26 @@
summary_services.DisplayableCollectionSummaryDict,
]
+# Schema for the language the returned exploration metadata is displayed in.
+# This is the learner's site language, so it is restricted to the languages the
+# site itself is available in.
+DISPLAY_IN_LANGUAGE_CODE_SCHEMA = {
+ 'schema': {
+ 'type': 'basestring',
+ 'choices': [
+ language['id'] for language in constants.SUPPORTED_SITE_LANGUAGES
+ ],
+ },
+ 'default_value': None,
+}
+
def get_matching_activity_dicts(
query_string: str,
categories: List[str],
language_codes: List[str],
search_offset: Optional[int],
+ display_in_language_code: Optional[str] = None,
) -> Tuple[Sequence[UnionSummaryDictType], Optional[int]]:
"""Given the details of a query and a search offset, returns a list of
activity dicts that satisfy the query.
@@ -60,6 +74,9 @@ def get_matching_activity_dicts(
exploration search results, to start the search from. If None,
collection search results are returned first before the
explorations.
+ display_in_language_code: str or None. The language code to display
+ translated exploration metadata in. If None, the original metadata
+ is returned.
Returns:
tuple. A tuple consisting of two elements:
@@ -95,7 +112,7 @@ def get_matching_activity_dicts(
for (
exp_summary_dict
) in summary_services.get_displayable_exp_summary_dicts_matching_ids( # pylint: disable=line-too-long
- exp_ids
+ exp_ids, display_in_language_code=display_in_language_code
):
activity_list.append(exp_summary_dict)
@@ -120,29 +137,50 @@ def get(self) -> None:
self.redirect(feconf.LIBRARY_INDEX_URL, permanent=True)
-class LibraryIndexHandler(base.BaseHandler[Dict[str, str], Dict[str, str]]):
+class LibraryIndexHandlerNormalizedRequestDict(TypedDict):
+ """Dict representation of LibraryIndexHandler's
+ normalized_request dictionary.
+ """
+
+ display_in_language_code: Optional[str]
+
+
+class LibraryIndexHandler(
+ base.BaseHandler[Dict[str, str], LibraryIndexHandlerNormalizedRequestDict]
+):
"""Provides data for the default library index page."""
GET_HANDLER_ERROR_RETURN_TYPE = feconf.HANDLER_TYPE_JSON
URL_PATH_ARGS_SCHEMAS: Dict[str, str] = {}
- HANDLER_ARGS_SCHEMAS: Dict[str, Dict[str, str]] = {'GET': {}}
+ HANDLER_ARGS_SCHEMAS = {
+ 'GET': {
+ 'display_in_language_code': DISPLAY_IN_LANGUAGE_CODE_SCHEMA,
+ }
+ }
@acl_decorators.open_access
def get(self) -> None:
"""Handles GET requests."""
+ assert self.normalized_request is not None
+ display_in_language_code = self.normalized_request.get(
+ 'display_in_language_code'
+ )
# TODO(sll): Support index pages for other language codes.
summary_dicts_by_category = summary_services.get_library_groups(
- [constants.DEFAULT_LANGUAGE_CODE]
+ [constants.DEFAULT_LANGUAGE_CODE],
+ display_in_language_code=display_in_language_code,
)
top_rated_activity_summary_dicts = (
summary_services.get_top_rated_exploration_summary_dicts(
[constants.DEFAULT_LANGUAGE_CODE],
feconf.NUMBER_OF_TOP_RATED_EXPLORATIONS_FOR_LIBRARY_PAGE,
+ display_in_language_code=display_in_language_code,
)
)
featured_activity_summary_dicts = (
summary_services.get_featured_activity_summary_dicts(
- [constants.DEFAULT_LANGUAGE_CODE]
+ [constants.DEFAULT_LANGUAGE_CODE],
+ display_in_language_code=display_in_language_code,
)
)
@@ -199,6 +237,7 @@ class LibraryGroupIndexHandlerNormalizedRequestDict(TypedDict):
"""
group_name: str
+ display_in_language_code: Optional[str]
class LibraryGroupIndexHandler(
@@ -220,7 +259,8 @@ class LibraryGroupIndexHandler(
feconf.LIBRARY_GROUP_TOP_RATED,
],
}
- }
+ },
+ 'display_in_language_code': DISPLAY_IN_LANGUAGE_CODE_SCHEMA,
}
}
@@ -230,13 +270,17 @@ def get(self) -> None:
# TODO(sll): Support index pages for other language codes.
assert self.normalized_request is not None
group_name = self.normalized_request['group_name']
+ display_in_language_code = self.normalized_request.get(
+ 'display_in_language_code'
+ )
activity_list = []
header_i18n_id = ''
if group_name == feconf.LIBRARY_GROUP_RECENTLY_PUBLISHED:
recently_published_summary_dicts = (
summary_services.get_recently_published_exp_summary_dicts(
- feconf.RECENTLY_PUBLISHED_QUERY_LIMIT_FULL_PAGE
+ feconf.RECENTLY_PUBLISHED_QUERY_LIMIT_FULL_PAGE,
+ display_in_language_code=display_in_language_code,
)
)
if recently_published_summary_dicts:
@@ -248,6 +292,7 @@ def get(self) -> None:
summary_services.get_top_rated_exploration_summary_dicts(
[constants.DEFAULT_LANGUAGE_CODE],
feconf.NUMBER_OF_TOP_RATED_EXPLORATIONS_FULL_PAGE,
+ display_in_language_code=display_in_language_code,
)
)
if top_rated_activity_summary_dicts:
@@ -278,6 +323,7 @@ class SearchHandlerNormalizedRequestDict(TypedDict):
category: str
language_code: str
offset: Optional[int]
+ display_in_language_code: Optional[str]
class SearchHandler(
@@ -317,6 +363,11 @@ class SearchHandler(
'default_value': '',
},
'offset': {'schema': {'type': 'int'}, 'default_value': None},
+ # This is the language the results are displayed in, which is the
+ # learner's site language. It is separate from language_code above,
+ # which filters which activities are returned by their own
+ # language.
+ 'display_in_language_code': DISPLAY_IN_LANGUAGE_CODE_SCHEMA,
}
}
@@ -344,9 +395,16 @@ def get(self) -> None:
)
search_offset = self.normalized_request.get('offset')
+ display_in_language_code = self.normalized_request.get(
+ 'display_in_language_code'
+ )
activity_list, new_search_offset = get_matching_activity_dicts(
- query_string, categories, language_codes, search_offset
+ query_string,
+ categories,
+ language_codes,
+ search_offset,
+ display_in_language_code=display_in_language_code,
)
self.values.update(
@@ -376,8 +434,9 @@ class ExplorationSummariesHandlerNormalizedRequestDict(TypedDict):
normalized_request dictionary.
"""
- stringified_exp_ids: str
+ stringified_exp_ids: List[str]
include_private_explorations: Optional[bool]
+ display_in_language_code: Optional[str]
class ExplorationSummariesHandler(
@@ -400,6 +459,7 @@ class ExplorationSummariesHandler(
'schema': {'type': 'bool'},
'default_value': False,
},
+ 'display_in_language_code': DISPLAY_IN_LANGUAGE_CODE_SCHEMA,
}
}
@@ -411,6 +471,9 @@ def get(self) -> None:
include_private_exps = self.normalized_request.get(
'include_private_explorations'
)
+ display_in_language_code = self.normalized_request.get(
+ 'display_in_language_code'
+ )
editor_user_id = self.user_id if include_private_exps else None
if not editor_user_id:
@@ -424,13 +487,16 @@ def get(self) -> None:
if include_private_exps:
summaries = (
summary_services.get_displayable_exp_summary_dicts_matching_ids(
- exp_ids, user=self.user
+ exp_ids,
+ user=self.user,
+ display_in_language_code=display_in_language_code,
)
)
else:
summaries = (
summary_services.get_displayable_exp_summary_dicts_matching_ids(
- exp_ids
+ exp_ids,
+ display_in_language_code=display_in_language_code,
)
)
self.values.update({'summaries': summaries})
diff --git a/core/controllers/library_test.py b/core/controllers/library_test.py
index cddb2f024491e..063018b65463f 100644
--- a/core/controllers/library_test.py
+++ b/core/controllers/library_test.py
@@ -34,6 +34,8 @@
rights_domain,
rights_manager,
summary_services,
+ translation_domain,
+ translation_services,
user_services,
)
from core.platform import models
@@ -762,6 +764,43 @@ def test_handler_for_recently_published_library_group_page(self) -> None:
response_dict['activity_list'][0],
)
+ def test_library_group_index_handler_with_display_in_language_code(
+ self,
+ ) -> None:
+ """Test library group index handler with display_in_language_code."""
+ exp_services.load_demo('0')
+ exp_summary = exp_fetchers.get_exploration_summary_by_id('0')
+
+ translated_title = translation_domain.TranslatedContent(
+ 'Hindi Welcome Title',
+ translation_domain.TranslatableContentFormat.UNICODE_STRING,
+ needs_update=False,
+ )
+ translation_services.add_new_translation(
+ feconf.TranslatableEntityType.EXPLORATION,
+ '0',
+ exp_summary.version,
+ 'hi',
+ feconf.EXPLORATION_TITLE_CONTENT_ID,
+ translated_title,
+ )
+
+ response_dict = self.get_json(
+ feconf.LIBRARY_GROUP_DATA_URL,
+ params={
+ 'group_name': feconf.LIBRARY_GROUP_RECENTLY_PUBLISHED,
+ 'display_in_language_code': 'hi',
+ },
+ )
+ self.assertEqual(len(response_dict['activity_list']), 1)
+ self.assertEqual(
+ response_dict['activity_list'][0]['title'], 'Hindi Welcome Title'
+ )
+ self.assertEqual(
+ response_dict['activity_list'][0]['translated_metadata_fields'],
+ ['title'],
+ )
+
def test_handler_for_top_rated_library_group_page(self) -> None:
"""Test library handler for top rated group page."""
@@ -946,6 +985,41 @@ def test_can_get_public_exploration_summaries(self) -> None:
self.logout()
+ def test_can_get_translated_exploration_summaries(self) -> None:
+ self.login(self.VIEWER_EMAIL)
+
+ exp_summary = exp_fetchers.get_exploration_summary_by_id(
+ self.PUBLIC_EXP_ID_EDITOR
+ )
+ translated_title = translation_domain.TranslatedContent(
+ 'Public Exploration Hindi Title',
+ translation_domain.TranslatableContentFormat.UNICODE_STRING,
+ needs_update=False,
+ )
+ translation_services.add_new_translation(
+ feconf.TranslatableEntityType.EXPLORATION,
+ self.PUBLIC_EXP_ID_EDITOR,
+ exp_summary.version,
+ 'hi',
+ feconf.EXPLORATION_TITLE_CONTENT_ID,
+ translated_title,
+ )
+
+ response_dict = self.get_json(
+ feconf.EXPLORATION_SUMMARIES_DATA_URL,
+ params={
+ 'stringified_exp_ids': json.dumps([self.PUBLIC_EXP_ID_EDITOR]),
+ 'display_in_language_code': 'hi',
+ },
+ )
+ summaries = response_dict['summaries']
+ self.assertEqual(len(summaries), 1)
+ self.assertEqual(
+ summaries[0]['title'], 'Public Exploration Hindi Title'
+ )
+
+ self.logout()
+
def test_can_get_editable_private_exploration_summaries(self) -> None:
self.login(self.VIEWER_EMAIL)
diff --git a/core/controllers/practice_sessions.py b/core/controllers/practice_sessions.py
index 61c42ae0f153b..e4ab5497db4bb 100644
--- a/core/controllers/practice_sessions.py
+++ b/core/controllers/practice_sessions.py
@@ -27,7 +27,7 @@
topic_fetchers,
)
-from typing import Dict, List, Optional, TypedDict
+from typing import Dict, List, Optional, Tuple, TypedDict
class PracticeSessionsPageDataHandlerNormalizedRequestDict(TypedDict):
@@ -153,26 +153,50 @@ def _get_skill_ids_for_node(
return node.acquired_skill_ids
return []
+ def _get_story_and_arc_for_arc_id(
+ self, topic: topic_domain.Topic, arc_id: str
+ ) -> Optional[Tuple[story_domain.Story, story_domain.Arc]]:
+ """Returns the story-arc pair matching the given arc ID.
+
+ The arc_id parameter is a 1-based index that maps to the nth arc in
+ the first published story of the topic (e.g., '1' maps to the first
+ arc).
+
+ Args:
+ topic: Topic. The topic object.
+ arc_id: str. The arc ID (1-based index).
+
+ Returns:
+ tuple(Story, Arc) or None. The matching story-arc pair, or None
+ if no matching arc is found.
+ """
+ arcs_with_stories = story_fetchers.get_all_arcs_with_stories_for_topic(
+ topic
+ )
+ if arc_id.isascii() and arc_id.isdigit():
+ arc_index = int(arc_id)
+ if 1 <= arc_index <= len(arcs_with_stories):
+ return arcs_with_stories[arc_index - 1]
+ return None
+
def _get_skill_ids_for_arc(
self, topic: topic_domain.Topic, arc_id: str
) -> List[str]:
"""Returns skill IDs associated with all nodes in a given arc.
- The arc_id parameter maps to an arc by its ID suffix (e.g., '1'
- maps to 'arc_1').
+ The arc_id parameter is a 1-based index that maps to the nth arc in
+ the first published story of the topic (e.g., '1' maps to the first
+ arc).
Args:
topic: Topic. The topic object.
- arc_id: str. The arc index (e.g., '1').
+ arc_id: str. The arc ID (1-based index).
Returns:
list(str). The skill IDs for all nodes in the arc.
"""
- arcs_with_stories = story_fetchers.get_all_arcs_with_stories_for_topic(
- topic
- )
- target_arc_id = 'arc_%s' % arc_id
- for story, arc in arcs_with_stories:
- if arc.id == target_arc_id:
- return story.get_acquired_skill_ids_for_node_ids(arc.node_ids)
- return []
+ story_arc = self._get_story_and_arc_for_arc_id(topic, arc_id)
+ if story_arc is None:
+ return []
+ story, arc = story_arc
+ return story.get_acquired_skill_ids_for_node_ids(arc.node_ids)
diff --git a/core/controllers/practice_sessions_test.py b/core/controllers/practice_sessions_test.py
index fa3cdfcd55315..36c3f04124c3c 100644
--- a/core/controllers/practice_sessions_test.py
+++ b/core/controllers/practice_sessions_test.py
@@ -505,6 +505,208 @@ def test_get_succeeds_with_arc_id(self) -> None:
'Skill 2',
)
+ def test_get_succeeds_with_arc_position(self) -> None:
+ story_id = 'story_id_2'
+ exp_id = 'exp_2'
+ self.save_new_valid_exploration(exp_id, self.admin_id)
+ self.publish_exploration(self.admin_id, exp_id)
+ self.save_new_story(story_id, self.admin_id, self.topic_id)
+ topic_services.add_canonical_story(
+ self.admin_id, self.topic_id, story_id
+ )
+ story_services.update_story(
+ self.admin_id,
+ story_id,
+ [
+ story_domain.StoryChange(
+ {
+ 'cmd': story_domain.CMD_ADD_STORY_NODE,
+ 'node_id': 'node_1',
+ 'title': 'Chapter 1',
+ }
+ ),
+ story_domain.StoryChange(
+ {
+ 'cmd': story_domain.CMD_UPDATE_STORY_NODE_PROPERTY,
+ 'property_name': (
+ story_domain.STORY_NODE_PROPERTY_EXPLORATION_ID
+ ),
+ 'node_id': 'node_1',
+ 'old_value': None,
+ 'new_value': exp_id,
+ }
+ ),
+ ],
+ 'Added node.',
+ )
+ topic_services.publish_story(self.topic_id, story_id, self.admin_id)
+ story_services.update_story(
+ self.admin_id,
+ story_id,
+ [
+ story_domain.StoryChange(
+ {
+ 'cmd': story_domain.CMD_UPDATE_STORY_NODE_PROPERTY,
+ 'property_name': (
+ story_domain.STORY_NODE_PROPERTY_ACQUIRED_SKILL_IDS
+ ),
+ 'node_id': 'node_1',
+ # Here we use cast because the empty list's type cannot
+ # be inferred, and List[str] is needed to match
+ # AcceptableChangeDictTypes.
+ 'old_value': cast(List[str], []),
+ 'new_value': [self.skill_id1, self.skill_id2],
+ }
+ ),
+ story_domain.StoryChange(
+ {
+ 'cmd': story_domain.CMD_CREATE_ARC,
+ 'arc_id': 'default_arc',
+ 'title': 'Default Arc',
+ 'description': 'Default arc',
+ 'node_ids': ['node_1'],
+ }
+ ),
+ ],
+ 'Added acquired skill IDs and arc.',
+ )
+
+ json_response = self.get_json(
+ '%s/staging/%s/arc/1'
+ % (feconf.PRACTICE_SESSION_DATA_URL_PREFIX, 'public-topic-name'),
+ )
+ self.assertEqual(json_response['topic_name'], 'public_topic_name')
+ self.assertEqual(len(json_response['skill_ids_to_descriptions_map']), 2)
+ self.assertEqual(
+ json_response['skill_ids_to_descriptions_map'][self.skill_id1],
+ 'Skill 1',
+ )
+ self.assertEqual(
+ json_response['skill_ids_to_descriptions_map'][self.skill_id2],
+ 'Skill 2',
+ )
+
+ def test_get_succeeds_with_second_arc_position(self) -> None:
+ story_id = 'story_id_2'
+ exp_id = 'exp_2'
+ exp_id_2 = 'exp_2_b'
+ self.save_new_valid_exploration(exp_id, self.admin_id)
+ self.publish_exploration(self.admin_id, exp_id)
+ self.save_new_valid_exploration(exp_id_2, self.admin_id)
+ self.publish_exploration(self.admin_id, exp_id_2)
+ self.save_new_story(story_id, self.admin_id, self.topic_id)
+ topic_services.add_canonical_story(
+ self.admin_id, self.topic_id, story_id
+ )
+ story_services.update_story(
+ self.admin_id,
+ story_id,
+ [
+ story_domain.StoryChange(
+ {
+ 'cmd': story_domain.CMD_ADD_STORY_NODE,
+ 'node_id': 'node_1',
+ 'title': 'Chapter 1',
+ }
+ ),
+ story_domain.StoryChange(
+ {
+ 'cmd': story_domain.CMD_ADD_STORY_NODE,
+ 'node_id': 'node_2',
+ 'title': 'Chapter 2',
+ }
+ ),
+ story_domain.StoryChange(
+ {
+ 'cmd': story_domain.CMD_UPDATE_STORY_NODE_PROPERTY,
+ 'property_name': (
+ story_domain.STORY_NODE_PROPERTY_EXPLORATION_ID
+ ),
+ 'node_id': 'node_1',
+ 'old_value': None,
+ 'new_value': exp_id,
+ }
+ ),
+ story_domain.StoryChange(
+ {
+ 'cmd': story_domain.CMD_UPDATE_STORY_NODE_PROPERTY,
+ 'property_name': (
+ story_domain.STORY_NODE_PROPERTY_EXPLORATION_ID
+ ),
+ 'node_id': 'node_2',
+ 'old_value': None,
+ 'new_value': exp_id_2,
+ }
+ ),
+ ],
+ 'Added nodes.',
+ )
+ topic_services.publish_story(self.topic_id, story_id, self.admin_id)
+ story_services.update_story(
+ self.admin_id,
+ story_id,
+ [
+ story_domain.StoryChange(
+ {
+ 'cmd': story_domain.CMD_UPDATE_STORY_NODE_PROPERTY,
+ 'property_name': (
+ story_domain.STORY_NODE_PROPERTY_ACQUIRED_SKILL_IDS
+ ),
+ 'node_id': 'node_1',
+ # Here we use cast because the empty list's type cannot
+ # be inferred, and List[str] is needed to match
+ # AcceptableChangeDictTypes.
+ 'old_value': cast(List[str], []),
+ 'new_value': [self.skill_id1],
+ }
+ ),
+ story_domain.StoryChange(
+ {
+ 'cmd': story_domain.CMD_UPDATE_STORY_NODE_PROPERTY,
+ 'property_name': (
+ story_domain.STORY_NODE_PROPERTY_ACQUIRED_SKILL_IDS
+ ),
+ 'node_id': 'node_2',
+ # Here we use cast because the empty list's type cannot
+ # be inferred, and List[str] is needed to match
+ # AcceptableChangeDictTypes.
+ 'old_value': cast(List[str], []),
+ 'new_value': [self.skill_id2],
+ }
+ ),
+ story_domain.StoryChange(
+ {
+ 'cmd': story_domain.CMD_CREATE_ARC,
+ 'arc_id': 'default_arc',
+ 'title': 'Default Arc',
+ 'description': 'Default arc',
+ 'node_ids': ['node_1'],
+ }
+ ),
+ story_domain.StoryChange(
+ {
+ 'cmd': story_domain.CMD_CREATE_ARC,
+ 'arc_id': 'arc_2',
+ 'title': 'Arc 2',
+ 'description': 'Second arc',
+ 'node_ids': ['node_2'],
+ }
+ ),
+ ],
+ 'Added acquired skill IDs and arcs.',
+ )
+
+ json_response = self.get_json(
+ '%s/staging/%s/arc/2'
+ % (feconf.PRACTICE_SESSION_DATA_URL_PREFIX, 'public-topic-name'),
+ )
+ self.assertEqual(json_response['topic_name'], 'public_topic_name')
+ self.assertEqual(len(json_response['skill_ids_to_descriptions_map']), 1)
+ self.assertEqual(
+ json_response['skill_ids_to_descriptions_map'][self.skill_id2],
+ 'Skill 2',
+ )
+
def test_get_returns_empty_for_zero_node_id(self) -> None:
json_response = self.get_json(
'%s/staging/%s/0'
@@ -545,6 +747,30 @@ def test_get_returns_empty_for_large_arc_index(self) -> None:
self.assertEqual(json_response['topic_name'], 'public_topic_name')
self.assertEqual(len(json_response['skill_ids_to_descriptions_map']), 0)
+ def test_get_returns_empty_for_non_ascii_arc_id(self) -> None:
+ json_response = self.get_json(
+ '%s/staging/%s/arc/%s'
+ % (
+ feconf.PRACTICE_SESSION_DATA_URL_PREFIX,
+ 'public-topic-name',
+ '%D9%A1',
+ ),
+ )
+ self.assertEqual(json_response['topic_name'], 'public_topic_name')
+ self.assertEqual(len(json_response['skill_ids_to_descriptions_map']), 0)
+
+ def test_get_returns_empty_for_emoji_arc_id(self) -> None:
+ json_response = self.get_json(
+ '%s/staging/%s/arc/%s'
+ % (
+ feconf.PRACTICE_SESSION_DATA_URL_PREFIX,
+ 'public-topic-name',
+ '%F0%9F%98%80',
+ ),
+ )
+ self.assertEqual(json_response['topic_name'], 'public_topic_name')
+ self.assertEqual(len(json_response['skill_ids_to_descriptions_map']), 0)
+
def test_get_node_skills_with_deleted_story_in_topic(self) -> None:
story_id = 'story_id'
deleted_story_id = 'del_story'
diff --git a/core/controllers/tasks.py b/core/controllers/tasks.py
index 2fd732943569a..cc859d435c446 100644
--- a/core/controllers/tasks.py
+++ b/core/controllers/tasks.py
@@ -488,6 +488,15 @@ def post(self) -> None:
deferred_task_function(*payload['args'], **payload['kwargs'])
+ # If the deferred task is for voiceover regeneration, do not update
+ # its status immediately. Instead, wait until all child processes
+ # have completed. The final status will be set at the end of the
+ # parent task.
+ if (
+ cloud_task_run_domain_instance.is_voiceover_regeneration_parent_task()
+ ):
+ return
+
updated_cloud_task_run_domain_instance = (
taskqueue_services.get_cloud_task_run_by_model_id(
cloud_task_model_id
diff --git a/core/controllers/tasks_test.py b/core/controllers/tasks_test.py
index 06acf2cd903ee..bbc12f9369915 100644
--- a/core/controllers/tasks_test.py
+++ b/core/controllers/tasks_test.py
@@ -748,7 +748,11 @@ def test_should_handle_deferred_tasks_successfully(self) -> None:
def test_should_handle_voiceover_deferred_tasks_successfully(self) -> None:
exploration_id = 'exploration_id'
- self.save_new_valid_exploration(exploration_id, self.owner_id)
+ self.save_new_valid_exploration(
+ exploration_id,
+ self.owner_id,
+ content_html='Hello world',
+ )
rights_manager.publish_exploration(self.owner, exploration_id)
url = feconf.TASK_URL_DEFERRED
@@ -795,7 +799,7 @@ def test_should_handle_voiceover_deferred_tasks_successfully(self) -> None:
taskqueue_services.get_cloud_task_run_by_model_id(new_model_id)
)
assert cloud_task_run_model_obj is not None
- self.assertEqual(cloud_task_run_model_obj.latest_job_state, 'SUCCEEDED')
+ self.assertEqual(cloud_task_run_model_obj.latest_job_state, 'RUNNING')
def test_should_handle_failure_case_for_voiceover_deferred_tasks(
self,
diff --git a/core/controllers/topic_editor.py b/core/controllers/topic_editor.py
index f1141ba13a561..bd9dacaf8ca8e 100644
--- a/core/controllers/topic_editor.py
+++ b/core/controllers/topic_editor.py
@@ -221,6 +221,7 @@ def get(self, topic_id: str) -> None:
upcoming_chapters_expected_days
),
'overdue_chapters_count': overdue_chapters_count,
+ 'arcs': [arc.to_dict() for arc in story.story_contents.arcs],
}
updated_canonical_story_summary_dicts.append(
updated_canonical_story_summary_dict
diff --git a/core/controllers/topic_editor_test.py b/core/controllers/topic_editor_test.py
index 6b5c224cf2aaa..e5a6cb2c3219a 100644
--- a/core/controllers/topic_editor_test.py
+++ b/core/controllers/topic_editor_test.py
@@ -261,6 +261,14 @@ def test_handler_updates_story_summary_dicts(self) -> None:
]
story.story_contents.initial_node_id = 'node_1'
story.story_contents.next_node_id = 'node_4'
+ story.story_contents.add_arc(
+ story_domain.Arc(
+ 'arc_1',
+ 'Adventure 1',
+ 'First adventure',
+ ['node_1', 'node_2', 'node_3'],
+ )
+ )
self.assertEqual(response['canonical_story_summary_dicts'], [])
self.assertEqual(response['additional_story_summary_dicts'], [])
@@ -365,6 +373,17 @@ def mock_get_current_time_in_millisecs() -> int:
self.assertEqual(
canonical_story_summary_dict['overdue_chapters_count'], 1
)
+ self.assertEqual(
+ canonical_story_summary_dict['arcs'],
+ [
+ {
+ 'id': 'arc_1',
+ 'title': 'Adventure 1',
+ 'description': 'First adventure',
+ 'node_ids': ['node_1', 'node_2', 'node_3'],
+ }
+ ],
+ )
self.assertEqual(
additional_story_summary_dict['description'],
diff --git a/core/domain/certificate_assessment_domain.py b/core/domain/certificate_assessment_domain.py
index a27ef5ee15ab7..17763bd876b47 100644
--- a/core/domain/certificate_assessment_domain.py
+++ b/core/domain/certificate_assessment_domain.py
@@ -32,6 +32,9 @@
MAX_TIME_LIMIT_IN_MINUTES = 60
MIN_TOTAL_QUESTIONS = 3
MAX_TOTAL_QUESTIONS = 50
+# Minimum time (in minutes) that must elapse between a learner's last
+# attempt start and their next attempt start for the same certificate.
+MIN_TIME_BETWEEN_ATTEMPTS_IN_MINUTES = 10
# Maximum size in bytes of a serialized selected_answer for a certificate
# assessment. Each response is stored in its own Datastore entity, so this
diff --git a/core/domain/certificate_assessment_services.py b/core/domain/certificate_assessment_services.py
index 5ee9bc0f7d3f5..46cc88f7f4cc0 100644
--- a/core/domain/certificate_assessment_services.py
+++ b/core/domain/certificate_assessment_services.py
@@ -21,6 +21,7 @@
import collections
import datetime
import json
+import math
import secrets
import sys
@@ -84,6 +85,23 @@ class CertificateAssessmentAttemptNotReadyException(Exception):
pass
+class CertificateAssessmentAttemptCooldownException(Exception):
+ """Raised when a learner starts a new attempt during the cooldown window.
+
+ The message here is server-side only and intentionally not user-facing
+ English: the HTTP handler converts this into a structured response
+ (I18N key + remaining_minutes) so the frontend can render a translated
+ message via its translate pipes.
+ """
+
+ def __init__(self, remaining_minutes: int) -> None:
+ super().__init__(
+ 'Assessment attempt blocked by cooldown; %d minute(s) remaining.'
+ % remaining_minutes
+ )
+ self.remaining_minutes = remaining_minutes
+
+
class CertificateOfferingClassroomSummary(TypedDict):
"""Dict representation of a classroom-facing certificate offering."""
@@ -441,27 +459,29 @@ def _build_version_data(
}
-def _get_in_progress_attempt_for_learner(
- learner_id: str,
+def _get_most_recent_attempt_for_learner_and_certificate(
+ learner_id: str, certificate_id: str
) -> Optional[gae_models.CertificateAssessmentAttemptModel]:
- """Returns the learner's in-progress assessment attempt, if any."""
- return gae_models.CertificateAssessmentAttemptModel.query(
- gae_models.CertificateAssessmentAttemptModel.learner_id == learner_id,
- gae_models.CertificateAssessmentAttemptModel.is_submitted # pylint: disable=singleton-comparison
- == False,
- ).get()
-
-
-def _get_active_attempt_for_learner(
- learner_id: str,
-) -> gae_models.CertificateAssessmentAttemptModel:
- """Returns the learner's active assessment attempt or raises."""
- attempt_model = _get_in_progress_attempt_for_learner(learner_id)
- if attempt_model is None:
- raise utils.ValidationError(
- 'No active certificate assessment attempt was found.'
+ """Returns the learner's most recent attempt for a certificate, if any.
+
+ Attempts are ordered by their creation time. We would prefer to sort by
+ started_at, but that property is not indexed (see
+ CertificateAssessmentAttemptModel.started_at) and Datastore cannot order
+ by a computed expression such as IF(started_at, -started_at,
+ -created_on), so started_at cannot be used as a query sort key. Since
+ attempts are created when they start, creation order matches start order,
+ so ordering by created_on yields the most recently started attempt.
+ """
+ return (
+ gae_models.CertificateAssessmentAttemptModel.query(
+ gae_models.CertificateAssessmentAttemptModel.learner_id
+ == learner_id,
+ gae_models.CertificateAssessmentAttemptModel.certificate_id
+ == certificate_id,
)
- return attempt_model
+ .order(-gae_models.CertificateAssessmentAttemptModel.created_on)
+ .get()
+ )
def _get_certificate_assessment_attempt_model(
@@ -550,18 +570,39 @@ def start_certificate_assessment_attempt(
Raises:
CertificateAssessmentAttemptNotReadyException. If the assessment can no
longer be started because the question pool is invalid.
- utils.ValidationError. If the learner already has an in-progress
- attempt.
+ CertificateAssessmentAttemptCooldownException. If the learner started an attempt for this
+ certificate less than MIN_TIME_BETWEEN_ATTEMPTS_IN_MINUTES minutes
+ ago.
"""
def _start_txn() -> Tuple[
certificate_assessment_domain.CertificateAssessmentAttempt,
List[Dict[str, Union[str, int]]],
]:
- if _get_in_progress_attempt_for_learner(learner_id) is not None:
- raise utils.ValidationError(
- 'You already have an in-progress certificate assessment attempt.'
+ most_recent_attempt = (
+ _get_most_recent_attempt_for_learner_and_certificate(
+ learner_id, certificate_id
+ )
+ )
+ if most_recent_attempt is not None:
+ cooldown = datetime.timedelta(
+ minutes=(
+ certificate_assessment_domain.MIN_TIME_BETWEEN_ATTEMPTS_IN_MINUTES
+ )
)
+ remaining_cooldown = cooldown - (
+ datetime.datetime.utcnow() - most_recent_attempt.started_at
+ )
+ if remaining_cooldown > datetime.timedelta(seconds=0):
+ # Round up so the reported wait never lapses before the actual
+ # cooldown expires, and never drop below one minute.
+ remaining_minutes = max(
+ 1,
+ int(math.ceil(remaining_cooldown.total_seconds() / 60)),
+ )
+ raise CertificateAssessmentAttemptCooldownException(
+ remaining_minutes
+ )
attempt_model = gae_models.CertificateAssessmentAttemptModel.create(
learner_id=learner_id,
certificate_id=certificate_id,
diff --git a/core/domain/certificate_assessment_services_test.py b/core/domain/certificate_assessment_services_test.py
index 0a501043bebad..908c0bb7e1c9d 100644
--- a/core/domain/certificate_assessment_services_test.py
+++ b/core/domain/certificate_assessment_services_test.py
@@ -39,7 +39,7 @@
from core.storage.certificate_assessment import gae_models
from core.tests import test_utils
-from typing import Sequence, TypedDict
+from typing import Sequence, TypedDict, cast
MYPY = False
if MYPY: # pragma: no cover
@@ -528,17 +528,17 @@ def test_build_version_data_includes_certificate_and_topic_versions(
},
)
- def test_get_active_attempt_for_learner_returns_or_raises(self) -> None:
- with self.assertRaisesRegex(
- utils.ValidationError,
- 'No active certificate assessment attempt was found.',
- ):
+ def test_get_most_recent_attempt_returns_latest_for_learner_and_certificate(
+ self,
+ ) -> None:
+ self.assertIsNone(
getattr(
certificate_assessment_services,
- '_get_active_attempt_for_learner',
- )('learner_1')
+ '_get_most_recent_attempt_for_learner_and_certificate',
+ )('learner_1', 'cert_1')
+ )
- attempt = gae_models.CertificateAssessmentAttemptModel.create(
+ gae_models.CertificateAssessmentAttemptModel.create(
learner_id='learner_1',
certificate_id='cert_1',
total_score=0.0,
@@ -555,12 +555,49 @@ def test_get_active_attempt_for_learner_returns_or_raises(self) -> None:
finished_at=None,
is_submitted=False,
)
+ most_recent_attempt = (
+ gae_models.CertificateAssessmentAttemptModel.create(
+ learner_id='learner_1',
+ certificate_id='cert_1',
+ total_score=0.0,
+ attempt_index=2,
+ attempt_data={},
+ version_data={
+ 'certificate_id': 'cert_1',
+ 'certificate_version': 1,
+ 'topic_versions': {'topic_1': 1},
+ 'question_versions': {'question_1': 1},
+ 'question_topic_links': {'question_1': ['topic_1']},
+ },
+ started_at=datetime.datetime.utcnow(),
+ finished_at=None,
+ is_submitted=False,
+ )
+ )
+ # An attempt for a different certificate is not considered.
+ gae_models.CertificateAssessmentAttemptModel.create(
+ learner_id='learner_1',
+ certificate_id='cert_2',
+ total_score=0.0,
+ attempt_index=1,
+ attempt_data={},
+ version_data={
+ 'certificate_id': 'cert_2',
+ 'certificate_version': 1,
+ 'topic_versions': {'topic_1': 1},
+ 'question_versions': {'question_1': 1},
+ 'question_topic_links': {'question_1': ['topic_1']},
+ },
+ started_at=datetime.datetime.utcnow(),
+ finished_at=None,
+ is_submitted=False,
+ )
self.assertEqual(
getattr(
certificate_assessment_services,
- '_get_active_attempt_for_learner',
- )('learner_1').id,
- attempt.id,
+ '_get_most_recent_attempt_for_learner_and_certificate',
+ )('learner_1', 'cert_1').id,
+ most_recent_attempt.id,
)
def test_get_next_attempt_index_uses_highest_submitted_index(
@@ -755,7 +792,7 @@ def test_get_certificate_assessment_attempt_raises_for_missing_attempt(
'missing_attempt_id'
)
- def test_start_certificate_assessment_attempt_rejects_in_progress_attempt(
+ def test_start_certificate_assessment_attempt_rejects_attempt_within_cooldown(
self,
) -> None:
owner_id = self.get_user_id_from_email(self.OWNER_EMAIL)
@@ -785,24 +822,25 @@ def test_start_certificate_assessment_attempt_rejects_in_progress_attempt(
async_status='Available',
)
- in_progress_attempt = (
- gae_models.CertificateAssessmentAttemptModel.create(
- learner_id=owner_id,
- certificate_id=created_offering.certificate_id,
- total_score=0.0,
- attempt_index=1,
- attempt_data={},
- version_data={
- 'certificate_id': created_offering.certificate_id,
- 'certificate_version': 1,
- 'topic_versions': {topic_id: 1},
- 'question_versions': {'dummy_question_id': 1},
- 'question_topic_links': {'dummy_question_id': [topic_id]},
- },
- started_at=datetime.datetime.utcnow(),
- finished_at=None,
- is_submitted=False,
- )
+ recent_attempt = gae_models.CertificateAssessmentAttemptModel.create(
+ learner_id=owner_id,
+ certificate_id=created_offering.certificate_id,
+ total_score=0.0,
+ attempt_index=1,
+ attempt_data={},
+ version_data={
+ 'certificate_id': created_offering.certificate_id,
+ 'certificate_version': 1,
+ 'topic_versions': {topic_id: 1},
+ 'question_versions': {'dummy_question_id': 1},
+ 'question_topic_links': {'dummy_question_id': [topic_id]},
+ },
+ started_at=(
+ datetime.datetime.utcnow()
+ - datetime.timedelta(minutes=4, seconds=30)
+ ),
+ finished_at=None,
+ is_submitted=False,
)
with mock.patch.object(
@@ -810,19 +848,170 @@ def test_start_certificate_assessment_attempt_rejects_in_progress_attempt(
'validate_certificate_assessment_offering',
return_value={'is_valid': True},
), self.assertRaisesRegex(
- utils.ValidationError,
- 'You already have an in-progress certificate assessment attempt.',
- ):
+ certificate_assessment_services.CertificateAssessmentAttemptCooldownException,
+ # The remaining wait is about 5 minutes and 30 seconds, so it
+ # must be reported rounded up to 6 minutes.
+ r'Assessment attempt blocked by cooldown; 6 minute\(s\) remaining\.',
+ ) as context:
certificate_assessment_services.start_certificate_assessment_attempt(
created_offering.certificate_id,
owner_id,
)
+ self.assertEqual(
+ # Here we use cast because context.exception is typed as BaseException
+ # and needs to be narrowed to access the remaining_minutes attribute.
+ cast(
+ certificate_assessment_services.CertificateAssessmentAttemptCooldownException,
+ context.exception,
+ ).remaining_minutes,
+ 6,
+ )
self.assertIsNotNone(
gae_models.CertificateAssessmentAttemptModel.get_by_id(
- in_progress_attempt.id
+ recent_attempt.id
+ )
+ )
+
+ def test_start_certificate_assessment_attempt_rejects_attempt_with_seconds_of_cooldown_left(
+ self,
+ ) -> None:
+ owner_id = self.get_user_id_from_email(self.OWNER_EMAIL)
+ question_id_1 = question_services.get_new_question_id()
+ question_id_2 = question_services.get_new_question_id()
+ question_id_3 = question_services.get_new_question_id()
+ self._create_assessment_question(
+ question_id_1, 'skill_1', 'Answer', 0.6
+ )
+ self._create_assessment_question(
+ question_id_2, 'skill_2', 'Answer 2', 0.3
+ )
+ self._create_assessment_question(
+ question_id_3, 'skill_3', 'Answer 3', 0.9
+ )
+ topic_id = self._create_assessment_topic_with_skills(
+ ['skill_1', 'skill_2', 'skill_3']
+ )
+ created_offering = certificate_assessment_services.create_certificate_assessment_offering(
+ title='Arithmetic Check',
+ description='Checks arithmetic basics.',
+ classroom_id=self.classroom_id,
+ topic_ids=[topic_id],
+ total_questions=3,
+ time_limit_in_minutes=30,
+ demonstrates=['Arithmetic reasoning'],
+ async_status='Available',
+ )
+
+ gae_models.CertificateAssessmentAttemptModel.create(
+ learner_id=owner_id,
+ certificate_id=created_offering.certificate_id,
+ total_score=0.0,
+ attempt_index=1,
+ attempt_data={},
+ version_data={
+ 'certificate_id': created_offering.certificate_id,
+ 'certificate_version': 1,
+ 'topic_versions': {topic_id: 1},
+ 'question_versions': {'dummy_question_id': 1},
+ 'question_topic_links': {'dummy_question_id': [topic_id]},
+ },
+ started_at=(
+ datetime.datetime.utcnow()
+ - datetime.timedelta(minutes=9, seconds=30)
+ ),
+ finished_at=None,
+ is_submitted=False,
+ )
+
+ with mock.patch.object(
+ certificate_assessment_services,
+ 'validate_certificate_assessment_offering',
+ return_value={'is_valid': True},
+ ), self.assertRaisesRegex(
+ certificate_assessment_services.CertificateAssessmentAttemptCooldownException,
+ r'Assessment attempt blocked by cooldown; 1 minute\(s\) remaining\.',
+ ) as context:
+ certificate_assessment_services.start_certificate_assessment_attempt(
+ created_offering.certificate_id,
+ owner_id,
)
+ self.assertEqual(
+ # Here we use cast because context.exception is typed as BaseException
+ # and needs to be narrowed to access the remaining_minutes attribute.
+ cast(
+ certificate_assessment_services.CertificateAssessmentAttemptCooldownException,
+ context.exception,
+ ).remaining_minutes,
+ 1,
+ )
+
+ def test_start_certificate_assessment_attempt_allows_attempt_after_cooldown(
+ self,
+ ) -> None:
+ owner_id = self.get_user_id_from_email(self.OWNER_EMAIL)
+ question_id_1 = question_services.get_new_question_id()
+ question_id_2 = question_services.get_new_question_id()
+ question_id_3 = question_services.get_new_question_id()
+ self._create_assessment_question(
+ question_id_1, 'skill_1', 'Answer', 0.6
+ )
+ self._create_assessment_question(
+ question_id_2, 'skill_2', 'Answer 2', 0.3
)
+ self._create_assessment_question(
+ question_id_3, 'skill_3', 'Answer 3', 0.9
+ )
+ topic_id = self._create_assessment_topic_with_skills(
+ ['skill_1', 'skill_2', 'skill_3']
+ )
+ created_offering = certificate_assessment_services.create_certificate_assessment_offering(
+ title='Arithmetic Check',
+ description='Checks arithmetic basics.',
+ classroom_id=self.classroom_id,
+ topic_ids=[topic_id],
+ total_questions=3,
+ time_limit_in_minutes=30,
+ demonstrates=['Arithmetic reasoning'],
+ async_status='Available',
+ )
+
+ gae_models.CertificateAssessmentAttemptModel.create(
+ learner_id=owner_id,
+ certificate_id=created_offering.certificate_id,
+ total_score=0.0,
+ attempt_index=1,
+ attempt_data={},
+ version_data={
+ 'certificate_id': created_offering.certificate_id,
+ 'certificate_version': 1,
+ 'topic_versions': {topic_id: 1},
+ 'question_versions': {'dummy_question_id': 1},
+ 'question_topic_links': {'dummy_question_id': [topic_id]},
+ },
+ started_at=(
+ datetime.datetime.utcnow()
+ - datetime.timedelta(minutes=10, seconds=1)
+ ),
+ finished_at=None,
+ is_submitted=False,
+ )
+
+ with mock.patch.object(
+ certificate_assessment_services,
+ 'validate_certificate_assessment_offering',
+ return_value={'is_valid': True},
+ ):
+ attempt, _ = (
+ certificate_assessment_services.start_certificate_assessment_attempt(
+ created_offering.certificate_id,
+ owner_id,
+ )
+ )
+
+ self.assertEqual(attempt.attempt_index, 0)
+ self.assertFalse(attempt.is_submitted)
+ self.assertIsNotNone(attempt.started_at)
def test_start_certificate_assessment_attempt_blocks_invalid_offering(
self,
diff --git a/core/domain/cloud_task_domain.py b/core/domain/cloud_task_domain.py
index db037ce12fb7a..23bffd3b7c4b7 100644
--- a/core/domain/cloud_task_domain.py
+++ b/core/domain/cloud_task_domain.py
@@ -38,6 +38,7 @@ class CloudTaskRunDict(TypedDict):
last_updated: str
current_retry_attempt: int
created_on: str
+ additional_contextual_information: Dict[str, str]
class CloudTaskRun:
@@ -55,6 +56,7 @@ def __init__(
current_retry_attempt: int,
last_updated: datetime.datetime,
created_on: datetime.datetime,
+ additional_contextual_information: Dict[str, str],
) -> None:
self.task_run_id = task_run_id
self.cloud_task_name = cloud_task_name
@@ -68,6 +70,9 @@ def __init__(
self.current_retry_attempt = current_retry_attempt
self.last_updated = last_updated
self.created_on = created_on
+ self.additional_contextual_information = (
+ additional_contextual_information
+ )
def to_dict(self) -> CloudTaskRunDict:
"""Returns a dictionary representation of this domain object.
@@ -89,6 +94,7 @@ def to_dict(self) -> CloudTaskRunDict:
'current_retry_attempt': self.current_retry_attempt,
'last_updated': self.last_updated.isoformat(),
'created_on': self.created_on.isoformat(),
+ 'additional_contextual_information': self.additional_contextual_information,
}
def to_dict_with_timezone_info(self) -> CloudTaskRunDict:
@@ -117,6 +123,7 @@ def to_dict_with_timezone_info(self) -> CloudTaskRunDict:
'created_on': self.created_on.replace(
tzinfo=datetime.timezone.utc
).isoformat(),
+ 'additional_contextual_information': self.additional_contextual_information,
}
@classmethod
@@ -148,8 +155,34 @@ def from_dict(cls, cloud_task_run_dict: CloudTaskRunDict) -> CloudTaskRun:
created_on=datetime.datetime.fromisoformat(
cloud_task_run_dict['created_on']
),
+ additional_contextual_information=cloud_task_run_dict.get(
+ 'additional_contextual_information', {}
+ ),
)
+ def is_voiceover_regeneration_parent_task(self) -> bool:
+ """Checks whether the cloud task run is related to a parent task for
+ voiceover regeneration.
+
+ Returns:
+ bool. True if the cloud task run is related to a parent task for
+ voiceover regeneration, False otherwise.
+ """
+ return self.function_id in {
+ feconf.FUNCTION_ID_TO_FUNCTION_NAME_FOR_DEFERRED_JOBS[
+ 'FUNCTION_ID_REGENERATE_VOICEOVERS_ON_EXP_UPDATE'
+ ],
+ feconf.FUNCTION_ID_TO_FUNCTION_NAME_FOR_DEFERRED_JOBS[
+ 'FUNCTION_ID_REGENERATE_VOICEOVERS_ON_EXP_CURATION'
+ ],
+ feconf.FUNCTION_ID_TO_FUNCTION_NAME_FOR_DEFERRED_JOBS[
+ 'FUNCTION_ID_REGENERATE_VOICEOVERS_AFTER_ACCEPTING_SUGGESTION'
+ ],
+ feconf.FUNCTION_ID_TO_FUNCTION_NAME_FOR_DEFERRED_JOBS[
+ 'FUNCTION_ID_REGENERATE_VOICEOVERS_BY_LANGUAGE_ACCENT'
+ ],
+ }
+
class VoiceoverRegenerationJobDict(TypedDict):
"""Dictionary representing the VoiceoverRegenerationJob object."""
diff --git a/core/domain/cloud_task_domain_test.py b/core/domain/cloud_task_domain_test.py
index 5ebcac1b6cb05..43a5ac4cce55f 100644
--- a/core/domain/cloud_task_domain_test.py
+++ b/core/domain/cloud_task_domain_test.py
@@ -59,6 +59,7 @@ def test_should_create_domain_object_correctly(self) -> None:
0,
last_updated,
created_on,
+ {},
)
self.assertEqual(cloud_task_run.task_run_id, cloud_task_run_id)
@@ -100,6 +101,7 @@ def test_should_create_domain_object_from_dict(self) -> None:
'current_retry_attempt': 0,
'last_updated': last_updated.isoformat(),
'created_on': created_on.isoformat(),
+ 'additional_contextual_information': {},
}
cloud_task_run = cloud_task_domain.CloudTaskRun.from_dict(
@@ -121,6 +123,7 @@ def test_should_convert_datetime_fields_with_timezone_info(self) -> None:
current_retry_attempt=0,
last_updated=datetime.datetime(2026, 1, 2, 3, 4, 5),
created_on=datetime.datetime(2026, 1, 2, 3, 4, 6),
+ additional_contextual_information={},
)
cloud_task_run_dict = cloud_task_run.to_dict_with_timezone_info()
diff --git a/core/domain/exp_services_test.py b/core/domain/exp_services_test.py
index 6c178094b90fd..2a70278564820 100644
--- a/core/domain/exp_services_test.py
+++ b/core/domain/exp_services_test.py
@@ -7243,6 +7243,109 @@ def mock_add_documents_to_index(
self.assertEqual(add_docs_counter.times_called, 1)
+ def test_translated_metadata_is_added_correctly_to_index(self) -> None:
+ exp_id = 'id0'
+ self.save_new_valid_exploration(
+ exp_id,
+ self.owner_id,
+ title='Fractions',
+ category='Mathematics',
+ objective='Learn about fractions',
+ )
+ exp_services.update_exploration(
+ self.owner_id,
+ exp_id,
+ [
+ exp_domain.ExplorationChange(
+ {
+ 'cmd': exp_domain.CMD_EDIT_EXPLORATION_PROPERTY,
+ 'property_name': 'tags',
+ 'new_value': ['numbers'],
+ }
+ )
+ ],
+ 'Add a tag.',
+ )
+ rights_manager.publish_exploration(self.owner, exp_id)
+ exp = exp_fetchers.get_exploration_by_id(exp_id)
+
+ # Hindi translates the title, the objective and the tag. Bengali
+ # translates only the title, and its objective translation is stale.
+ translation_models.EntityTranslationsModel.create_new(
+ feconf.TranslatableEntityType.EXPLORATION.value,
+ exp_id,
+ exp.version,
+ 'hi',
+ {
+ feconf.EXPLORATION_TITLE_CONTENT_ID: {
+ 'content_value': 'भिन्न',
+ 'needs_update': False,
+ 'content_format': 'unicode',
+ },
+ feconf.EXPLORATION_OBJECTIVE_CONTENT_ID: {
+ 'content_value': 'भिन्न के बारे में जानें',
+ 'needs_update': False,
+ 'content_format': 'unicode',
+ },
+ f'{feconf.EXPLORATION_TAG_CONTENT_ID_PREFIX}_0': {
+ 'content_value': 'संख्याएँ',
+ 'needs_update': False,
+ 'content_format': 'unicode',
+ },
+ },
+ ).put()
+ translation_models.EntityTranslationsModel.create_new(
+ feconf.TranslatableEntityType.EXPLORATION.value,
+ exp_id,
+ exp.version,
+ 'bn',
+ {
+ feconf.EXPLORATION_TITLE_CONTENT_ID: {
+ 'content_value': 'ভগ্নাংশ',
+ 'needs_update': False,
+ 'content_format': 'unicode',
+ },
+ feconf.EXPLORATION_OBJECTIVE_CONTENT_ID: {
+ 'content_value': 'পুরানো উদ্দেশ্য',
+ 'needs_update': True,
+ 'content_format': 'unicode',
+ },
+ },
+ ).put()
+
+ actual_docs = []
+
+ def mock_add_documents_to_index(
+ docs: List[Dict[str, str]], index: str
+ ) -> None:
+ self.assertEqual(index, exp_services.SEARCH_INDEX_EXPLORATIONS)
+ actual_docs.extend(docs)
+
+ add_docs_swap = self.swap(
+ search_services,
+ 'add_documents_to_index',
+ mock_add_documents_to_index,
+ )
+
+ with add_docs_swap:
+ exp_services.index_explorations_given_ids([exp_id])
+
+ self.assertEqual(len(actual_docs), 1)
+ indexed_doc = actual_docs[0]
+ # The English metadata is indexed unchanged.
+ self.assertEqual(indexed_doc['title'], 'Fractions')
+ self.assertEqual(indexed_doc['objective'], 'Learn about fractions')
+ self.assertEqual(indexed_doc['tags'], ['numbers'])
+ # Both title translations are indexed, sorted. Sorting is by Unicode
+ # code point, so the Devanagari title precedes the Bengali one.
+ self.assertEqual(indexed_doc['translated_titles'], ['भिन्न', 'ভগ্নাংশ'])
+ # The Bengali objective is stale, so only the Hindi one is indexed.
+ self.assertEqual(
+ indexed_doc['translated_objectives'], ['भिन्न के बारे में जानें']
+ )
+ # Only Hindi translated the tag.
+ self.assertEqual(indexed_doc['translated_tags'], ['संख्याएँ'])
+
def test_updated_exploration_is_added_correctly_to_index(self) -> None:
exp_id = 'id0'
exp_title = 'title 0'
@@ -7251,20 +7354,26 @@ def test_updated_exploration_is_added_correctly_to_index(self) -> None:
initial_exp_doc = {
'category': 'cat0',
'id': 'id0',
- 'language_code': 'en',
+ 'language_code': ['en'],
'objective': 'An objective',
'rank': 20,
'tags': [],
'title': 'title 0',
+ 'translated_titles': [],
+ 'translated_objectives': [],
+ 'translated_tags': [],
}
updated_exp_doc = {
'category': 'cat1',
'id': 'id0',
- 'language_code': 'en',
+ 'language_code': ['en'],
'objective': 'An objective',
'rank': 20,
'tags': [],
'title': 'title 0',
+ 'translated_titles': [],
+ 'translated_objectives': [],
+ 'translated_tags': [],
}
def mock_add_documents_to_index(
diff --git a/core/domain/search_services.py b/core/domain/search_services.py
index da6136047fb5a..e67557b84bb49 100644
--- a/core/domain/search_services.py
+++ b/core/domain/search_services.py
@@ -20,17 +20,20 @@
import math
-from core import utils
+from core import feconf, utils
from core.domain import (
blog_domain,
collection_domain,
exp_domain,
+ exp_fetchers,
rights_domain,
rights_manager,
+ translation_domain,
+ translation_fetchers,
)
from core.platform import models
-from typing import Final, List, Optional, Tuple, TypedDict
+from typing import Final, List, Optional, Tuple, TypedDict, Union
MYPY = False
if MYPY: # pragma: no cover
@@ -68,7 +71,7 @@ class DomainSearchDict(TypedDict):
"""Dictionary representing the search dictionary of a domain object."""
id: str
- language_code: str
+ language_code: Union[str, List[str]]
title: str
category: str
tags: List[str]
@@ -76,6 +79,108 @@ class DomainSearchDict(TypedDict):
rank: int
+class ExplorationSearchDict(DomainSearchDict):
+ """Dictionary representing the search dictionary of an exploration."""
+
+ translated_titles: List[str]
+ translated_objectives: List[str]
+ translated_tags: List[str]
+
+
+# This duplicates the check that #27083 adds as the public
+# translation_services.get_up_to_date_translation. This copy is replaced by a
+# call to that helper once #27083 merges.
+def _get_up_to_date_translation(
+ entity_translation: translation_domain.EntityTranslation,
+ content_id: str,
+) -> Optional[str]:
+ """Returns the translation of the given content ID, if it is usable.
+
+ Args:
+ entity_translation: EntityTranslation. The entity's translations.
+ content_id: str. The content ID to look up.
+
+ Returns:
+ str or None. The translated value, or None when there is no translation
+ or the translation is stale.
+ """
+ translated_content = entity_translation.translations.get(content_id)
+ # A translation that needs an update is stale because the English content
+ # changed after it was accepted, so it is not shown.
+ if (
+ translated_content is not None
+ and not translated_content.needs_update
+ and isinstance(translated_content.content_value, str)
+ ):
+ return translated_content.content_value
+ return None
+
+
+def _get_translated_search_texts(
+ exp_id: str, exp_version: int, num_tags: int
+) -> Tuple[List[str], List[str], List[str], List[str]]:
+ """Returns the translated titles, objectives, and tags for an exploration.
+
+ Args:
+ exp_id: str. The exploration ID.
+ exp_version: int. The exploration version.
+ num_tags: int. The number of tags in the exploration.
+
+ Returns:
+ tuple(list(str), list(str), list(str), list(str)). The sorted, de-duplicated
+ translated titles, translated objectives, translated tags, and translated language codes.
+ """
+ entity_translations = (
+ translation_fetchers.get_all_entity_translations_for_entity(
+ feconf.TranslatableEntityType.EXPLORATION, exp_id, exp_version
+ )
+ )
+
+ translated_titles = set()
+ translated_objectives = set()
+ translated_tags = set()
+ translated_languages = set()
+
+ for entity_translation in entity_translations:
+ has_valid_translation = False
+
+ # Title translation.
+ title_translation = _get_up_to_date_translation(
+ entity_translation, feconf.EXPLORATION_TITLE_CONTENT_ID
+ )
+ if title_translation is not None:
+ translated_titles.add(title_translation)
+ has_valid_translation = True
+
+ # Objective translation.
+ objective_translation = _get_up_to_date_translation(
+ entity_translation, feconf.EXPLORATION_OBJECTIVE_CONTENT_ID
+ )
+ if objective_translation is not None:
+ translated_objectives.add(objective_translation)
+ has_valid_translation = True
+
+ # Tags translations.
+ for idx in range(num_tags):
+ tag_content_id = f'{feconf.EXPLORATION_TAG_CONTENT_ID_PREFIX}_{idx}'
+ tag_translation = _get_up_to_date_translation(
+ entity_translation, tag_content_id
+ )
+ if tag_translation is not None:
+ translated_tags.add(tag_translation)
+ has_valid_translation = True
+
+ if has_valid_translation:
+ translated_languages.add(entity_translation.language_code)
+
+ return (
+ sorted(list(translated_titles)),
+ sorted(list(translated_objectives)),
+ sorted(list(translated_tags)),
+ sorted(list(translated_languages)),
+ )
+
+
def index_exploration_summaries(
exp_summaries: List[exp_domain.ExplorationSummary],
) -> None:
@@ -85,37 +190,86 @@ def index_exploration_summaries(
exp_summaries: list(ExplorationSummary). List of Exp Summary domain
objects to be indexed.
"""
+ indexable = [s for s in exp_summaries if _should_index_exploration(s)]
+ explorations_dict = exp_fetchers.get_multiple_explorations_by_id(
+ [s.id for s in indexable], strict=False
+ )
+
+ documents: List[ExplorationSearchDict] = []
+ for exp_summary in indexable:
+ exploration = explorations_dict.get(exp_summary.id)
+ if exploration is not None:
+ (
+ translated_titles,
+ translated_objectives,
+ translated_tags,
+ translated_languages,
+ ) = _get_translated_search_texts(
+ exp_summary.id, exploration.version, len(exploration.tags)
+ )
+ else:
+ (
+ translated_titles,
+ translated_objectives,
+ translated_tags,
+ translated_languages,
+ ) = (
+ [],
+ [],
+ [],
+ [],
+ )
+
+ documents.append(
+ _exp_summary_to_search_dict(
+ exp_summary,
+ translated_titles,
+ translated_objectives,
+ translated_tags,
+ translated_languages,
+ )
+ )
+
platform_search_services.add_documents_to_index(
- [
- _exp_summary_to_search_dict(exp_summary)
- for exp_summary in exp_summaries
- if _should_index_exploration(exp_summary)
- ],
+ documents,
SEARCH_INDEX_EXPLORATIONS,
)
def _exp_summary_to_search_dict(
exp_summary: exp_domain.ExplorationSummary,
-) -> DomainSearchDict:
+ translated_titles: List[str],
+ translated_objectives: List[str],
+ translated_tags: List[str],
+ translated_languages: List[str],
+) -> ExplorationSearchDict:
"""Updates the dict to be returned, whether the given exploration is to
be indexed for further queries or not.
Args:
exp_summary: ExplorationSummary. ExplorationSummary domain object.
+ translated_titles: list(str). List of translated titles.
+ translated_objectives: list(str). List of translated objectives.
+ translated_tags: list(str). List of translated tags.
+ translated_languages: list(str). List of translated languages.
Returns:
dict. The representation of the given exploration, in a form that can
be used by the search index.
"""
- doc: DomainSearchDict = {
+ doc: ExplorationSearchDict = {
'id': exp_summary.id,
- 'language_code': exp_summary.language_code,
+ 'language_code': sorted(
+ list(set([exp_summary.language_code] + translated_languages))
+ ),
'title': exp_summary.title,
'category': exp_summary.category,
'tags': exp_summary.tags,
'objective': exp_summary.objective,
'rank': get_search_rank_from_exp_summary(exp_summary),
+ 'translated_titles': translated_titles,
+ 'translated_objectives': translated_objectives,
+ 'translated_tags': translated_tags,
}
return doc
diff --git a/core/domain/search_services_test.py b/core/domain/search_services_test.py
index 2720589bcf229..9c79574f95517 100644
--- a/core/domain/search_services_test.py
+++ b/core/domain/search_services_test.py
@@ -18,9 +18,11 @@
from __future__ import annotations
+from core import feconf
from core.domain import (
blog_services,
collection_services,
+ exp_domain,
exp_fetchers,
exp_services,
rating_services,
@@ -35,6 +37,10 @@
gae_search_services = models.Registry.import_search_services()
+(translation_models,) = models.Registry.import_models(
+ [models.Names.TRANSLATION]
+)
+
class SearchServicesUnitTests(test_utils.GenericTestBase):
"""Test the search services module."""
@@ -285,6 +291,192 @@ def _mock_delete_docs(ids: List[str], index: str) -> None:
self.assertEqual(delete_docs_counter.times_called, 1)
+ def test_index_exploration_summaries_with_translations(self) -> None:
+ exp = exp_domain.Exploration.create_default_exploration(
+ self.EXP_ID,
+ title='Exploration Title',
+ category='Category',
+ )
+ exp.objective = 'Objective'
+ exp.tags = ['algebra', 'math']
+ exp_services.save_new_exploration(self.owner_id, exp)
+ rights_manager.publish_exploration(self.owner, self.EXP_ID)
+ exp_summary = exp_fetchers.get_exploration_summary_by_id(self.EXP_ID)
+
+ # 1. Test case: No translations.
+ indexed_docs: List[search_services.ExplorationSearchDict] = []
+
+ def mock_add_docs(
+ docs: List[search_services.ExplorationSearchDict], index: str
+ ) -> None:
+ self.assertEqual(index, search_services.SEARCH_INDEX_EXPLORATIONS)
+ indexed_docs.extend(docs)
+
+ add_docs_counter = test_utils.CallCounter(mock_add_docs)
+ add_docs_swap = self.swap(
+ gae_search_services, 'add_documents_to_index', add_docs_counter
+ )
+
+ with add_docs_swap:
+ search_services.index_exploration_summaries([exp_summary])
+
+ self.assertEqual(add_docs_counter.times_called, 1)
+ self.assertEqual(len(indexed_docs), 1)
+ self.assertEqual(indexed_docs[0]['language_code'], ['en'])
+ self.assertEqual(indexed_docs[0]['translated_titles'], [])
+ self.assertEqual(indexed_docs[0]['translated_objectives'], [])
+ self.assertEqual(indexed_docs[0]['translated_tags'], [])
+
+ # 2. Test case: Accepted translations.
+ translation_models.EntityTranslationsModel.create_new(
+ feconf.TranslatableEntityType.EXPLORATION.value,
+ self.EXP_ID,
+ 1,
+ 'hi',
+ {
+ feconf.EXPLORATION_TITLE_CONTENT_ID: {
+ 'content_value': 'translated title hi',
+ 'needs_update': False,
+ 'content_format': 'unicode',
+ },
+ feconf.EXPLORATION_OBJECTIVE_CONTENT_ID: {
+ 'content_value': 'translated objective hi',
+ 'needs_update': False,
+ 'content_format': 'unicode',
+ },
+ f'{feconf.EXPLORATION_TAG_CONTENT_ID_PREFIX}_0': {
+ 'content_value': 'translated tag hi',
+ 'needs_update': False,
+ 'content_format': 'unicode',
+ },
+ },
+ ).put()
+
+ # Let's add a second language translation.
+ translation_models.EntityTranslationsModel.create_new(
+ feconf.TranslatableEntityType.EXPLORATION.value,
+ self.EXP_ID,
+ 1,
+ 'bn',
+ {
+ feconf.EXPLORATION_TITLE_CONTENT_ID: {
+ 'content_value': 'translated title bn',
+ 'needs_update': False,
+ 'content_format': 'unicode',
+ },
+ # Stale translation (needs_update=True) for objective.
+ feconf.EXPLORATION_OBJECTIVE_CONTENT_ID: {
+ 'content_value': 'stale objective bn',
+ 'needs_update': True,
+ 'content_format': 'unicode',
+ },
+ f'{feconf.EXPLORATION_TAG_CONTENT_ID_PREFIX}_0': {
+ # Duplicate tag translation.
+ 'content_value': 'translated tag hi',
+ 'needs_update': False,
+ 'content_format': 'unicode',
+ },
+ f'{feconf.EXPLORATION_TAG_CONTENT_ID_PREFIX}_1': {
+ 'content_value': 'translated tag bn',
+ 'needs_update': False,
+ 'content_format': 'unicode',
+ },
+ },
+ ).put()
+
+ indexed_docs = []
+ add_docs_counter = test_utils.CallCounter(mock_add_docs)
+ add_docs_swap = self.swap(
+ gae_search_services, 'add_documents_to_index', add_docs_counter
+ )
+
+ with add_docs_swap:
+ search_services.index_exploration_summaries([exp_summary])
+
+ self.assertEqual(add_docs_counter.times_called, 1)
+ self.assertEqual(len(indexed_docs), 1)
+ # Verify title translations (sorted, deduplicated).
+ self.assertEqual(
+ indexed_docs[0]['translated_titles'],
+ ['translated title bn', 'translated title hi'],
+ )
+ # Verify objective translations (stale translation excluded).
+ self.assertEqual(
+ indexed_docs[0]['translated_objectives'],
+ ['translated objective hi'],
+ )
+ # Verify tag translations (sorted, deduplicated, stale/untranslated excluded).
+ self.assertEqual(
+ indexed_docs[0]['translated_tags'],
+ ['translated tag bn', 'translated tag hi'],
+ )
+ # Verify language codes (original + languages with valid translations, sorted).
+ self.assertEqual(
+ indexed_docs[0]['language_code'],
+ ['bn', 'en', 'hi'],
+ )
+
+ # 3. Test case: Exploration not found in explorations_dict (defensive check).
+ indexed_docs = []
+ add_docs_counter = test_utils.CallCounter(mock_add_docs)
+ add_docs_swap = self.swap(
+ gae_search_services, 'add_documents_to_index', add_docs_counter
+ )
+ get_multiple_swap = self.swap(
+ exp_fetchers,
+ 'get_multiple_explorations_by_id',
+ lambda *args, **kwargs: {},
+ )
+
+ with add_docs_swap, get_multiple_swap:
+ search_services.index_exploration_summaries([exp_summary])
+
+ self.assertEqual(add_docs_counter.times_called, 1)
+ self.assertEqual(len(indexed_docs), 1)
+ self.assertEqual(indexed_docs[0]['language_code'], ['en'])
+ self.assertEqual(indexed_docs[0]['translated_titles'], [])
+ self.assertEqual(indexed_docs[0]['translated_objectives'], [])
+ self.assertEqual(indexed_docs[0]['translated_tags'], [])
+
+ def test_index_exploration_summaries_batches_explorations_lookups(
+ self,
+ ) -> None:
+ # Create multiple explorations.
+ self.save_new_valid_exploration(
+ 'exp_id_1',
+ self.owner_id,
+ title='Title 1',
+ category='Category',
+ )
+ rights_manager.publish_exploration(self.owner, 'exp_id_1')
+ self.save_new_valid_exploration(
+ 'exp_id_2',
+ self.owner_id,
+ title='Title 2',
+ category='Category',
+ )
+ rights_manager.publish_exploration(self.owner, 'exp_id_2')
+ summaries = [
+ exp_fetchers.get_exploration_summary_by_id('exp_id_1'),
+ exp_fetchers.get_exploration_summary_by_id('exp_id_2'),
+ ]
+
+ # Use CallCounter to verify how many times get_multiple_explorations_by_id is called.
+ orig_get_multiple = exp_fetchers.get_multiple_explorations_by_id
+ get_multiple_counter = test_utils.CallCounter(orig_get_multiple)
+
+ get_multiple_swap = self.swap(
+ exp_fetchers,
+ 'get_multiple_explorations_by_id',
+ get_multiple_counter,
+ )
+
+ with get_multiple_swap:
+ search_services.index_exploration_summaries(summaries)
+
+ # It should be called exactly once, not N times (where N = 2).
+ self.assertEqual(get_multiple_counter.times_called, 1)
+
class BlogPostSearchServicesUnitTests(test_utils.GenericTestBase):
diff --git a/core/domain/skill_services.py b/core/domain/skill_services.py
index da08626252500..6ce5d7b04f698 100644
--- a/core/domain/skill_services.py
+++ b/core/domain/skill_services.py
@@ -37,6 +37,7 @@
topic_domain,
topic_fetchers,
topic_services,
+ translation_fetchers,
translation_services,
user_services,
)
@@ -61,6 +62,7 @@
question_models,
skill_models,
topic_models,
+ translation_models,
user_models,
)
@@ -1773,3 +1775,94 @@ def get_categorized_skill_ids_and_descriptions() -> (
)
return categorized_skills
+
+
+class ConceptCardDict(skill_domain.SkillContentsDict):
+ """Dictionary representing a concept card shown to a learner. It carries
+ the skill description alongside the skill contents, because both are
+ displayed on the card and both are translatable.
+ """
+
+ skill_description: str
+
+
+def get_concept_card_dicts(
+ skills: List[skill_domain.Skill],
+ language_code: Optional[str] = None,
+) -> List[ConceptCardDict]:
+ """Returns the concept card dicts for the given skills, with the skill
+ description and explanation translated into the given language where a
+ translation exists.
+
+ Args:
+ skills: list(Skill). The skills whose concept cards are required.
+ language_code: str or None. The language code to display the concept
+ cards in. If None or English, the original content is returned.
+
+ Returns:
+ list(ConceptCardDict). The concept card dicts for the given skills.
+ Any content without an up-to-date translation falls back to English.
+ """
+ concept_card_dicts: List[ConceptCardDict] = []
+ for skill in skills:
+ skill_contents_dict = skill.skill_contents.to_dict()
+ # Every key of SkillContentsDict is listed explicitly because mypy
+ # rejects a ** spread into a TypedDict, so a field added to
+ # SkillContentsDict has to be added here as well.
+ concept_card_dicts.append(
+ {
+ 'explanation': skill_contents_dict['explanation'],
+ 'recorded_voiceovers': skill_contents_dict[
+ 'recorded_voiceovers'
+ ],
+ 'written_translations': skill_contents_dict[
+ 'written_translations'
+ ],
+ 'skill_description': skill.description,
+ }
+ )
+
+ if (
+ language_code is None
+ or language_code == constants.DEFAULT_LANGUAGE_CODE
+ ):
+ return concept_card_dicts
+
+ entity_references: List[
+ translation_models.EntityTranslationReferenceDict
+ ] = [
+ {
+ 'entity_type': feconf.TranslatableEntityType.SKILL,
+ 'entity_id': skill.id,
+ 'entity_version': skill.version,
+ 'language_code': language_code,
+ }
+ for skill in skills
+ ]
+ entity_translations = translation_fetchers.get_multiple_entity_translations(
+ entity_references
+ )
+
+ for skill, concept_card_dict, entity_translation in zip(
+ skills, concept_card_dicts, entity_translations
+ ):
+ if entity_translation is None:
+ continue
+
+ translated_description = (
+ translation_services.get_up_to_date_translation(
+ entity_translation, feconf.SKILL_DESCRIPTION_CONTENT_ID
+ )
+ )
+ if translated_description is not None:
+ concept_card_dict['skill_description'] = translated_description
+
+ translated_explanation = (
+ translation_services.get_up_to_date_translation(
+ entity_translation, skill.skill_contents.explanation.content_id
+ )
+ )
+ if translated_explanation is not None:
+ concept_card_dict['explanation']['html'] = translated_explanation
+
+ return concept_card_dicts
diff --git a/core/domain/suggestion_services.py b/core/domain/suggestion_services.py
index 4b0bf4628e266..4e7999f86fcc3 100644
--- a/core/domain/suggestion_services.py
+++ b/core/domain/suggestion_services.py
@@ -1007,6 +1007,12 @@ def accept_suggestion(
suggestion.change_cmd.language_code,
suggestion.target_type,
)
+ if suggestion.target_type == feconf.ENTITY_TYPE_EXPLORATION:
+ # We import exp_services locally here to avoid a circular dependency
+ # with exp_services, which imports suggestion_services at the top level.
+ from core.domain import exp_services
+
+ exp_services.index_explorations_given_ids([suggestion.target_id])
# Update the community contribution stats so that the number of suggestions
# of this type that are in review decreases by one, since this
diff --git a/core/domain/suggestion_services_test.py b/core/domain/suggestion_services_test.py
index 84b6bc827c8bc..2652d5b28489c 100644
--- a/core/domain/suggestion_services_test.py
+++ b/core/domain/suggestion_services_test.py
@@ -1299,6 +1299,156 @@ def test_accept_suggestion_successfully(self) -> None:
last_message = thread_messages[len(thread_messages) - 1]
self.assertEqual(last_message.text, 'review message')
+ def test_accept_exploration_translation_suggestion_reindexes_exploration(
+ self,
+ ) -> None:
+ """Test that accepting a translation suggestion for an exploration
+ triggers exploration reindexing.
+ """
+ # Create an exploration with the content.
+ exp = exp_domain.Exploration.create_default_exploration(
+ self.target_id, title='Title', category='Category'
+ )
+ exp.states['Introduction'].update_content(
+ state_domain.SubtitledHtml('content_0', 'The content html
')
+ )
+ exp_services.save_new_exploration(self.author_id, exp)
+ caching_services.flush_memory_caches()
+
+ change_dict = {
+ 'cmd': exp_domain.CMD_ADD_WRITTEN_TRANSLATION,
+ 'state_name': 'Introduction',
+ 'content_id': 'content_0',
+ 'language_code': 'hi',
+ 'content_html': exp.states['Introduction'].content.html,
+ 'translation_html': 'Updated translation.
',
+ 'data_format': 'html',
+ }
+
+ suggestion_services.create_suggestion(
+ feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT,
+ feconf.ENTITY_TYPE_EXPLORATION,
+ self.target_id,
+ exp.version,
+ self.author_id,
+ change_dict,
+ 'test description',
+ )
+ suggestion = suggestion_services.query_suggestions(
+ [('author_id', self.author_id), ('target_id', self.target_id)]
+ )[0]
+
+ reindexed_ids = []
+
+ def mock_index_explorations(ids: List[str]) -> None:
+ reindexed_ids.extend(ids)
+
+ index_counter = test_utils.CallCounter(mock_index_explorations)
+
+ with (
+ self.swap(
+ suggestion_registry.SuggestionTranslateContent,
+ 'pre_accept_validate',
+ lambda self: None,
+ ),
+ self.swap(
+ suggestion_registry.SuggestionTranslateContent,
+ 'accept',
+ lambda self, unused_commit_message: None,
+ ),
+ self.swap(
+ opportunity_services,
+ 'update_translation_opportunity_with_accepted_suggestion',
+ lambda *args: None,
+ ),
+ self.swap(
+ exp_services,
+ 'index_explorations_given_ids',
+ index_counter,
+ ),
+ ):
+ suggestion_services.accept_suggestion(
+ suggestion.suggestion_id,
+ self.reviewer_id,
+ self.COMMIT_MESSAGE,
+ 'review message',
+ )
+
+ self.assertEqual(index_counter.times_called, 1)
+ self.assertEqual(reindexed_ids, [self.target_id])
+
+ def test_accept_skill_translation_suggestion_does_not_reindex(self) -> None:
+ """Test that accepting a translation suggestion for a skill does not
+ trigger exploration reindexing.
+ """
+ skill_id = skill_services.get_new_skill_id()
+ skill_description = 'Skill Description'
+ self.save_new_skill(
+ skill_id, self.author_id, description=skill_description
+ )
+
+ change_dict = {
+ 'cmd': exp_domain.CMD_ADD_WRITTEN_TRANSLATION,
+ 'state_name': constants.DEFAULT_SUGGESTION_STATE_NAME,
+ 'content_id': feconf.SKILL_DESCRIPTION_CONTENT_ID,
+ 'language_code': 'hi',
+ 'content_html': skill_description,
+ 'translation_html': 'Skill Description in Hindi',
+ 'data_format': 'unicode',
+ }
+
+ suggestion_services.create_suggestion(
+ feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT,
+ feconf.ENTITY_TYPE_SKILL,
+ skill_id,
+ 1,
+ self.author_id,
+ change_dict,
+ 'test description',
+ )
+ suggestion = suggestion_services.query_suggestions(
+ [('author_id', self.author_id), ('target_id', skill_id)]
+ )[0]
+
+ reindexed_ids = []
+
+ def mock_index_explorations(ids: List[str]) -> None:
+ reindexed_ids.extend(ids)
+
+ index_counter = test_utils.CallCounter(mock_index_explorations)
+
+ with (
+ self.swap(
+ suggestion_registry.SuggestionTranslateContent,
+ 'pre_accept_validate',
+ lambda self: None,
+ ),
+ self.swap(
+ suggestion_registry.SuggestionTranslateContent,
+ 'accept',
+ lambda self, unused_commit_message: None,
+ ),
+ self.swap(
+ opportunity_services,
+ 'update_translation_opportunity_with_accepted_suggestion',
+ lambda *args: None,
+ ),
+ self.swap(
+ exp_services,
+ 'index_explorations_given_ids',
+ index_counter,
+ ),
+ ):
+ suggestion_services.accept_suggestion(
+ suggestion.suggestion_id,
+ self.reviewer_id,
+ self.COMMIT_MESSAGE,
+ 'review message',
+ )
+
+ self.assertEqual(index_counter.times_called, 0)
+ self.assertEqual(reindexed_ids, [])
+
def test_accept_suggestion_raises_exception_if_suggestion_does_not_exist(
self,
) -> None:
diff --git a/core/domain/summary_services.py b/core/domain/summary_services.py
index 49e62a7d519e4..e8298c06918a6 100644
--- a/core/domain/summary_services.py
+++ b/core/domain/summary_services.py
@@ -18,7 +18,7 @@
from __future__ import annotations
-from core import utils
+from core import feconf, utils
from core.constants import constants
from core.domain import (
activity_domain,
@@ -32,6 +32,8 @@
rights_manager,
search_services,
stats_services,
+ translation_domain,
+ translation_fetchers,
user_domain,
user_services,
)
@@ -48,6 +50,10 @@
cast,
)
+MYPY = False
+if MYPY: # pragma: no cover
+ from mypy_imports import translation_models
+
class DisplayableCollectionSummaryDict(TypedDict):
"""Type for the displayable collection summary dictionary."""
@@ -86,6 +92,7 @@ class DisplayableExplorationSummaryDict(TypedDict):
visited_checkpoints_count: int
total_checkpoints_count: int
num_views: int
+ translated_metadata_fields: List[str]
class PlaythroughDict(TypedDict):
@@ -472,6 +479,7 @@ def get_exploration_metadata_dicts(
def get_displayable_exp_summary_dicts_matching_ids(
exploration_ids: List[str],
user: Optional[user_domain.UserActionsInfo] = None,
+ display_in_language_code: Optional[str] = None,
) -> List[DisplayableExplorationSummaryDict]:
"""Gets a summary of explorations in human readable form from
exploration ids.
@@ -488,6 +496,8 @@ def get_displayable_exp_summary_dicts_matching_ids(
exploration_ids: list(str). List of exploration ids.
user: UserActionsInfo or None. Object having user_id, role and actions
for given user.
+ display_in_language_code: str or None. The language code in which to
+ display translated metadata.
Returns:
list(dict). A list of exploration summary dicts in human readable form.
@@ -532,11 +542,58 @@ def get_displayable_exp_summary_dicts_matching_ids(
filtered_exploration_summaries.append(exploration_summary)
- return get_displayable_exp_summary_dicts(filtered_exploration_summaries)
+ return get_displayable_exp_summary_dicts(
+ filtered_exploration_summaries,
+ display_in_language_code=display_in_language_code,
+ )
+
+
+EXPLORATION_SUMMARY_TITLE_FIELD = 'title'
+EXPLORATION_SUMMARY_OBJECTIVE_FIELD = 'objective'
+EXPLORATION_SUMMARY_CATEGORY_FIELD = 'category'
+EXPLORATION_SUMMARY_TAGS_FIELD = 'tags'
+
+# Maps each single-valued translatable field of a displayable exploration
+# summary dict to the content ID its translation is stored under. Tags are
+# handled separately because each tag has its own content ID.
+EXPLORATION_SUMMARY_FIELD_TO_CONTENT_ID = {
+ EXPLORATION_SUMMARY_TITLE_FIELD: feconf.EXPLORATION_TITLE_CONTENT_ID,
+ EXPLORATION_SUMMARY_OBJECTIVE_FIELD: (
+ feconf.EXPLORATION_OBJECTIVE_CONTENT_ID
+ ),
+ EXPLORATION_SUMMARY_CATEGORY_FIELD: feconf.EXPLORATION_CATEGORY_CONTENT_ID,
+}
+
+
+def _get_up_to_date_translation(
+ entity_translation: translation_domain.EntityTranslation,
+ content_id: str,
+) -> Optional[str]:
+ """Returns the translation of the given content ID, if it is usable.
+
+ Args:
+ entity_translation: EntityTranslation. The entity's translations.
+ content_id: str. The content ID to look up.
+
+ Returns:
+ str or None. The translated value, or None when there is no translation
+ or the translation is stale.
+ """
+ translated_content = entity_translation.translations.get(content_id)
+ # A translation that needs an update is stale because the English content
+ # changed after it was accepted, so it is not shown.
+ if (
+ translated_content is not None
+ and not translated_content.needs_update
+ and isinstance(translated_content.content_value, str)
+ ):
+ return translated_content.content_value
+ return None
def get_displayable_exp_summary_dicts(
exploration_summaries: List[exp_domain.ExplorationSummary],
+ display_in_language_code: Optional[str] = None,
) -> List[DisplayableExplorationSummaryDict]:
"""Gets a summary of explorations in human readable form.
@@ -549,6 +606,8 @@ def get_displayable_exp_summary_dicts(
Args:
exploration_summaries: list(ExplorationSummary). List of exploration
summary objects.
+ display_in_language_code: str or None. The language code in which to
+ display translated metadata.
Returns:
list(dict). A list of exploration summary dicts in human readable form.
@@ -578,19 +637,104 @@ def get_displayable_exp_summary_dicts(
)
view_counts = [exp_stats.num_starts for exp_stats in exp_stats_list]
+ entity_translations_map: Dict[str, translation_domain.EntityTranslation] = (
+ {}
+ )
+ if (
+ display_in_language_code
+ and display_in_language_code != constants.DEFAULT_LANGUAGE_CODE
+ ):
+ # Translations are stored against the exploration's own version, and a
+ # summary's version can run ahead of it, so the version is read from
+ # the exploration rather than from the summary. This fetch only happens
+ # when a non-English display language is requested.
+ exp_ids = [
+ exp_summary.id
+ for exp_summary in exploration_summaries
+ if exp_summary is not None
+ ]
+ explorations = exp_fetchers.get_multiple_explorations_by_id(
+ exp_ids, strict=False
+ )
+ translated_exp_ids = [
+ exp_id for exp_id in exp_ids if exp_id in explorations
+ ]
+ entity_references: List[
+ translation_models.EntityTranslationReferenceDict
+ ] = [
+ {
+ 'entity_type': feconf.TranslatableEntityType.EXPLORATION,
+ 'entity_id': exp_id,
+ 'entity_version': explorations[exp_id].version,
+ 'language_code': display_in_language_code,
+ }
+ for exp_id in translated_exp_ids
+ ]
+ entity_translations_list = (
+ translation_fetchers.get_multiple_entity_translations(
+ entity_references
+ )
+ )
+ for exp_id, entity_translation in zip(
+ translated_exp_ids, entity_translations_list
+ ):
+ if entity_translation is not None:
+ entity_translations_map[exp_id] = entity_translation
+
displayable_exp_summaries = []
for ind, exploration_summary in enumerate(exploration_summaries):
if exploration_summary:
+ translated_values: Dict[str, str] = {}
+ translated_metadata_fields: List[str] = []
+ tags = list(exploration_summary.tags)
+
+ entity_translation = entity_translations_map.get(
+ exploration_summary.id
+ )
+ if entity_translation is not None:
+ for (
+ field_name,
+ content_id,
+ ) in EXPLORATION_SUMMARY_FIELD_TO_CONTENT_ID.items():
+ translated_value = _get_up_to_date_translation(
+ entity_translation, content_id
+ )
+ if translated_value is not None:
+ translated_values[field_name] = translated_value
+ translated_metadata_fields.append(field_name)
+
+ # Tag content IDs are index based, matching how they are
+ # generated in Exploration.get_translatable_contents_collection.
+ for tag_idx, _ in enumerate(exploration_summary.tags):
+ translated_tag = _get_up_to_date_translation(
+ entity_translation,
+ f'{feconf.EXPLORATION_TAG_CONTENT_ID_PREFIX}_{tag_idx}',
+ )
+ if translated_tag is not None:
+ tags[tag_idx] = translated_tag
+ if tags != exploration_summary.tags:
+ translated_metadata_fields.append(
+ EXPLORATION_SUMMARY_TAGS_FIELD
+ )
+
summary_dict: DisplayableExplorationSummaryDict = {
'id': exploration_summary.id,
- 'title': exploration_summary.title,
+ 'title': translated_values.get(
+ EXPLORATION_SUMMARY_TITLE_FIELD, exploration_summary.title
+ ),
'activity_type': constants.ACTIVITY_TYPE_EXPLORATION,
- 'category': exploration_summary.category,
+ 'category': translated_values.get(
+ EXPLORATION_SUMMARY_CATEGORY_FIELD,
+ exploration_summary.category,
+ ),
'created_on_msec': utils.get_time_in_millisecs(
exploration_summary.exploration_model_created_on
),
- 'objective': exploration_summary.objective,
+ 'objective': translated_values.get(
+ EXPLORATION_SUMMARY_OBJECTIVE_FIELD,
+ exploration_summary.objective,
+ ),
'language_code': exploration_summary.language_code,
'last_updated_msec': utils.get_time_in_millisecs(
exploration_summary.exploration_model_last_updated
@@ -603,7 +747,7 @@ def get_displayable_exp_summary_dicts(
'status': exploration_summary.status,
'ratings': exploration_summary.ratings,
'community_owned': exploration_summary.community_owned,
- 'tags': exploration_summary.tags,
+ 'tags': tags,
'thumbnail_icon_url': utils.get_thumbnail_icon_url_for_category(
exploration_summary.category
),
@@ -613,6 +757,10 @@ def get_displayable_exp_summary_dicts(
'num_views': view_counts[ind],
'visited_checkpoints_count': 0,
'total_checkpoints_count': 0,
+ # Clients fall back to their own hardcoded translation bundles
+ # for the fields that are not listed here, so the list has to
+ # say which values actually came back translated.
+ 'translated_metadata_fields': translated_metadata_fields,
}
displayable_exp_summaries.append(summary_dict)
@@ -683,13 +831,19 @@ def _get_displayable_collection_summary_dicts(
return displayable_collection_summaries
-def get_library_groups(language_codes: List[str]) -> List[LibraryGroupDict]:
+def get_library_groups(
+ language_codes: List[str],
+ display_in_language_code: Optional[str] = None,
+) -> List[LibraryGroupDict]:
"""Returns a list of groups for the library index page. Each group has a
header and a list of dicts representing activity summaries.
Args:
language_codes: list(str). A list of language codes. Only explorations
with these languages will be returned.
+ display_in_language_code: str or None. The language code to display
+ translated exploration metadata in. If None, the original metadata
+ is returned.
Returns:
list(dict). A list of groups for the library index page. Each group is
@@ -750,7 +904,9 @@ def get_library_groups(language_codes: List[str]) -> List[LibraryGroupDict]:
exp_summary_dicts = {
summary_dict['id']: summary_dict
- for summary_dict in get_displayable_exp_summary_dicts(exp_summaries)
+ for summary_dict in get_displayable_exp_summary_dicts(
+ exp_summaries, display_in_language_code=display_in_language_code
+ )
}
results: List[LibraryGroupDict] = []
@@ -873,6 +1029,7 @@ def check_activity_id_validity(
def get_featured_activity_summary_dicts(
language_codes: List[str],
+ display_in_language_code: Optional[str] = None,
) -> List[DisplayableSummaryDictsType]:
"""Returns a list of featured activities with the given language codes.
The return value is sorted according to the list stored in the datastore.
@@ -880,6 +1037,9 @@ def get_featured_activity_summary_dicts(
Args:
language_codes: list(str). A list of language codes. Only explorations
with these languages will be returned.
+ display_in_language_code: str or None. The language code to display
+ translated exploration metadata in. If None, the original metadata
+ is returned.
Returns:
list(dict). Each dict in this list represents a featured activity.
@@ -907,7 +1067,7 @@ def get_featured_activity_summary_dicts(
)
exp_summary_dicts = get_displayable_exp_summary_dicts_matching_ids(
- exploration_ids
+ exploration_ids, display_in_language_code=display_in_language_code
)
col_summary_dicts = get_displayable_collection_summary_dicts_matching_ids(
collection_ids
@@ -934,7 +1094,9 @@ def get_featured_activity_summary_dicts(
def get_top_rated_exploration_summary_dicts(
- language_codes: List[str], limit: int
+ language_codes: List[str],
+ limit: int,
+ display_in_language_code: Optional[str] = None,
) -> List[DisplayableExplorationSummaryDict]:
"""Returns a list of top rated explorations with the given language codes.
The return value is sorted in decreasing order of average rating.
@@ -943,6 +1105,9 @@ def get_top_rated_exploration_summary_dicts(
language_codes: list(str). A list of language codes. Only explorations
with these languages will be returned.
limit: int. The maximum number of explorations to return.
+ display_in_language_code: str or None. The language code to display
+ translated exploration metadata in. If None, the original metadata
+ is returned.
Returns:
list(dict). Each dict in this list represents a exploration summary in
@@ -980,16 +1145,22 @@ def get_top_rated_exploration_summary_dicts(
filtered_exp_summaries, key=sort_fnc, reverse=True
)
- return get_displayable_exp_summary_dicts(sorted_exp_summaries)
+ return get_displayable_exp_summary_dicts(
+ sorted_exp_summaries, display_in_language_code=display_in_language_code
+ )
def get_recently_published_exp_summary_dicts(
limit: int,
+ display_in_language_code: Optional[str] = None,
) -> List[DisplayableExplorationSummaryDict]:
"""Returns a list of recently published explorations.
Args:
limit: int. The maximum number of explorations to return.
+ display_in_language_code: str or None. The language code to display
+ translated exploration metadata in. If None, the original metadata
+ is returned.
Returns:
list(dict). Each dict in this list represents a featured activity in
@@ -1027,4 +1198,6 @@ def get_recently_published_exp_summary_dicts(
recently_published_exploration_summaries, key=sort_fnc, reverse=True
)
- return get_displayable_exp_summary_dicts(summaries)
+ return get_displayable_exp_summary_dicts(
+ summaries, display_in_language_code=display_in_language_code
+ )
diff --git a/core/domain/summary_services_test.py b/core/domain/summary_services_test.py
index 52d1ca82e843d..3152de9eb35ff 100644
--- a/core/domain/summary_services_test.py
+++ b/core/domain/summary_services_test.py
@@ -26,12 +26,15 @@
collection_domain,
collection_services,
exp_domain,
+ exp_fetchers,
exp_services,
exp_services_test,
rating_services,
rights_domain,
rights_manager,
summary_services,
+ translation_domain,
+ translation_services,
user_services,
)
from core.tests import test_utils
@@ -266,6 +269,7 @@ def test_get_displayable_exp_summary_dicts_matching_ids(self) -> None:
'thumbnail_bg_color': '#cc4b00',
'thumbnail_icon_url': '/subjects/Algebra.svg',
'title': 'Exploration 2 Albert title',
+ 'translated_metadata_fields': [],
}
self.assertIn('last_updated_msec', displayable_summaries[0])
self.assertDictContainsSubset(
@@ -282,6 +286,153 @@ def test_get_displayable_exp_summary_dicts_matching_ids_with_invalid_exp_id(
)
self.assertEqual(displayable_summaries, [])
+ def test_get_displayable_exp_summary_dicts_with_translated_metadata(
+ self,
+ ) -> None:
+ exp_summary_1 = exp_fetchers.get_exploration_summary_by_id(
+ self.EXP_ID_1
+ )
+ exp_summary_2 = exp_fetchers.get_exploration_summary_by_id(
+ self.EXP_ID_2
+ )
+ exp_summary_2.tags = ['tag', 'math']
+
+ translated_title = translation_domain.TranslatedContent(
+ 'Exploration 2 Hindi Title',
+ translation_domain.TranslatableContentFormat.UNICODE_STRING,
+ needs_update=False,
+ )
+ translated_objective = translation_domain.TranslatedContent(
+ 'Exploration 2 Hindi Objective',
+ translation_domain.TranslatableContentFormat.UNICODE_STRING,
+ needs_update=False,
+ )
+ translated_category = translation_domain.TranslatedContent(
+ 'Exploration 2 Hindi Category',
+ translation_domain.TranslatableContentFormat.UNICODE_STRING,
+ needs_update=False,
+ )
+ translated_tag_0 = translation_domain.TranslatedContent(
+ 'Hindi Tag 1',
+ translation_domain.TranslatableContentFormat.UNICODE_STRING,
+ needs_update=False,
+ )
+ translated_tag_1_outdated = translation_domain.TranslatedContent(
+ 'Outdated Tag 2',
+ translation_domain.TranslatableContentFormat.UNICODE_STRING,
+ needs_update=True,
+ )
+
+ translation_services.add_new_translation(
+ feconf.TranslatableEntityType.EXPLORATION,
+ self.EXP_ID_2,
+ exp_summary_2.version,
+ 'hi',
+ feconf.EXPLORATION_TITLE_CONTENT_ID,
+ translated_title,
+ )
+ translation_services.add_new_translation(
+ feconf.TranslatableEntityType.EXPLORATION,
+ self.EXP_ID_2,
+ exp_summary_2.version,
+ 'hi',
+ feconf.EXPLORATION_OBJECTIVE_CONTENT_ID,
+ translated_objective,
+ )
+ translation_services.add_new_translation(
+ feconf.TranslatableEntityType.EXPLORATION,
+ self.EXP_ID_2,
+ exp_summary_2.version,
+ 'hi',
+ feconf.EXPLORATION_CATEGORY_CONTENT_ID,
+ translated_category,
+ )
+ translation_services.add_new_translation(
+ feconf.TranslatableEntityType.EXPLORATION,
+ self.EXP_ID_2,
+ exp_summary_2.version,
+ 'hi',
+ f'{feconf.EXPLORATION_TAG_CONTENT_ID_PREFIX}_0',
+ translated_tag_0,
+ )
+ translation_services.add_new_translation(
+ feconf.TranslatableEntityType.EXPLORATION,
+ self.EXP_ID_2,
+ exp_summary_2.version,
+ 'hi',
+ f'{feconf.EXPLORATION_TAG_CONTENT_ID_PREFIX}_1',
+ translated_tag_1_outdated,
+ )
+
+ # Querying with exp_summary_1 (no translations) and exp_summary_2 (has translations).
+ displayable_summaries = (
+ summary_services.get_displayable_exp_summary_dicts(
+ [exp_summary_1, exp_summary_2], display_in_language_code='hi'
+ )
+ )
+ self.assertEqual(len(displayable_summaries), 2)
+
+ # exp_summary_1 keeps untranslated default metadata.
+ exp1_summary = next(
+ s for s in displayable_summaries if s['id'] == self.EXP_ID_1
+ )
+ self.assertEqual(exp1_summary['title'], 'Exploration 1 title')
+ self.assertEqual(exp1_summary['translated_metadata_fields'], [])
+
+ # exp_summary_2 receives translated title, objective, category, and tag 0.
+ exp2_summary = next(
+ s for s in displayable_summaries if s['id'] == self.EXP_ID_2
+ )
+ self.assertEqual(exp2_summary['title'], 'Exploration 2 Hindi Title')
+ self.assertEqual(
+ exp2_summary['objective'], 'Exploration 2 Hindi Objective'
+ )
+ self.assertEqual(
+ exp2_summary['category'], 'Exploration 2 Hindi Category'
+ )
+ self.assertEqual(exp2_summary['tags'], ['Hindi Tag 1', 'math'])
+ self.assertEqual(
+ exp2_summary['translated_metadata_fields'],
+ ['title', 'objective', 'category', 'tags'],
+ )
+
+ def test_get_displayable_exp_summary_dicts_with_partially_translated_metadata(
+ self,
+ ) -> None:
+ exp_summary_2 = exp_fetchers.get_exploration_summary_by_id(
+ self.EXP_ID_2
+ )
+ exp_summary_2.tags = ['tag1']
+
+ translated_title = translation_domain.TranslatedContent(
+ 'Exploration 2 Hindi Title Only',
+ translation_domain.TranslatableContentFormat.UNICODE_STRING,
+ needs_update=False,
+ )
+
+ translation_services.add_new_translation(
+ feconf.TranslatableEntityType.EXPLORATION,
+ self.EXP_ID_2,
+ exp_summary_2.version,
+ 'hi',
+ feconf.EXPLORATION_TITLE_CONTENT_ID,
+ translated_title,
+ )
+
+ displayable_summaries = (
+ summary_services.get_displayable_exp_summary_dicts(
+ [exp_summary_2], display_in_language_code='hi'
+ )
+ )
+ self.assertEqual(len(displayable_summaries), 1)
+ self.assertEqual(
+ displayable_summaries[0]['translated_metadata_fields'], ['title']
+ )
+ self.assertEqual(
+ displayable_summaries[0]['title'], 'Exploration 2 Hindi Title Only'
+ )
+ self.assertEqual(displayable_summaries[0]['tags'], ['tag1'])
+
def test_get_public_and_filtered_private_summary_dicts_for_creator(
self,
) -> None:
@@ -1078,6 +1229,40 @@ def test_for_recently_published_explorations(self) -> None:
test_summary_3, recently_published_exploration_summaries[0]
)
+ def test_get_recently_published_exp_summary_dicts_with_display_in_language_code(
+ self,
+ ) -> None:
+ exp_summary_1 = exp_fetchers.get_exploration_summary_by_id(
+ self.EXP_ID_1
+ )
+ translated_title = translation_domain.TranslatedContent(
+ 'Recently Published Hindi Title',
+ translation_domain.TranslatableContentFormat.UNICODE_STRING,
+ needs_update=False,
+ )
+ translation_services.add_new_translation(
+ feconf.TranslatableEntityType.EXPLORATION,
+ self.EXP_ID_1,
+ exp_summary_1.version,
+ 'hi',
+ feconf.EXPLORATION_TITLE_CONTENT_ID,
+ translated_title,
+ )
+
+ recently_published_summaries = (
+ summary_services.get_recently_published_exp_summary_dicts(
+ feconf.RECENTLY_PUBLISHED_QUERY_LIMIT_FOR_LIBRARY_PAGE,
+ display_in_language_code='hi',
+ )
+ )
+ exp1_summary = next(
+ s for s in recently_published_summaries if s['id'] == self.EXP_ID_1
+ )
+ self.assertEqual(
+ exp1_summary['title'], 'Recently Published Hindi Title'
+ )
+ self.assertEqual(exp1_summary['translated_metadata_fields'], ['title'])
+
class ActivityReferenceAccessCheckerTests(test_utils.GenericTestBase):
"""Tests for checking id validity of activities
diff --git a/core/domain/taskqueue_services.py b/core/domain/taskqueue_services.py
index eba1590f9b69c..763b5cc5b622c 100644
--- a/core/domain/taskqueue_services.py
+++ b/core/domain/taskqueue_services.py
@@ -258,6 +258,9 @@ def update_cloud_task_run_model(
cloud_task_model.current_retry_attempt = (
cloud_task_run_domain_instance.current_retry_attempt
)
+ cloud_task_model.additional_contextual_information = (
+ cloud_task_run_domain_instance.additional_contextual_information
+ )
cloud_task_model.update_timestamps()
cloud_task_model.put()
@@ -353,6 +356,9 @@ def convert_cloud_task_run_model_to_domain_object(
'current_retry_attempt': cloud_task_model.current_retry_attempt,
'last_updated': cloud_task_model.last_updated.isoformat(),
'created_on': cloud_task_model.created_on.isoformat(),
+ 'additional_contextual_information': (
+ cloud_task_model.additional_contextual_information
+ ),
}
cloud_task_run = cloud_task_domain.CloudTaskRun.from_dict(model_dict)
return cloud_task_run
@@ -388,6 +394,12 @@ def get_cloud_task_run_by_given_params(
<= model.last_updated.replace(tzinfo=datetime.timezone.utc)
<= end_datetime
)
+ and (
+ model.function_id
+ != feconf.FUNCTION_ID_TO_FUNCTION_NAME_FOR_DEFERRED_JOBS[
+ 'FUNCTION_ID_REGENERATE_VOICEOVERS_FOR_BATCH_CONTENTS'
+ ]
+ )
]
return [
convert_cloud_task_run_model_to_domain_object(model)
diff --git a/core/domain/translation_services.py b/core/domain/translation_services.py
index 4e5234b257d56..adcc93a58ac11 100644
--- a/core/domain/translation_services.py
+++ b/core/domain/translation_services.py
@@ -485,3 +485,29 @@ def save_machine_translation_provider_mapping(
model.language_to_provider_mapping = language_to_provider_mapping
model.update_timestamps()
model.put()
+
+
+def get_up_to_date_translation(
+ entity_translation: translation_domain.EntityTranslation,
+ content_id: str,
+) -> Optional[str]:
+ """Returns the translation of the given content ID, if it is usable.
+
+ Args:
+ entity_translation: EntityTranslation. The entity's translations.
+ content_id: str. The content ID to look up.
+
+ Returns:
+ str or None. The translated value, or None when there is no translation
+ or the translation is stale.
+ """
+ translated_content = entity_translation.translations.get(content_id)
+ # A translation that needs an update is stale because the English content
+ # changed after it was accepted, so it is not shown.
+ if (
+ translated_content is not None
+ and not translated_content.needs_update
+ and isinstance(translated_content.content_value, str)
+ ):
+ return translated_content.content_value
+ return None
diff --git a/core/domain/translation_services_test.py b/core/domain/translation_services_test.py
index deb58552827eb..37d78d9a800bd 100644
--- a/core/domain/translation_services_test.py
+++ b/core/domain/translation_services_test.py
@@ -970,3 +970,90 @@ def test_save_machine_translation_provider_mapping_raises_error(
translation_services.save_machine_translation_provider_mapping(
invalid_mapping
)
+
+ def test_get_up_to_date_translation_returns_translation_successfully(
+ self,
+ ) -> None:
+ translation_dict = {
+ 'content_id_1': translation_domain.TranslatedContent(
+ 'Valid translation',
+ translation_domain.TranslatableContentFormat.HTML,
+ False,
+ )
+ }
+ entity_translation = translation_domain.EntityTranslation(
+ 'exp_id',
+ feconf.TranslatableEntityType.EXPLORATION,
+ 1,
+ 'hi',
+ translation_dict,
+ )
+ self.assertEqual(
+ translation_services.get_up_to_date_translation(
+ entity_translation, 'content_id_1'
+ ),
+ 'Valid translation',
+ )
+
+ def test_get_up_to_date_translation_missing_id_returns_none(self) -> None:
+ translation_dict = {
+ 'content_id_1': translation_domain.TranslatedContent(
+ 'Valid translation',
+ translation_domain.TranslatableContentFormat.HTML,
+ False,
+ )
+ }
+ entity_translation = translation_domain.EntityTranslation(
+ 'exp_id',
+ feconf.TranslatableEntityType.EXPLORATION,
+ 1,
+ 'hi',
+ translation_dict,
+ )
+ self.assertIsNone(
+ translation_services.get_up_to_date_translation(
+ entity_translation, 'content_id_2'
+ )
+ )
+
+ def test_get_up_to_date_translation_stale_returns_none(self) -> None:
+ translation_dict = {
+ 'content_id_1': translation_domain.TranslatedContent(
+ 'Stale translation',
+ translation_domain.TranslatableContentFormat.HTML,
+ True,
+ )
+ }
+ entity_translation = translation_domain.EntityTranslation(
+ 'exp_id',
+ feconf.TranslatableEntityType.EXPLORATION,
+ 1,
+ 'hi',
+ translation_dict,
+ )
+ self.assertIsNone(
+ translation_services.get_up_to_date_translation(
+ entity_translation, 'content_id_1'
+ )
+ )
+
+ def test_get_up_to_date_translation_non_string_returns_none(self) -> None:
+ translation_dict = {
+ 'content_id_1': translation_domain.TranslatedContent(
+ ['List translation'],
+ translation_domain.TranslatableContentFormat.SET_OF_UNICODE_STRING,
+ False,
+ )
+ }
+ entity_translation = translation_domain.EntityTranslation(
+ 'exp_id',
+ feconf.TranslatableEntityType.EXPLORATION,
+ 1,
+ 'hi',
+ translation_dict,
+ )
+ self.assertIsNone(
+ translation_services.get_up_to_date_translation(
+ entity_translation, 'content_id_1'
+ )
+ )
diff --git a/core/domain/voiceover_services.py b/core/domain/voiceover_services.py
index 5bb44860917bd..9e324eecc5031 100644
--- a/core/domain/voiceover_services.py
+++ b/core/domain/voiceover_services.py
@@ -33,6 +33,7 @@
state_domain,
suggestion_services,
taskqueue_services,
+ topic_fetchers,
translation_domain,
translation_fetchers,
user_services,
@@ -47,13 +48,14 @@
MYPY = False
if MYPY: # pragma: no cover
- from mypy_imports import exp_models, voiceover_models
+ from mypy_imports import exp_models, opportunity_models, voiceover_models
(
exp_models,
+ opportunity_models,
voiceover_models,
) = models.Registry.import_models(
- [models.Names.EXPLORATION, models.Names.VOICEOVER]
+ [models.Names.EXPLORATION, models.Names.OPPORTUNITY, models.Names.VOICEOVER]
)
@@ -974,6 +976,7 @@ def regenerate_voiceovers_for_given_contents(
exploration_version: int,
language_code_to_contents_mapping: Dict[str, Dict[str, str]],
task_run_id: str,
+ additional_contextual_information: Dict[str, str],
specific_language_accent_code: Optional[str] = None,
) -> None:
"""Helper method to regenerate voiceovers for specified contents
@@ -989,6 +992,8 @@ def regenerate_voiceovers_for_given_contents(
that require voiceover regeneration.
task_run_id: str. The unique identifier for the voiceover
regeneration task.
+ additional_contextual_information: dict. A dictionary containing additional
+ contextual information related to the voiceover regeneration process.
specific_language_accent_code: Optional[str]. The specific language
accent code to use for voiceover regeneration, if provided.
"""
@@ -999,6 +1004,8 @@ def regenerate_voiceovers_for_given_contents(
# support autogeneration.
language_code_to_autogeneratable_accent_codes = {}
+ language_accent_codes_for_voiceover_regenerations = []
+
# Retrieve all Oppia-supported language accents, grouped by language code,
# for which voiceovers need to be regenerated for the given contents.
for language_code in language_codes:
@@ -1026,6 +1033,23 @@ def regenerate_voiceovers_for_given_contents(
language_code_to_autogeneratable_accent_codes,
)
+ language_accent_codes_to_descriptions = (
+ get_language_accent_codes_to_descriptions()
+ )
+
+ for (
+ language_code,
+ accent_codes,
+ ) in language_code_to_autogeneratable_accent_codes.items():
+ for accent_code in accent_codes:
+ language_accent_codes_for_voiceover_regenerations.append(
+ language_accent_codes_to_descriptions[accent_code]
+ )
+
+ additional_contextual_information['language_accents'] = ', '.join(
+ language_accent_codes_for_voiceover_regenerations
+ )
+
# Ruling out the possibility of None for mypy type checking.
assert voiceover_regeneration_job is not None
@@ -1033,6 +1057,18 @@ def regenerate_voiceovers_for_given_contents(
voiceover_regeneration_job
)
+ parent_cloud_task_run = taskqueue_services.get_cloud_task_run_by_model_id(
+ task_run_id
+ )
+ # Ruling out the possibility of None for mypy type checking.
+ assert parent_cloud_task_run is not None
+
+ parent_cloud_task_run.additional_contextual_information = (
+ additional_contextual_information
+ )
+
+ taskqueue_services.update_cloud_task_run_model(parent_cloud_task_run)
+
# Voiceover regeneration for a large number of contents within a single
# Cloud Task run (deferred request) significantly increases the workload and
# may lead to timeout failures due to Gunicorn limitations of 60 seconds.
@@ -1391,6 +1427,9 @@ def wrap_up_voiceover_regeneration_task(
)
parent_cloud_task_run.latest_job_state = 'PERMANENTLY_FAILED'
taskqueue_services.update_cloud_task_run_model(parent_cloud_task_run)
+ else:
+ parent_cloud_task_run.latest_job_state = 'SUCCEEDED'
+ taskqueue_services.update_cloud_task_run_model(parent_cloud_task_run)
voiceover_regeneration_job_status.update_remaining_content_status_as_succeeded()
voiceover_cloud_task_services.save_voiceover_regeneration_job(
@@ -1522,11 +1561,25 @@ def regenerate_voiceovers_on_exploration_update(
logging.info(
'Voiceover regeneration logs: %s' % language_code_to_contents_mapping
)
+
+ try:
+ exploration_summary = exp_fetchers.get_exploration_summary_by_id(
+ exploration_id
+ )
+ exploration_title = exploration_summary.title
+
+ additional_contextual_information = {
+ 'exploration_title': exploration_title
+ }
+ except Exception as _:
+ additional_contextual_information = {}
+
regenerate_voiceovers_for_given_contents(
exploration_id,
exploration_version,
language_code_to_contents_mapping,
task_run_id,
+ additional_contextual_information,
)
@@ -1551,6 +1604,7 @@ def regenerate_voiceovers_on_exploration_added_to_topic(
'Voiceover regeneration logs: Started regenerating voiceovers for '
'exploration with ID: %s when it is added to topic.' % exploration_id
)
+
# A dictionary where each key is a language code, and each value is a
# content mapping dictionary. The content mapping dictionary contains
# content IDs as keys and their corresponding HTML content as values.
@@ -1561,6 +1615,24 @@ def regenerate_voiceovers_on_exploration_added_to_topic(
exploration_version = exploration.version
+ try:
+ exp_opportuinity_summary_model = (
+ opportunity_models.ExplorationOpportunitySummaryModel.get_by_id(
+ exploration_id
+ )
+ )
+ topic_id = exp_opportuinity_summary_model.topic_id
+ topic = topic_fetchers.get_topic_by_id(topic_id)
+ topic_name = topic.name
+ exploration_title = exploration.title
+
+ additional_contextual_information = {
+ 'exploration_title': exploration_title,
+ 'topic_name': topic_name,
+ }
+ except Exception as _:
+ additional_contextual_information = {}
+
# Retrieve all English-language contents from the exploration.
language_code_to_contents_mapping.update(
extract_english_voiceover_texts_from_exploration(exploration)
@@ -1588,6 +1660,7 @@ def regenerate_voiceovers_on_exploration_added_to_topic(
exploration_version,
language_code_to_contents_mapping,
task_run_id,
+ additional_contextual_information,
)
@@ -1621,6 +1694,7 @@ def regenerate_voiceovers_of_exploration_for_given_language_accent(
language_accent_code,
)
)
+
# A dictionary where each key is a language code, and each value is a
# content mapping dictionary. The content mapping dictionary contains
# content IDs as keys and their corresponding HTML content as values.
@@ -1640,6 +1714,19 @@ def regenerate_voiceovers_of_exploration_for_given_language_accent(
exploration_version = exploration.version
+ try:
+ exploration_title = exploration.title
+ language_accent = get_language_accent_codes_to_descriptions().get(
+ language_accent_code, ''
+ )
+
+ additional_contextual_information = {
+ 'exploration_title': exploration_title,
+ 'language_accent': language_accent,
+ }
+ except Exception as _:
+ additional_contextual_information = {}
+
if language_code == constants.constants.DEFAULT_LANGUAGE_CODE:
# Retrieve all English-language contents from the exploration.
language_code_to_contents_mapping.update(
@@ -1667,10 +1754,30 @@ def regenerate_voiceovers_of_exploration_for_given_language_accent(
exploration_version,
language_code_to_contents_mapping,
cloud_task_run_id,
+ additional_contextual_information,
specific_language_accent_code=language_accent_code,
)
+def get_language_description_from_code(language_code: str) -> Optional[str]:
+ """Returns the language description for the given language code.
+
+ Args:
+ language_code: str. The language code for which the description is to be
+ retrieved.
+
+ Returns:
+ str|None. The language description corresponding to the given language
+ code, or None if not found.
+ """
+ for language_map in constants.constants.SUPPORTED_CONTENT_LANGUAGES:
+ if language_map['code'] == language_code:
+ description = language_map.get('description')
+ if isinstance(description, str):
+ return description
+ return None
+
+
def regenerate_voiceovers_after_accepting_suggestion(
suggestion_id: str,
task_run_id: str,
@@ -1695,6 +1802,20 @@ def regenerate_voiceovers_after_accepting_suggestion(
language_code_to_contents_mapping = {
language_code: {content_id: translated_html_content}
}
+ try:
+ exploration_summary = exp_fetchers.get_exploration_summary_by_id(
+ exploration_id
+ )
+ exploration_title = exploration_summary.title
+
+ additional_contextual_information = {
+ 'exploration_title': exploration_title,
+ 'language': get_language_description_from_code(language_code)
+ or language_code,
+ }
+ except Exception as _:
+ additional_contextual_information = {}
+
logging.info(
'Voiceover regeneration logs: %s' % language_code_to_contents_mapping
)
@@ -1703,4 +1824,5 @@ def regenerate_voiceovers_after_accepting_suggestion(
exploration_version,
language_code_to_contents_mapping,
task_run_id,
+ additional_contextual_information,
)
diff --git a/core/domain/voiceover_services_test.py b/core/domain/voiceover_services_test.py
index b219489302975..34e0238bbed89 100644
--- a/core/domain/voiceover_services_test.py
+++ b/core/domain/voiceover_services_test.py
@@ -21,15 +21,20 @@
import json
import os
import uuid
+from types import SimpleNamespace
-from core import feconf, schema_utils
-from core.constants import constants
-from core.domain import exp_domain, exp_services
+from core import constants, feconf, schema_utils
+from core.domain import exp_domain, exp_fetchers, exp_services
from core.domain import platform_parameter_list as param_list
from core.domain import (
state_domain,
+ story_domain,
+ story_services,
suggestion_services,
taskqueue_services,
+ topic_domain,
+ topic_fetchers,
+ topic_services,
translation_domain,
translation_fetchers,
voiceover_domain,
@@ -47,6 +52,7 @@
cloud_task_models,
email_models,
exp_models,
+ opportunity_models,
translation_models,
voiceover_models,
)
@@ -54,6 +60,7 @@
(
cloud_task_models,
exp_models,
+ opportunity_models,
email_models,
voiceover_models,
translation_models,
@@ -61,6 +68,7 @@
[
models.Names.CLOUD_TASK,
models.Names.EXPLORATION,
+ models.Names.OPPORTUNITY,
models.Names.EMAIL,
models.Names.VOICEOVER,
models.Names.TRANSLATION,
@@ -324,7 +332,6 @@ def test_should_get_entity_voiceovers_by_exploration_and_language_code(
'exp_id_1', 'exploration', 1, 'en'
)
)
-
self.assertEqual(len(retrieved_entity_voiceovers), 2)
def test_should_compute_entity_voiceovers_model_successfully(self) -> None:
@@ -1029,7 +1036,13 @@ class VoiceoverRegenerationTests(test_utils.GenericTestBase):
def setUp(self) -> None:
super().setUp()
self.signup(self.VIEWER_EMAIL, self.VIEWER_USERNAME)
+ self.signup(self.CURRICULUM_ADMIN_EMAIL, self.CURRICULUM_ADMIN_USERNAME)
+ self.set_curriculum_admins([self.CURRICULUM_ADMIN_USERNAME])
+ self.signup(self.OWNER_EMAIL, self.OWNER_USERNAME)
self.committer_1_id = self.get_user_id_from_email(self.VIEWER_EMAIL)
+ self.admin_id = self.get_user_id_from_email(self.CURRICULUM_ADMIN_EMAIL)
+
+ self.owner_id = self.get_user_id_from_email(self.OWNER_EMAIL)
self.old_content_html = 'old content html
'
self.new_content_html = 'new content html
'
@@ -1098,7 +1111,7 @@ def test_should_regenerate_voiceover_for_curated_exploration_content_update(
},
}
],
- constants.ACTIVITY_STATUS_PRIVATE,
+ constants.constants.ACTIVITY_STATUS_PRIVATE,
False,
)
@@ -1179,7 +1192,7 @@ def test_should_regenerate_voiceover_for_translation_update(self) -> None:
).to_dict(),
}
],
- constants.ACTIVITY_STATUS_PRIVATE,
+ constants.constants.ACTIVITY_STATUS_PRIVATE,
False,
)
@@ -1290,7 +1303,7 @@ def test_should_not_regenerate_voiceover_for_non_supported_accents(
},
}
],
- constants.ACTIVITY_STATUS_PRIVATE,
+ constants.constants.ACTIVITY_STATUS_PRIVATE,
False,
)
@@ -1449,7 +1462,7 @@ def test_should_raise_error_while_regenerating_voiceover(self) -> None:
},
}
],
- constants.ACTIVITY_STATUS_PRIVATE,
+ constants.constants.ACTIVITY_STATUS_PRIVATE,
False,
)
@@ -1576,7 +1589,7 @@ def test_should_raise_exception_while_regenerating_voiceovers_in_batch(
},
}
],
- constants.ACTIVITY_STATUS_PRIVATE,
+ constants.constants.ACTIVITY_STATUS_PRIVATE,
False,
)
@@ -1650,13 +1663,12 @@ def _create_exploration_and_arabic_translation(
exploration_id: str. The ID of the exploration to create.
language_code: str. The language code for the translation.
"""
- self.signup(self.OWNER_EMAIL, self.OWNER_USERNAME)
owner_id = self.get_user_id_from_email(self.OWNER_EMAIL)
exploration = exp_domain.Exploration.create_default_exploration(
exploration_id,
title='A Title',
- category='A Category',
+ category=constants.constants.ALL_CATEGORIES[0],
objective='An Objective',
)
exploration.states['Introduction'].content.html = 'First Card!'
@@ -1702,6 +1714,8 @@ def _create_exploration_and_arabic_translation(
exp_services.save_new_exploration(owner_id, exploration)
+ self.publish_exploration(self.owner_id, exploration_id)
+
arabic_translation = 'المحتوى المترجم'
translations_mapping: Dict[str, feconf.TranslatedContentDict] = {
'content_0': {
@@ -1733,6 +1747,67 @@ def _create_exploration_and_arabic_translation(
language_code,
translations_mapping,
).put()
+ topic_id_1 = 'topic_id_1'
+ story_id_1 = 'story_id_1'
+ curated_exploration_1 = exploration_id
+
+ topic_1 = topic_domain.Topic.create_default_topic(
+ topic_id_1, 'topic1', 'abbrev', 'description', 'fragm'
+ )
+ topic_1.thumbnail_filename = 'thumbnail.svg'
+ topic_1.thumbnail_bg_color = '#C6DCDA'
+ topic_1.subtopics = [
+ topic_domain.Subtopic(
+ 1,
+ 'Title',
+ ['skill_id_1'],
+ 'image.svg',
+ constants.constants.ALLOWED_THUMBNAIL_BG_COLORS['subtopic'][0],
+ 21131,
+ 'dummy-subtopic-url',
+ )
+ ]
+ topic_1.next_subtopic_id = 2
+ topic_1.skill_ids_for_diagnostic_test = ['skill_id_1']
+
+ topic_services.save_new_topic(owner_id, topic_1)
+ topic_services.publish_topic(topic_id_1, self.admin_id)
+
+ story_1 = story_domain.Story.create_default_story(
+ story_id_1,
+ 'A story',
+ 'Description',
+ topic_id_1,
+ 'story-two',
+ )
+ story_services.save_new_story(owner_id, story_1)
+ topic_services.add_canonical_story(owner_id, topic_id_1, story_id_1)
+
+ topic_services.publish_story(topic_id_1, story_id_1, self.admin_id)
+
+ story_services.update_story(
+ owner_id,
+ story_id_1,
+ [
+ story_domain.StoryChange(
+ {
+ 'cmd': 'add_story_node',
+ 'node_id': 'node_1',
+ 'title': 'Node1',
+ }
+ ),
+ story_domain.StoryChange(
+ {
+ 'cmd': 'update_story_node_property',
+ 'property_name': 'exploration_id',
+ 'node_id': 'node_1',
+ 'old_value': None,
+ 'new_value': curated_exploration_1,
+ }
+ ),
+ ],
+ 'Changes.',
+ )
def test_should_regenerate_voiceover_for_arabic_language(self) -> None:
language_accent_code = 'ar-AE'
@@ -1841,6 +1916,316 @@ def test_should_raise_exception_when_language_accent_code_is_not_supported(
)
)
+ def test_should_return_none_when_language_description_has_non_string_value(
+ self,
+ ) -> None:
+ self.swap(
+ constants.constants,
+ 'SUPPORTED_CONTENT_LANGUAGES',
+ [{'code': 'en', 'description': 123}],
+ )
+
+ self.assertIsNone(
+ voiceover_services.get_language_description_from_code('error')
+ )
+
+ def test_should_use_empty_context_when_title_lookup_fails_for_language_accent_regeneration(
+ self,
+ ) -> None:
+ class ExploitationWithBrokenTitle:
+ """A mock exploration class that raises an exception when the
+ title is accessed.
+ """
+
+ version = 1
+
+ @property
+ def title(self) -> str:
+ """A mock exploration class method that raises an exception
+ when the title is accessed.
+
+ Raises:
+ Exception. Always raises an exception to simulate a title lookup failure.
+ """
+ raise Exception('title lookup failed')
+
+ exploration = ExploitationWithBrokenTitle()
+ captured_additional_contextual_information = {}
+
+ def mock_regenerate_voiceovers_for_given_contents(
+ _exploration_id: str,
+ _exploration_version: int,
+ _language_code_to_contents_mapping: Dict[str, Dict[str, str]],
+ _task_run_id: str,
+ additional_contextual_information: Dict[str, str],
+ specific_language_accent_code: str | None = None,
+ ) -> None:
+ _ = specific_language_accent_code
+ captured_additional_contextual_information.update(
+ additional_contextual_information
+ )
+
+ with self.swap(
+ exp_fetchers,
+ 'get_exploration_by_id',
+ lambda _exploration_id: exploration,
+ ):
+ with self.swap(
+ voiceover_services,
+ 'extract_english_voiceover_texts_from_exploration',
+ lambda _exploration: {},
+ ):
+ with self.swap(
+ voiceover_services,
+ 'regenerate_voiceovers_for_given_contents',
+ mock_regenerate_voiceovers_for_given_contents,
+ ):
+ voiceover_services.regenerate_voiceovers_of_exploration_for_given_language_accent(
+ 'exp_id_1', 'en-US', 'task_run_id'
+ )
+
+ self.assertDictEqual(captured_additional_contextual_information, {})
+
+ def test_should_use_exploration_title_context_when_summary_lookup_succeeds(
+ self,
+ ) -> None:
+ exploration_id = 'exp_id_1'
+ exploration_version = 2
+ captured_additional_contextual_information = {}
+
+ commit_log_entry = exp_models.ExplorationCommitLogEntryModel.create(
+ exploration_id,
+ exploration_version,
+ self.committer_1_id,
+ 'msg',
+ 'create',
+ [
+ {
+ 'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
+ 'property_name': exp_domain.STATE_PROPERTY_CONTENT,
+ 'state_name': 'State 1',
+ 'old_value': {
+ 'content_id': 'content_5',
+ 'html': self.old_content_html,
+ },
+ 'new_value': {
+ 'content_id': 'content_5',
+ 'html': self.new_content_html,
+ },
+ }
+ ],
+ constants.constants.ACTIVITY_STATUS_PRIVATE,
+ False,
+ )
+ commit_log_entry.exploration_id = exploration_id
+ commit_log_entry.update_timestamps()
+ commit_log_entry.put()
+
+ def mock_regenerate_voiceovers_for_given_contents(
+ _exploration_id: str,
+ _exploration_version: int,
+ _language_code_to_contents_mapping: Dict[str, Dict[str, str]],
+ _task_run_id: str,
+ additional_contextual_information: Dict[str, str],
+ specific_language_accent_code: str | None = None,
+ ) -> None:
+ _ = specific_language_accent_code
+ captured_additional_contextual_information.update(
+ additional_contextual_information
+ )
+
+ with self.swap(
+ exp_fetchers,
+ 'get_exploration_summary_by_id',
+ lambda _exploration_id: SimpleNamespace(title='Test Exploration'),
+ ):
+ with self.swap(
+ voiceover_services,
+ 'regenerate_voiceovers_for_given_contents',
+ mock_regenerate_voiceovers_for_given_contents,
+ ):
+ voiceover_services.regenerate_voiceovers_on_exploration_update(
+ exploration_id, exploration_version, 'task_run_id'
+ )
+
+ self.assertDictEqual(
+ captured_additional_contextual_information,
+ {'exploration_title': 'Test Exploration'},
+ )
+
+ def test_should_use_topic_and_exploration_context_when_topic_lookup_succeeds(
+ self,
+ ) -> None:
+ captured_additional_contextual_information = {}
+
+ def mock_regenerate_voiceovers_for_given_contents(
+ _exploration_id: str,
+ _exploration_version: int,
+ _language_code_to_contents_mapping: Dict[str, Dict[str, str]],
+ _task_run_id: str,
+ additional_contextual_information: Dict[str, str],
+ specific_language_accent_code: str | None = None,
+ ) -> None:
+ _ = specific_language_accent_code
+ captured_additional_contextual_information.update(
+ additional_contextual_information
+ )
+
+ with self.swap(
+ exp_fetchers,
+ 'get_exploration_by_id',
+ lambda _exploration_id: SimpleNamespace(
+ version=1, title='Curated Exploration'
+ ),
+ ):
+ with self.swap(
+ opportunity_models.ExplorationOpportunitySummaryModel,
+ 'get_by_id',
+ lambda _exploration_id: SimpleNamespace(topic_id='topic_1'),
+ ):
+ with self.swap(
+ topic_fetchers,
+ 'get_topic_by_id',
+ lambda _topic_id: SimpleNamespace(name='Sample Topic'),
+ ):
+ with self.swap(
+ voiceover_services,
+ 'extract_english_voiceover_texts_from_exploration',
+ lambda _exploration: {},
+ ):
+ with self.swap(
+ translation_fetchers,
+ 'get_all_entity_translations_for_entity',
+ lambda *_args, **_kwargs: [],
+ ):
+ with self.swap(
+ voiceover_services,
+ 'extract_translated_voiceover_texts_from_entity_translations',
+ lambda _entity_translations: {},
+ ):
+ with self.swap(
+ voiceover_services,
+ 'regenerate_voiceovers_for_given_contents',
+ mock_regenerate_voiceovers_for_given_contents,
+ ):
+ voiceover_services.regenerate_voiceovers_on_exploration_added_to_topic(
+ 'exp_id_1', 'task_run_id'
+ )
+
+ self.assertDictEqual(
+ captured_additional_contextual_information,
+ {
+ 'exploration_title': 'Curated Exploration',
+ 'topic_name': 'Sample Topic',
+ },
+ )
+
+ def test_should_use_empty_context_when_topic_lookup_fails_for_curated_regeneration(
+ self,
+ ) -> None:
+ captured_additional_contextual_information = {}
+
+ def mock_regenerate_voiceovers_for_given_contents(
+ _exploration_id: str,
+ _exploration_version: int,
+ _language_code_to_contents_mapping: Dict[str, Dict[str, str]],
+ _task_run_id: str,
+ additional_contextual_information: Dict[str, str],
+ specific_language_accent_code: str | None = None,
+ ) -> None:
+ _ = specific_language_accent_code
+ captured_additional_contextual_information.update(
+ additional_contextual_information
+ )
+
+ with self.swap(
+ exp_fetchers,
+ 'get_exploration_by_id',
+ lambda _exploration_id: SimpleNamespace(
+ version=1, title='Curated Exploration'
+ ),
+ ):
+ with self.swap(
+ opportunity_models.ExplorationOpportunitySummaryModel,
+ 'get_by_id',
+ lambda _exploration_id: (_ for _ in ()).throw(
+ Exception('topic lookup failed')
+ ),
+ ):
+ with self.swap(
+ voiceover_services,
+ 'extract_english_voiceover_texts_from_exploration',
+ lambda _exploration: {},
+ ):
+ with self.swap(
+ translation_fetchers,
+ 'get_all_entity_translations_for_entity',
+ lambda *_args, **_kwargs: [],
+ ):
+ with self.swap(
+ voiceover_services,
+ 'extract_translated_voiceover_texts_from_entity_translations',
+ lambda _entity_translations: {},
+ ):
+ with self.swap(
+ voiceover_services,
+ 'regenerate_voiceovers_for_given_contents',
+ mock_regenerate_voiceovers_for_given_contents,
+ ):
+ voiceover_services.regenerate_voiceovers_on_exploration_added_to_topic(
+ 'exp_id_1', 'task_run_id'
+ )
+
+ self.assertDictEqual(captured_additional_contextual_information, {})
+
+ def test_should_use_empty_context_when_exploration_summary_lookup_fails_after_accepting_suggestion(
+ self,
+ ) -> None:
+ captured_additional_contextual_information = {}
+
+ suggestion = SimpleNamespace(
+ change_cmd=SimpleNamespace(
+ translation_html='translated
', content_id='content_0'
+ ),
+ language_code='en',
+ target_id='exp_id_1',
+ target_version_at_submission=1,
+ )
+
+ def mock_regenerate_voiceovers_for_given_contents(
+ _exploration_id: str,
+ _exploration_version: int,
+ _language_code_to_contents_mapping: Dict[str, Dict[str, str]],
+ _task_run_id: str,
+ additional_contextual_information: Dict[str, str],
+ ) -> None:
+ captured_additional_contextual_information.update(
+ additional_contextual_information
+ )
+
+ with self.swap(
+ suggestion_services,
+ 'get_suggestion_by_id',
+ lambda _suggestion_id: suggestion,
+ ):
+ with self.swap(
+ exp_fetchers,
+ 'get_exploration_summary_by_id',
+ lambda _exploration_id: (_ for _ in ()).throw(
+ Exception('summary lookup failed')
+ ),
+ ):
+ with self.swap(
+ voiceover_services,
+ 'regenerate_voiceovers_for_given_contents',
+ mock_regenerate_voiceovers_for_given_contents,
+ ):
+ voiceover_services.regenerate_voiceovers_after_accepting_suggestion(
+ 'suggestion_id', 'task_run_id'
+ )
+
+ self.assertDictEqual(captured_additional_contextual_information, {})
+
def test_should_regenerate_voiceover_for_english_language(self) -> None:
language_accent_code = 'en-US'
language_code = 'en'
@@ -2063,13 +2448,12 @@ def test_should_generate_voiceover_for_translated_content(self) -> None:
exploration_version = 1
language_accent_code = 'hi-IN'
- self.signup(self.OWNER_EMAIL, self.OWNER_USERNAME)
- owner_id = self.get_user_id_from_email(self.OWNER_EMAIL)
+ owner_id = self.owner_id
exploration = exp_domain.Exploration.create_default_exploration(
exploration_id,
title='A Title',
- category='A Category',
+ category=constants.constants.ALL_CATEGORIES[0],
objective='An Objective',
)
exploration.states['Introduction'].content.html = 'First Card!'
diff --git a/core/feature_flag_list.py b/core/feature_flag_list.py
index 9c129309eb8fe..e0efc849251e7 100644
--- a/core/feature_flag_list.py
+++ b/core/feature_flag_list.py
@@ -152,10 +152,6 @@ class FeatureNames(enum.Enum):
FeatureNames.CD_ALLOW_UNDOING_TRANSLATION_REVIEW,
FeatureNames.SHOW_VOICEOVER_TAB_FOR_NON_CURATED_EXPLORATIONS,
FeatureNames.NEW_LESSON_PLAYER,
- FeatureNames.AUTOMATIC_VOICEOVER_REGENERATION_FROM_EXP,
- FeatureNames.HIGHLIGHT_SENTENCES_DURING_AUTOMATIC_VOICEOVER_PLAYBACK,
- FeatureNames.SHOW_REGENERATED_VOICEOVERS_TO_LEARNERS,
- FeatureNames.ENABLE_BACKGROUND_VOICEOVER_SYNTHESIS,
FeatureNames.ENABLE_FINANCIAL_LITERACY_CAMPAIGN_BANNER_TEST_MODE,
FeatureNames.WEB_FEEDBACK_MODAL_ENABLED,
FeatureNames.ENABLE_TRANSLATION_OPPORTUNITIES_WITH_NEW_OPP_MODELS,
@@ -173,6 +169,10 @@ class FeatureNames(enum.Enum):
FeatureNames.SHOW_REDESIGNED_LEARNER_DASHBOARD,
FeatureNames.SHOW_RESTRUCTURED_STUDY_GUIDES,
FeatureNames.ENABLE_FINANCIAL_LITERACY_CAMPAIGN_BANNER,
+ FeatureNames.AUTOMATIC_VOICEOVER_REGENERATION_FROM_EXP,
+ FeatureNames.HIGHLIGHT_SENTENCES_DURING_AUTOMATIC_VOICEOVER_PLAYBACK,
+ FeatureNames.SHOW_REGENERATED_VOICEOVERS_TO_LEARNERS,
+ FeatureNames.ENABLE_BACKGROUND_VOICEOVER_SYNTHESIS,
]
# Names of features that should not be used anymore, e.g. features that are
@@ -290,7 +290,7 @@ class FeatureNames(enum.Enum):
(
'The flag enables the automatic regeneration of voiceovers '
'directly from the exploration editor page.',
- feature_flag_domain.ServerMode.TEST,
+ feature_flag_domain.ServerMode.PROD,
)
),
FeatureNames.SHOW_VOICEOVER_TAB_FOR_NON_CURATED_EXPLORATIONS.value: (
@@ -319,7 +319,7 @@ class FeatureNames(enum.Enum):
(
'This flag allows learners to see the regenerated voiceovers '
'in the exploration player.',
- feature_flag_domain.ServerMode.TEST,
+ feature_flag_domain.ServerMode.PROD,
)
),
FeatureNames.HIGHLIGHT_SENTENCES_DURING_AUTOMATIC_VOICEOVER_PLAYBACK.value: (
@@ -327,14 +327,14 @@ class FeatureNames(enum.Enum):
'This flag enables the highlighting of sentences during the '
'automatic voiceover playback in the exploration player and '
'editor pages.',
- feature_flag_domain.ServerMode.TEST,
+ feature_flag_domain.ServerMode.PROD,
)
),
FeatureNames.ENABLE_BACKGROUND_VOICEOVER_SYNTHESIS.value: (
(
'The flag enables the asynchronous voiceover synthesis for the '
'curated exploration contents.',
- feature_flag_domain.ServerMode.TEST,
+ feature_flag_domain.ServerMode.PROD,
)
),
FeatureNames.ENABLE_READY_FOR_REVIEW_TEST.value: (
diff --git a/core/jobs/batch_jobs/exp_end_state_normalization_jobs.py b/core/jobs/batch_jobs/exp_end_state_normalization_jobs.py
new file mode 100644
index 0000000000000..b037415e6de22
--- /dev/null
+++ b/core/jobs/batch_jobs/exp_end_state_normalization_jobs.py
@@ -0,0 +1,123 @@
+# coding: utf-8
+#
+# Copyright 2026 The Oppia Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS-IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Jobs used for migrating the terminal state of the Exploration to have texts inside P tag."""
+
+from __future__ import annotations
+
+from core.jobs import base_jobs
+from core.jobs.io import ndb_io
+from core.jobs.types import job_run_result
+from core.platform import models
+
+import apache_beam as beam
+from typing import Optional
+
+MYPY = False
+if MYPY: # pragma: no cover
+ from mypy_imports import datastore_services, exp_models
+
+(base_models, exp_models) = models.Registry.import_models(
+ [models.Names.BASE_MODEL, models.Names.EXPLORATION]
+)
+datastore_services = models.Registry.import_datastore_services()
+
+
+class ExplorationEndStateNormalizationJob(base_jobs.JobBase):
+ """A one-off job for normalizing the end state of explorations."""
+
+ DEFAULT_TERMINAL_STATE_CONTENT_WITHOUT_P_TAG = (
+ 'Congratulations, you have finished!'
+ )
+ DATASTORE_UPDATES_ALLOWED = True
+
+ def update_end_state_content(
+ self, exploration_model: exp_models.ExplorationModel
+ ) -> Optional[exp_models.ExplorationModel]:
+ """Updates the end state content of the exploration to have texts inside P tag.
+
+ Args:
+ exploration_model: exp_models.ExplorationModel. The exploration model to be updated.
+
+ Returns:
+ Optional[exp_models.ExplorationModel]. The updated exploration
+ model if any changes were made, else None.
+ """
+ is_exploration_updated = False
+
+ with datastore_services.get_ndb_context():
+ states = getattr(exploration_model, 'states', None) or {}
+
+ for state in states.values():
+ if (
+ state.get('content', {}).get('html')
+ == self.DEFAULT_TERMINAL_STATE_CONTENT_WITHOUT_P_TAG
+ ):
+ state['content'][
+ 'html'
+ ] = f'{self.DEFAULT_TERMINAL_STATE_CONTENT_WITHOUT_P_TAG}
'
+ is_exploration_updated = True
+
+ if is_exploration_updated:
+ return exploration_model
+ else:
+ return None
+
+ def run(self) -> beam.PCollection[job_run_result.JobRunResult]:
+ """Returns a PCollection of job run results for ExplorationModels
+ that were updated after end state normalization.
+
+ Returns:
+ beam.PCollection[job_run_result.JobRunResult]. A PCollection
+ containing job run results with the IDs of the
+ ExplorationModels that were updated or created.
+ """
+ exploration_models = (
+ self.pipeline
+ | 'Get exploration models'
+ >> ndb_io.GetModels(exp_models.ExplorationModel.get_all())
+ )
+
+ updated_exploration_models = (
+ exploration_models
+ | 'Update end state content'
+ >> beam.Map(self.update_end_state_content)
+ | 'Filter out None results'
+ >> beam.Filter(lambda exploration: exploration is not None)
+ )
+
+ if self.DATASTORE_UPDATES_ALLOWED:
+ _ = (
+ updated_exploration_models
+ | 'Put models into datastore' >> ndb_io.PutModels()
+ )
+
+ return updated_exploration_models | 'Format results' >> beam.Map(
+ lambda exploration: job_run_result.JobRunResult(
+ stdout=(
+ f'Updated exploration with ID: {exploration.id} '
+ 'after end state normalization.'
+ )
+ )
+ )
+
+
+class ExplorationEndStateNormalizationAuditJob(
+ ExplorationEndStateNormalizationJob
+):
+ """A one-off job for auditing the end state of explorations."""
+
+ DATASTORE_UPDATES_ALLOWED = False
diff --git a/core/jobs/batch_jobs/exp_end_state_normalization_jobs_test.py b/core/jobs/batch_jobs/exp_end_state_normalization_jobs_test.py
new file mode 100644
index 0000000000000..ec44c6216ef3e
--- /dev/null
+++ b/core/jobs/batch_jobs/exp_end_state_normalization_jobs_test.py
@@ -0,0 +1,130 @@
+# coding: utf-8
+#
+# Copyright 2026 The Oppia Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS-IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Tests for exp_end_state_normalization_jobs."""
+
+from __future__ import annotations
+
+from core.domain import exp_domain, exp_services
+from core.jobs import job_test_utils
+from core.jobs.batch_jobs import exp_end_state_normalization_jobs
+from core.jobs.types import job_run_result
+from core.platform import models
+
+MYPY = False
+if MYPY: # pragma: no cover
+ from mypy_imports import datastore_services, exp_models
+
+(exp_models,) = models.Registry.import_models([models.Names.EXPLORATION])
+datastore_services = models.Registry.import_datastore_services()
+
+
+class ExplorationEndStateNormalizationJobTests(job_test_utils.JobTestBase):
+ """Tests for ExplorationEndStateNormalizationJob."""
+
+ JOB_CLASS = (
+ exp_end_state_normalization_jobs.ExplorationEndStateNormalizationJob
+ )
+
+ def test_job_skips_exploration_with_none_states(self) -> None:
+ """Tests that the job does not fail on exploration models with None states."""
+ exploration = exp_domain.Exploration.create_default_exploration(
+ 'exp_id', title='Test Exploration', category='Test'
+ )
+ exp_services.save_new_exploration('owner_id', exploration)
+
+ exploration_model = exp_models.ExplorationModel.get_by_id('exp_id')
+ self.assertIsNotNone(exploration_model)
+ assert exploration_model is not None
+
+ exploration_model.states = None
+ exploration_model.update_timestamps(update_last_updated_time=False)
+ datastore_services.put_multi([exploration_model])
+
+ self.assert_job_output_is_empty()
+
+ def test_job_updates_content_without_paragraph_tag(self) -> None:
+ """Tests that the job reports updates when terminal content has no p-tag."""
+ exploration = exp_domain.Exploration.create_default_exploration(
+ 'exp_id', title='Test Exploration', category='Test'
+ )
+ exp_services.save_new_exploration('owner_id', exploration)
+
+ exploration_model = exp_models.ExplorationModel.get_by_id('exp_id')
+ self.assertIsNotNone(exploration_model)
+ assert exploration_model is not None
+
+ exploration_model.states['Introduction']['content'][
+ 'html'
+ ] = self.JOB_CLASS.DEFAULT_TERMINAL_STATE_CONTENT_WITHOUT_P_TAG
+ exploration_model.update_timestamps(update_last_updated_time=False)
+ datastore_services.put_multi([exploration_model])
+
+ self.assert_job_output_is(
+ [
+ job_run_result.JobRunResult.as_stdout(
+ 'Updated exploration with ID: exp_id '
+ 'after end state normalization.'
+ )
+ ]
+ )
+
+
+class ExplorationEndStateNormalizationAuditJobTests(job_test_utils.JobTestBase):
+ """Tests for ExplorationEndStateNormalizationAuditJob."""
+
+ JOB_CLASS = (
+ exp_end_state_normalization_jobs.ExplorationEndStateNormalizationAuditJob
+ )
+
+ def test_audit_job_reports_updates_without_saving_models(self) -> None:
+ """Tests that audit job reports matching explorations but does not update them."""
+ exploration = exp_domain.Exploration.create_default_exploration(
+ 'exp_id', title='Test Exploration', category='Test'
+ )
+ exp_services.save_new_exploration('owner_id', exploration)
+
+ exploration_model = exp_models.ExplorationModel.get_by_id('exp_id')
+ self.assertIsNotNone(exploration_model)
+ assert exploration_model is not None
+
+ exploration_model.states['Introduction']['content'][
+ 'html'
+ ] = (
+ exp_end_state_normalization_jobs.ExplorationEndStateNormalizationJob.DEFAULT_TERMINAL_STATE_CONTENT_WITHOUT_P_TAG
+ )
+ exploration_model.update_timestamps(update_last_updated_time=False)
+ datastore_services.put_multi([exploration_model])
+
+ self.assert_job_output_is(
+ [
+ job_run_result.JobRunResult.as_stdout(
+ 'Updated exploration with ID: exp_id '
+ 'after end state normalization.'
+ )
+ ]
+ )
+
+ updated_exploration_model = exp_models.ExplorationModel.get_by_id(
+ 'exp_id'
+ )
+ self.assertIsNotNone(updated_exploration_model)
+ assert updated_exploration_model is not None
+
+ self.assertEqual(
+ updated_exploration_model.states['Introduction']['content']['html'],
+ exp_end_state_normalization_jobs.ExplorationEndStateNormalizationJob.DEFAULT_TERMINAL_STATE_CONTENT_WITHOUT_P_TAG,
+ )
diff --git a/core/jobs/batch_jobs/exp_search_indexing_jobs_test.py b/core/jobs/batch_jobs/exp_search_indexing_jobs_test.py
index cbb204cf31b21..564d489e30ffe 100644
--- a/core/jobs/batch_jobs/exp_search_indexing_jobs_test.py
+++ b/core/jobs/batch_jobs/exp_search_indexing_jobs_test.py
@@ -72,12 +72,15 @@ def test_indexes_non_deleted_model(self) -> None:
[
{
'id': 'abcd',
- 'language_code': 'lang',
+ 'language_code': ['lang'],
'title': 'title',
'category': 'category',
'tags': [],
'objective': 'objective',
'rank': 20,
+ 'translated_titles': [],
+ 'translated_objectives': [],
+ 'translated_tags': [],
}
],
search_services.SEARCH_INDEX_EXPLORATIONS,
@@ -115,12 +118,15 @@ def test_indexes_non_deleted_models(self) -> None:
[
{
'id': 'abcd%s' % i,
- 'language_code': 'lang',
+ 'language_code': ['lang'],
'title': 'title',
'category': 'category',
'tags': [],
'objective': 'objective',
'rank': 20,
+ 'translated_titles': [],
+ 'translated_objectives': [],
+ 'translated_tags': [],
}
],
search_services.SEARCH_INDEX_EXPLORATIONS,
@@ -170,12 +176,15 @@ def add_docs_to_index_mock(
[
{
'id': 'abcd',
- 'language_code': 'lang',
+ 'language_code': ['lang'],
'title': 'title',
'category': 'category',
'tags': [],
'objective': 'objective',
'rank': 20,
+ 'translated_titles': [],
+ 'translated_objectives': [],
+ 'translated_tags': [],
}
],
search_services.SEARCH_INDEX_EXPLORATIONS,
diff --git a/core/jobs/registry.py b/core/jobs/registry.py
index 64ab5710887be..0182f596f3f9f 100644
--- a/core/jobs/registry.py
+++ b/core/jobs/registry.py
@@ -56,6 +56,7 @@
delete_duplicate_content_ids_jobs,
dummy_jobs,
email_deletion_jobs,
+ exp_end_state_normalization_jobs,
exp_migration_jobs,
exp_search_indexing_jobs,
exploration_voiceover_sync_jobs,
diff --git a/core/platform/search/elastic_search_services.py b/core/platform/search/elastic_search_services.py
index 6ddd323cda6a5..bf02c0699005a 100644
--- a/core/platform/search/elastic_search_services.py
+++ b/core/platform/search/elastic_search_services.py
@@ -328,7 +328,14 @@ def search(
'multi_match': {
'query': query_string,
'type': 'bool_prefix',
- 'fields': ['title^3', 'objective^2'],
+ 'fields': [
+ 'title^3',
+ 'objective^2',
+ 'tags',
+ 'translated_titles^3',
+ 'translated_objectives^2',
+ 'translated_tags',
+ ],
}
}
]
diff --git a/core/platform/search/elastic_search_services_test.py b/core/platform/search/elastic_search_services_test.py
index 410021445bb6d..6956f6abf1c54 100644
--- a/core/platform/search/elastic_search_services_test.py
+++ b/core/platform/search/elastic_search_services_test.py
@@ -220,7 +220,14 @@ def mock_search(
'multi_match': {
'query': 'query',
'type': 'bool_prefix',
- 'fields': ['title^3', 'objective^2'],
+ 'fields': [
+ 'title^3',
+ 'objective^2',
+ 'tags',
+ 'translated_titles^3',
+ 'translated_objectives^2',
+ 'translated_tags',
+ ],
}
}
],
diff --git a/core/storage/cloud_task/gae_models.py b/core/storage/cloud_task/gae_models.py
index c204ee257a15d..366ec1ecc8a7c 100644
--- a/core/storage/cloud_task/gae_models.py
+++ b/core/storage/cloud_task/gae_models.py
@@ -140,6 +140,13 @@ class CloudTaskRunModel(base_models.BaseModel):
required=True, indexed=True, default=0
)
+ # This property stores additional contextual information related to the cloud
+ # task run—such as exploration title, topic name, or language accent. It is
+ # not used for querying and is intended solely for display purposes in the UI.
+ additional_contextual_information = datastore_services.JsonProperty(
+ required=False, indexed=False, default={}
+ )
+
@staticmethod
def get_deletion_policy() -> base_models.DELETION_POLICY:
"""Model doesn't contain any data directly corresponding to a user.
@@ -176,6 +183,9 @@ def get_export_policy(cls) -> Dict[str, base_models.EXPORT_POLICY]:
base_models.EXPORT_POLICY.NOT_APPLICABLE
),
'current_retry_attempt': base_models.EXPORT_POLICY.NOT_APPLICABLE,
+ 'additional_contextual_information': (
+ base_models.EXPORT_POLICY.NOT_APPLICABLE
+ ),
},
)
diff --git a/core/storage/cloud_task/gae_models_test.py b/core/storage/cloud_task/gae_models_test.py
index ca33351550704..55c21e406e875 100644
--- a/core/storage/cloud_task/gae_models_test.py
+++ b/core/storage/cloud_task/gae_models_test.py
@@ -49,6 +49,9 @@ def test_get_export_policy_not_applicable(self) -> None:
'exception_messages_for_failed_runs': (
base_models.EXPORT_POLICY.NOT_APPLICABLE
),
+ 'additional_contextual_information': (
+ base_models.EXPORT_POLICY.NOT_APPLICABLE
+ ),
'latest_job_state': base_models.EXPORT_POLICY.NOT_APPLICABLE,
'function_id': base_models.EXPORT_POLICY.NOT_APPLICABLE,
'created_on': base_models.EXPORT_POLICY.NOT_APPLICABLE,
diff --git a/core/templates/components/button-directives/primary-button.component.css b/core/templates/components/button-directives/primary-button.component.css
index 975ce3ea93e01..399b59eaf96de 100644
--- a/core/templates/components/button-directives/primary-button.component.css
+++ b/core/templates/components/button-directives/primary-button.component.css
@@ -15,6 +15,7 @@
font-family: "Roboto", Arial, sans-serif;
font-weight: 500;
letter-spacing: 0.7px;
+ margin-left: 40px;
}
.primary-button:hover,
diff --git a/core/templates/components/certificate-assessment-offering-helper/assessment-interrupt.component.css b/core/templates/components/certificate-assessment-offering-helper/assessment-interrupt.component.css
index aebd23fd046bd..ffb8410babe8d 100644
--- a/core/templates/components/certificate-assessment-offering-helper/assessment-interrupt.component.css
+++ b/core/templates/components/certificate-assessment-offering-helper/assessment-interrupt.component.css
@@ -1,5 +1,16 @@
* { box-sizing: border-box; }
+.assessment-interrupt-overlay {
+ align-items: center;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ inset: 0;
+ justify-content: center;
+ padding: 16px;
+ position: fixed;
+ z-index: 1000;
+}
+
.assessment-interrupt-card {
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.12);
diff --git a/core/templates/components/certificate-assessment-offering-helper/assessment-interrupt.component.html b/core/templates/components/certificate-assessment-offering-helper/assessment-interrupt.component.html
index e8ba66e128752..45d4af544bef2 100644
--- a/core/templates/components/certificate-assessment-offering-helper/assessment-interrupt.component.html
+++ b/core/templates/components/certificate-assessment-offering-helper/assessment-interrupt.component.html
@@ -1,32 +1,34 @@
-
-
{{ 'I18N_ASSESSMENT_INTERRUPT_TITLE' | translate }}
+
+
+
{{ 'I18N_ASSESSMENT_INTERRUPT_TITLE' | translate }}
-
- {{ 'I18N_ASSESSMENT_INTERRUPT_SESSION_ENDED' | translate }}
-
-
-
- {{ 'I18N_ASSESSMENT_INTERRUPT_RETRY_MESSAGE' | translate }}
-
+
+ {{ 'I18N_ASSESSMENT_INTERRUPT_SESSION_ENDED' | translate }}
+
-
-
i
-
- {{ 'I18N_ASSESSMENT_INTERRUPT_NOTE_TEXT' | translate }}
+
+ {{ 'I18N_ASSESSMENT_INTERRUPT_RETRY_MESSAGE' | translate }}
-
-
-
- {{ 'I18N_ASSESSMENT_INTERRUPT_RETRY_BUTTON' | translate }}
-
-
- {{ 'I18N_ASSESSMENT_INTERRUPT_RESUME_BUTTON' | translate }}
-
+
+
i
+
+ {{ 'I18N_ASSESSMENT_INTERRUPT_NOTE_TEXT' | translate }}
+
+
+
+
+
+ {{ 'I18N_ASSESSMENT_INTERRUPT_RETRY_BUTTON' | translate }}
+
+
+ {{ 'I18N_ASSESSMENT_INTERRUPT_RESUME_BUTTON' | translate }}
+
+
diff --git a/core/templates/components/certificate-assessment-offering-helper/assessment-unavailable-modal.component.css b/core/templates/components/certificate-assessment-offering-helper/assessment-unavailable-modal.component.css
new file mode 100644
index 0000000000000..ad38f197fa22b
--- /dev/null
+++ b/core/templates/components/certificate-assessment-offering-helper/assessment-unavailable-modal.component.css
@@ -0,0 +1,99 @@
+* { box-sizing: border-box; }
+
+.assessment-unavailable-overlay {
+ align-items: center;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ inset: 0;
+ justify-content: center;
+ padding: 16px;
+ position: fixed;
+ z-index: 1000;
+}
+
+.assessment-unavailable-card {
+ background: #fff;
+ border: 1px solid rgba(0, 0, 0, 0.12);
+ border-radius: 8px;
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
+ max-width: 640px;
+ padding: 48px 56px;
+ text-align: center;
+ width: 100%;
+}
+
+.assessment-unavailable-title {
+ color: #2d2d2d;
+ font-size: 32px;
+ font-weight: 800;
+ margin: 0 0 20px;
+}
+
+.assessment-unavailable-message {
+ color: #334e68;
+ font-size: 16px;
+ line-height: 1.5;
+ margin: 0 0 12px;
+}
+
+.assessment-unavailable-note {
+ align-items: flex-start;
+ background: #d9eaf7;
+ border-radius: 8px;
+ display: flex;
+ gap: 12px;
+ margin: 20px 0 8px;
+ padding: 14px 18px;
+ text-align: left;
+}
+
+.assessment-unavailable-note-icon {
+ align-items: center;
+ background: #6d93b8;
+ border-radius: 50%;
+ color: #fff;
+ display: flex;
+ flex: none;
+ font-size: 13px;
+ font-style: italic;
+ font-weight: 700;
+ height: 22px;
+ justify-content: center;
+ margin-top: 2px;
+ width: 22px;
+}
+
+.assessment-unavailable-note-text {
+ color: #334e68;
+ font-size: 14px;
+ line-height: 1.45;
+ margin: 0;
+}
+
+.assessment-unavailable-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ justify-content: center;
+ margin-top: 28px;
+}
+
+.assessment-unavailable-action-button {
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: 700;
+ line-height: 1.4;
+ min-width: 140px;
+ padding: 8px 20px;
+}
+
+.btn-primary.assessment-unavailable-action-button {
+ background: #0d6259;
+ border: 1px solid #0d6259;
+ color: #fff;
+}
+
+.btn-primary.assessment-unavailable-action-button:hover {
+ background: #0a4f48;
+}
diff --git a/core/templates/components/certificate-assessment-offering-helper/assessment-unavailable-modal.component.html b/core/templates/components/certificate-assessment-offering-helper/assessment-unavailable-modal.component.html
new file mode 100644
index 0000000000000..3e7f0f462317a
--- /dev/null
+++ b/core/templates/components/certificate-assessment-offering-helper/assessment-unavailable-modal.component.html
@@ -0,0 +1,35 @@
+
+
+
{{ 'I18N_ASSESSMENT_UNAVAILABLE_TITLE' | translate }}
+
+
+ {{ 'I18N_ASSESSMENT_UNAVAILABLE_MESSAGE' | translate }}
+
+
+
+ {{ 'I18N_ASSESSMENT_UNAVAILABLE_ALERTED_CREATOR_MESSAGE' | translate }}
+
+
+
+ {{ 'I18N_ASSESSMENT_UNAVAILABLE_TRY_OTHER_MESSAGE' | translate }}
+
+
+
+
i
+
+ {{ 'I18N_ASSESSMENT_UNAVAILABLE_NOTE_TEXT' | translate }}
+
+
+
+
+
+ {{ 'I18N_ASSESSMENT_UNAVAILABLE_GO_TO_CERTIFICATES_BUTTON' | translate }}
+
+
+
+
diff --git a/core/templates/components/certificate-assessment-offering-helper/assessment-unavailable-modal.component.spec.ts b/core/templates/components/certificate-assessment-offering-helper/assessment-unavailable-modal.component.spec.ts
new file mode 100644
index 0000000000000..e466699c1ff32
--- /dev/null
+++ b/core/templates/components/certificate-assessment-offering-helper/assessment-unavailable-modal.component.spec.ts
@@ -0,0 +1,120 @@
+// Copyright 2026 The Oppia Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/**
+ * @fileoverview Unit tests for AssessmentUnavailableModalComponent.
+ */
+
+import {ComponentFixture, TestBed} from '@angular/core/testing';
+import {CommonModule} from '@angular/common';
+import {NO_ERRORS_SCHEMA} from '@angular/core';
+
+import {AssessmentUnavailableModalComponent} from './assessment-unavailable-modal.component';
+import {MockTranslatePipe} from 'tests/unit-test-utils';
+
+describe('AssessmentUnavailableModalComponent', () => {
+ let component: AssessmentUnavailableModalComponent;
+ let fixture: ComponentFixture
;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [CommonModule],
+ declarations: [AssessmentUnavailableModalComponent, MockTranslatePipe],
+ schemas: [NO_ERRORS_SCHEMA],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(AssessmentUnavailableModalComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should default to visible', () => {
+ expect(component.isVisible).toBe(true);
+ });
+
+ it('should render the overlay when visible', () => {
+ fixture.detectChanges();
+
+ expect(
+ fixture.nativeElement.querySelector('.assessment-unavailable-overlay')
+ ).not.toBeNull();
+ });
+
+ it('should remove the overlay when isVisible is false', () => {
+ component.isVisible = false;
+ fixture.detectChanges();
+
+ expect(
+ fixture.nativeElement.querySelector('.assessment-unavailable-overlay')
+ ).toBeNull();
+ });
+
+ it('should move initial focus to the modal action button', () => {
+ fixture.detectChanges();
+
+ const actionButton: HTMLButtonElement = fixture.nativeElement.querySelector(
+ '.assessment-unavailable-action-button'
+ );
+ expect(document.activeElement).toBe(actionButton);
+ });
+
+ it('should not render a focusable action button when the modal is hidden', () => {
+ component.isVisible = false;
+ fixture.detectChanges();
+
+ expect(
+ fixture.nativeElement.querySelector(
+ '.assessment-unavailable-action-button'
+ )
+ ).toBeNull();
+ });
+
+ it('should keep Tab focus within the modal', () => {
+ fixture.detectChanges();
+ const tabEvent = new KeyboardEvent('keydown', {key: 'Tab'});
+ spyOn(tabEvent, 'preventDefault');
+ spyOn(component.goToCertificatesButton.nativeElement, 'focus');
+
+ component.containFocusWithinModal(tabEvent);
+
+ expect(tabEvent.preventDefault).toHaveBeenCalled();
+ expect(
+ component.goToCertificatesButton.nativeElement.focus
+ ).toHaveBeenCalled();
+ });
+
+ it('should ignore non-Tab keys in the focus containment handler', () => {
+ fixture.detectChanges();
+ const enterEvent = new KeyboardEvent('keydown', {key: 'Enter'});
+ spyOn(enterEvent, 'preventDefault');
+ spyOn(component.goToCertificatesButton.nativeElement, 'focus');
+
+ component.containFocusWithinModal(enterEvent);
+
+ expect(enterEvent.preventDefault).not.toHaveBeenCalled();
+ expect(
+ component.goToCertificatesButton.nativeElement.focus
+ ).not.toHaveBeenCalled();
+ });
+
+ it('should emit goToAvailableCertificates when the button is clicked', () => {
+ spyOn(component.goToAvailableCertificates, 'emit');
+ fixture.detectChanges();
+
+ const goToCertificatesButton: HTMLButtonElement =
+ fixture.nativeElement.querySelector('button');
+ goToCertificatesButton.click();
+
+ expect(component.goToAvailableCertificates.emit).toHaveBeenCalled();
+ });
+});
diff --git a/core/templates/components/certificate-assessment-offering-helper/assessment-unavailable-modal.component.ts b/core/templates/components/certificate-assessment-offering-helper/assessment-unavailable-modal.component.ts
new file mode 100644
index 0000000000000..688211d119904
--- /dev/null
+++ b/core/templates/components/certificate-assessment-offering-helper/assessment-unavailable-modal.component.ts
@@ -0,0 +1,66 @@
+// Copyright 2026 The Oppia Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS-IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/**
+ * @fileoverview Modal shown when a certificate assessment is unavailable and
+ * cannot be started.
+ */
+
+import {
+ AfterViewInit,
+ Component,
+ ElementRef,
+ EventEmitter,
+ Input,
+ Output,
+ ViewChild,
+} from '@angular/core';
+import './assessment-unavailable-modal.component.css';
+
+@Component({
+ selector: 'oppia-assessment-unavailable-modal',
+ templateUrl: './assessment-unavailable-modal.component.html',
+ styleUrls: ['./assessment-unavailable-modal.component.css'],
+})
+export class AssessmentUnavailableModalComponent implements AfterViewInit {
+ @Input() isVisible = true;
+
+ @ViewChild('goToCertificatesButton')
+ goToCertificatesButton!: ElementRef;
+
+ @Output() goToAvailableCertificates = new EventEmitter();
+
+ ngAfterViewInit(): void {
+ if (this.isVisible) {
+ this.moveFocusToActionButton();
+ }
+ }
+
+ containFocusWithinModal(event: KeyboardEvent): void {
+ if (event.key !== 'Tab') {
+ return;
+ }
+
+ event.preventDefault();
+ this.moveFocusToActionButton();
+ }
+
+ onGoToAvailableCertificates(): void {
+ this.goToAvailableCertificates.emit();
+ }
+
+ private moveFocusToActionButton(): void {
+ this.goToCertificatesButton.nativeElement.focus();
+ }
+}
diff --git a/core/templates/components/certificate-assessment-offering-helper/certificate-offering-shared.module.ts b/core/templates/components/certificate-assessment-offering-helper/certificate-offering-shared.module.ts
index f21bb20056d9f..cd0d8c09b93fd 100644
--- a/core/templates/components/certificate-assessment-offering-helper/certificate-offering-shared.module.ts
+++ b/core/templates/components/certificate-assessment-offering-helper/certificate-offering-shared.module.ts
@@ -27,6 +27,7 @@ import {CertificateOfferingDetailsComponent} from './certificate-offering-detail
import {CertificateOfferingProgressComponent} from './certificate-offering-progress.component';
import {CertificateOfferingReviewAndAvailabilityComponent} from './certificate-offering-review-and-availability.component';
import {AssessmentInterruptComponent} from './assessment-interrupt.component';
+import {AssessmentUnavailableModalComponent} from './assessment-unavailable-modal.component';
import {PostCertificateOfferingResultModalComponent} from './post-certificate-offering-result-modal.component';
import {TimeExpiredModalComponent} from './time-expired-modal.component';
import {UnansweredQuestionModalComponent} from './unanswered-question-modal.component';
@@ -41,6 +42,7 @@ import {FormsModule} from '@angular/forms';
CertificateOfferingReviewAndAvailabilityComponent,
CertificateOfferingConfirmationModalComponent,
AssessmentInterruptComponent,
+ AssessmentUnavailableModalComponent,
PostCertificateOfferingResultModalComponent,
TimeExpiredModalComponent,
UnansweredQuestionModalComponent,
@@ -52,6 +54,7 @@ import {FormsModule} from '@angular/forms';
CertificateOfferingReviewAndAvailabilityComponent,
CertificateOfferingConfirmationModalComponent,
AssessmentInterruptComponent,
+ AssessmentUnavailableModalComponent,
PostCertificateOfferingResultModalComponent,
TimeExpiredModalComponent,
UnansweredQuestionModalComponent,
diff --git a/core/templates/components/certificate-assessment-offering-helper/time-expired-modal.component.spec.ts b/core/templates/components/certificate-assessment-offering-helper/time-expired-modal.component.spec.ts
index 3ebf58603f7ae..5c252a24e8593 100644
--- a/core/templates/components/certificate-assessment-offering-helper/time-expired-modal.component.spec.ts
+++ b/core/templates/components/certificate-assessment-offering-helper/time-expired-modal.component.spec.ts
@@ -22,6 +22,7 @@ import {MatBottomSheetRef} from '@angular/material/bottom-sheet';
import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
import {TimeExpiredModalComponent} from './time-expired-modal.component';
+import {CertificateAssessmentPlayerPageConstants} from '../../pages/certificate-assessment-player-page/certificate-assessment-player-page.constants';
import {MockTranslatePipe} from 'tests/unit-test-utils';
describe('TimeExpiredModalComponent', () => {
@@ -113,7 +114,9 @@ describe('TimeExpiredModalComponent', () => {
describe('viewResults', () => {
it('should close NgbActiveModal when available', () => {
component.viewResults();
- expect(ngbActiveModal.close).toHaveBeenCalledWith('view-results');
+ expect(ngbActiveModal.close).toHaveBeenCalledWith(
+ CertificateAssessmentPlayerPageConstants.VIEW_RESULTS_RESULT
+ );
});
it('should close the modal when the action button is clicked', () => {
@@ -123,7 +126,9 @@ describe('TimeExpiredModalComponent', () => {
viewResultsButton.triggerEventHandler('click', null);
- expect(ngbActiveModal.close).toHaveBeenCalledWith('view-results');
+ expect(ngbActiveModal.close).toHaveBeenCalledWith(
+ CertificateAssessmentPlayerPageConstants.VIEW_RESULTS_RESULT
+ );
});
it('should dismiss MatBottomSheetRef when NgbActiveModal is not available', () => {
diff --git a/core/templates/components/certificate-assessment-offering-helper/time-expired-modal.component.ts b/core/templates/components/certificate-assessment-offering-helper/time-expired-modal.component.ts
index d42fb4183d11d..ffd3926979426 100644
--- a/core/templates/components/certificate-assessment-offering-helper/time-expired-modal.component.ts
+++ b/core/templates/components/certificate-assessment-offering-helper/time-expired-modal.component.ts
@@ -19,6 +19,7 @@
import {Component, Optional} from '@angular/core';
import {MatBottomSheetRef} from '@angular/material/bottom-sheet';
import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
+import {CertificateAssessmentPlayerPageConstants} from '../../pages/certificate-assessment-player-page/certificate-assessment-player-page.constants';
import './time-expired-modal.component.css';
@Component({
@@ -42,9 +43,13 @@ export class TimeExpiredModalComponent {
viewResults(): void {
if (this.ngbActiveModal) {
- this.ngbActiveModal.close('view-results');
+ this.ngbActiveModal.close(
+ CertificateAssessmentPlayerPageConstants.VIEW_RESULTS_RESULT
+ );
} else if (this.bottomSheetRef) {
- this.bottomSheetRef.dismiss('view-results');
+ this.bottomSheetRef.dismiss(
+ CertificateAssessmentPlayerPageConstants.VIEW_RESULTS_RESULT
+ );
}
}
}
diff --git a/core/templates/components/certificate-assessment-offering-helper/unanswered-question-modal.component.spec.ts b/core/templates/components/certificate-assessment-offering-helper/unanswered-question-modal.component.spec.ts
index ea1d101b497f7..f590eeacbf518 100644
--- a/core/templates/components/certificate-assessment-offering-helper/unanswered-question-modal.component.spec.ts
+++ b/core/templates/components/certificate-assessment-offering-helper/unanswered-question-modal.component.spec.ts
@@ -150,22 +150,22 @@ describe('UnansweredQuestionModalComponent', () => {
});
describe('submitAnyway', () => {
- it('should close NgbActiveModal when available', () => {
+ it('should close NgbActiveModal with submit-anyway when available', () => {
component.submitAnyway();
- expect(ngbActiveModal.close).toHaveBeenCalled();
+ expect(ngbActiveModal.close).toHaveBeenCalledWith('submit-anyway');
});
- it('should close the modal when the submit button is clicked', () => {
+ it('should close the modal with submit-anyway when the submit button is clicked', () => {
const submitButton = fixture.debugElement.query(
By.css('.assessment-modal-action-button')
);
submitButton.triggerEventHandler('click', null);
- expect(ngbActiveModal.close).toHaveBeenCalled();
+ expect(ngbActiveModal.close).toHaveBeenCalledWith('submit-anyway');
});
- it('should dismiss MatBottomSheetRef when NgbActiveModal is not available', () => {
+ it('should dismiss MatBottomSheetRef with submit-anyway when NgbActiveModal is not available', () => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
declarations: [UnansweredQuestionModalComponent, MockTranslatePipe],
@@ -178,7 +178,7 @@ describe('UnansweredQuestionModalComponent', () => {
const newComponentInstance = newFixture.componentInstance;
newComponentInstance.submitAnyway();
- expect(bottomSheetRef.dismiss).toHaveBeenCalled();
+ expect(bottomSheetRef.dismiss).toHaveBeenCalledWith('submit-anyway');
});
});
});
diff --git a/core/templates/components/certificate-assessment-offering-helper/unanswered-question-modal.component.ts b/core/templates/components/certificate-assessment-offering-helper/unanswered-question-modal.component.ts
index 60a94c12fc4be..4244560bbf38e 100644
--- a/core/templates/components/certificate-assessment-offering-helper/unanswered-question-modal.component.ts
+++ b/core/templates/components/certificate-assessment-offering-helper/unanswered-question-modal.component.ts
@@ -22,6 +22,8 @@ import {MatBottomSheetRef} from '@angular/material/bottom-sheet';
import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
import './unanswered-question-modal.component.css';
+export const SUBMIT_ANYWAY_RESULT = 'submit-anyway';
+
@Component({
selector: 'oppia-unanswered-question-modal',
templateUrl: './unanswered-question-modal.component.html',
@@ -45,9 +47,9 @@ export class UnansweredQuestionModalComponent {
submitAnyway(): void {
if (this.ngbActiveModal) {
- this.ngbActiveModal.close();
+ this.ngbActiveModal.close(SUBMIT_ANYWAY_RESULT);
} else if (this.bottomSheetRef) {
- this.bottomSheetRef.dismiss();
+ this.bottomSheetRef.dismiss(SUBMIT_ANYWAY_RESULT);
}
}
}
diff --git a/core/templates/components/concept-card/concept-card.component.spec.ts b/core/templates/components/concept-card/concept-card.component.spec.ts
index 48b43e871aff1..1a06bdc6ee805 100644
--- a/core/templates/components/concept-card/concept-card.component.spec.ts
+++ b/core/templates/components/concept-card/concept-card.component.spec.ts
@@ -30,11 +30,15 @@ import {SubtitledHtml} from 'domain/exploration/subtitled-html.model';
import {ConceptCardBackendApiService} from 'domain/skill/concept-card-backend-api.service';
import {ConceptCard} from 'domain/skill/concept-card.model';
import {ConceptCardComponent} from './concept-card.component';
+import {I18nLanguageCodeService} from 'services/i18n-language-code.service';
+import {ContentTranslationLanguageService} from 'pages/exploration-player-page/services/content-translation-language.service';
describe('Concept card component', () => {
let fixture: ComponentFixture;
let componentInstance: ConceptCardComponent;
let conceptCardBackendApiService: ConceptCardBackendApiService;
+ let i18nLanguageCodeService: I18nLanguageCodeService;
+ let contentTranslationLanguageService: ContentTranslationLanguageService;
let conceptCard = new ConceptCard(
new SubtitledHtml('', '1'),
RecordedVoiceovers.createEmpty()
@@ -53,6 +57,10 @@ describe('Concept card component', () => {
fixture = TestBed.createComponent(ConceptCardComponent);
componentInstance = fixture.componentInstance;
conceptCardBackendApiService = TestBed.inject(ConceptCardBackendApiService);
+ i18nLanguageCodeService = TestBed.inject(I18nLanguageCodeService);
+ contentTranslationLanguageService = TestBed.inject(
+ ContentTranslationLanguageService
+ );
});
it('should initialize and load concept cards successfully', fakeAsync(() => {
@@ -69,6 +77,43 @@ describe('Concept card component', () => {
expect(componentInstance.currentConceptCard).toEqual(conceptCard);
}));
+ it('should emit the skill description once the concept card loads', fakeAsync(() => {
+ const translatedConceptCard = new ConceptCard(
+ new SubtitledHtml('', '1'),
+ RecordedVoiceovers.createEmpty(),
+ 'nombre de la habilidad'
+ );
+ spyOn(
+ conceptCardBackendApiService,
+ 'loadConceptCardsAsync'
+ ).and.returnValue(Promise.resolve([translatedConceptCard]));
+ const emitSpy = spyOn(componentInstance.skillDescriptionLoaded, 'emit');
+ componentInstance.index = 0;
+
+ componentInstance.ngOnInit();
+ tick();
+
+ expect(emitSpy).toHaveBeenCalledWith('nombre de la habilidad');
+ }));
+
+ it("should load concept cards in the learner's selected language", fakeAsync(() => {
+ spyOn(
+ i18nLanguageCodeService,
+ 'getCurrentI18nLanguageCode'
+ ).and.returnValue('es');
+ const loadConceptCardsSpy = spyOn(
+ conceptCardBackendApiService,
+ 'loadConceptCardsAsync'
+ ).and.returnValue(Promise.resolve(conceptCardObjects));
+ componentInstance.index = 0;
+ componentInstance.skillIds = ['skill_1'];
+
+ componentInstance.ngOnInit();
+ tick();
+
+ expect(loadConceptCardsSpy).toHaveBeenCalledWith(['skill_1'], 'es');
+ }));
+
it('should initialize and handle error if fails to load concept cards', fakeAsync(() => {
spyOn(
conceptCardBackendApiService,
@@ -83,4 +128,26 @@ describe('Concept card component', () => {
'Oops, it looks like this skill has been deleted.'
);
}));
+
+ it("should prioritize the lesson's study language over the site language", fakeAsync(() => {
+ spyOn(
+ i18nLanguageCodeService,
+ 'getCurrentI18nLanguageCode'
+ ).and.returnValue('es');
+ spyOn(
+ contentTranslationLanguageService,
+ 'getCurrentContentLanguageCode'
+ ).and.returnValue('hi');
+ const loadConceptCardsSpy = spyOn(
+ conceptCardBackendApiService,
+ 'loadConceptCardsAsync'
+ ).and.returnValue(Promise.resolve(conceptCardObjects));
+ componentInstance.index = 0;
+ componentInstance.skillIds = ['skill_1'];
+
+ componentInstance.ngOnInit();
+ tick();
+
+ expect(loadConceptCardsSpy).toHaveBeenCalledWith(['skill_1'], 'hi');
+ }));
});
diff --git a/core/templates/components/concept-card/concept-card.component.ts b/core/templates/components/concept-card/concept-card.component.ts
index 813e1b1708fdb..e76bf17a5694e 100644
--- a/core/templates/components/concept-card/concept-card.component.ts
+++ b/core/templates/components/concept-card/concept-card.component.ts
@@ -16,9 +16,11 @@
* @fileoverview Component for the concept cards viewer.
*/
-import {Component, Input, OnInit} from '@angular/core';
+import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core';
import {ConceptCardBackendApiService} from 'domain/skill/concept-card-backend-api.service';
import {ConceptCard} from 'domain/skill/concept-card.model';
+import {I18nLanguageCodeService} from 'services/i18n-language-code.service';
+import {ContentTranslationLanguageService} from 'pages/exploration-player-page/services/content-translation-language.service';
import './concept-card.component.css';
@@ -33,6 +35,10 @@ export class ConceptCardComponent implements OnInit {
// https://github.com/oppia/oppia/wiki/Guide-on-defining-types#ts-7-1
@Input() skillIds!: string[];
@Input() index!: number;
+ // The skill description is translated by the backend, so it is emitted for
+ // any surrounding UI (such as a modal heading) that displays it.
+ @Output() skillDescriptionLoaded: EventEmitter =
+ new EventEmitter();
currentConceptCard!: ConceptCard;
loadingMessage!: string;
skillDeletedMessage!: string;
@@ -40,24 +46,39 @@ export class ConceptCardComponent implements OnInit {
explanationIsShown: boolean = false;
constructor(
- private conceptCardBackendApiService: ConceptCardBackendApiService
+ private conceptCardBackendApiService: ConceptCardBackendApiService,
+ private i18nLanguageCodeService: I18nLanguageCodeService,
+ private contentTranslationLanguageService: ContentTranslationLanguageService
) {}
ngOnInit(): void {
this.loadingMessage = 'Loading';
- this.conceptCardBackendApiService.loadConceptCardsAsync(this.skillIds).then(
- conceptCardObjects => {
- conceptCardObjects.forEach(conceptCardObject => {
- this.conceptsCards.push(conceptCardObject);
- });
- this.loadingMessage = '';
- this.currentConceptCard = this.conceptsCards[this.index];
- },
- errorResponse => {
- this.loadingMessage = '';
- this.skillDeletedMessage =
- 'Oops, it looks like this skill has' + ' been deleted.';
- }
- );
+ let languageCode =
+ this.i18nLanguageCodeService.getCurrentI18nLanguageCode();
+ if (
+ this.contentTranslationLanguageService.getCurrentContentLanguageCode()
+ ) {
+ languageCode =
+ this.contentTranslationLanguageService.getCurrentContentLanguageCode();
+ }
+ this.conceptCardBackendApiService
+ .loadConceptCardsAsync(this.skillIds, languageCode)
+ .then(
+ conceptCardObjects => {
+ conceptCardObjects.forEach(conceptCardObject => {
+ this.conceptsCards.push(conceptCardObject);
+ });
+ this.loadingMessage = '';
+ this.currentConceptCard = this.conceptsCards[this.index];
+ this.skillDescriptionLoaded.emit(
+ this.currentConceptCard.getSkillDescription()
+ );
+ },
+ errorResponse => {
+ this.loadingMessage = '';
+ this.skillDeletedMessage =
+ 'Oops, it looks like this skill has' + ' been deleted.';
+ }
+ );
}
}
diff --git a/core/templates/components/forms/slider/audio-slider.component.css b/core/templates/components/forms/slider/audio-slider.component.css
new file mode 100644
index 0000000000000..8fdf19e1f9c45
--- /dev/null
+++ b/core/templates/components/forms/slider/audio-slider.component.css
@@ -0,0 +1,27 @@
+.mat-accent /deep/ .mat-slider-track-fill,
+.mat-accent /deep/ .mat-slider-thumb,
+.mat-accent /deep/ .mat-slider-thumb-label {
+ background: #ff4081;
+}
+
+.mat-slider-horizontal {
+ height: 2px;
+ min-width: 120px;
+}
+
+.mat-slider {
+ padding: 0;
+}
+
+/deep/ .mat-slider-wrapper {
+ position: static;
+}
+
+/* ::ng-deep is used here to override Angular Material's own internally-injected
+ .mat-slider-wrapper styles, which set top: 23px and are not reachable through
+ normal Emulated view encapsulation since they live outside this component's
+ template boundary */
+/* stylelint-disable selector-pseudo-element-no-unknown */
+::ng-deep .mat-slider-horizontal .mat-slider-wrapper {
+ top: 0 !important;
+}
diff --git a/core/templates/components/forms/slider/audio-slider.component.ts b/core/templates/components/forms/slider/audio-slider.component.ts
index 61a7b4eddf72d..9b254390cbf2f 100644
--- a/core/templates/components/forms/slider/audio-slider.component.ts
+++ b/core/templates/components/forms/slider/audio-slider.component.ts
@@ -17,6 +17,7 @@
*/
import {Component, EventEmitter, Input, Output} from '@angular/core';
+import './audio-slider.component.css';
@Component({
selector: 'oppia-audio-slider',
@@ -32,15 +33,7 @@ import {Component, EventEmitter, Input, Output} from '@angular/core';
aria-label="audio-slider"
>
`,
- styles: [
- `
- .mat-accent /deep/ .mat-slider-track-fill,
- .mat-accent /deep/ .mat-slider-thumb,
- .mat-accent /deep/ .mat-slider-thumb-label {
- background: #ff4081;
- }
- `,
- ],
+ styleUrls: ['./audio-slider.component.css'],
})
export class AudioSliderComponent {
// These properties are initialized using component interactions
diff --git a/core/templates/components/interaction-display/interaction-display.component.css b/core/templates/components/interaction-display/interaction-display.component.css
new file mode 100644
index 0000000000000..63d9f2c95d716
--- /dev/null
+++ b/core/templates/components/interaction-display/interaction-display.component.css
@@ -0,0 +1,5 @@
+/* interaction-display.component.css */
+.conversation-skin-learner-answer-content {
+ padding: 12px;
+ word-wrap: break-word;
+}
diff --git a/core/templates/components/interaction-display/interaction-display.component.ts b/core/templates/components/interaction-display/interaction-display.component.ts
index 6c4c060cf6641..1e42de75e8a22 100644
--- a/core/templates/components/interaction-display/interaction-display.component.ts
+++ b/core/templates/components/interaction-display/interaction-display.component.ts
@@ -27,6 +27,7 @@ import {
ViewContainerRef,
} from '@angular/core';
import camelCaseFromHyphen from 'utility/string-utility';
+import './interaction-display.component.css';
import {TAG_TO_INTERACTION_MAPPING} from 'interactions/tag-to-interaction-mapping';
type ScopedValue =
@@ -45,6 +46,7 @@ type ScopedValue =
@Component({
selector: 'oppia-interaction-display',
templateUrl: './interaction-display.component.html',
+ styleUrls: ['./interaction-display.component.css'],
})
export class InteractionDisplayComponent {
// These properties are initialized using Angular lifecycle hooks
diff --git a/core/templates/components/question-directives/question-player/question-player-concept-card-modal.component.html b/core/templates/components/question-directives/question-player/question-player-concept-card-modal.component.html
index 12ac8454c0669..ff92ad9f70006 100644
--- a/core/templates/components/question-directives/question-player/question-player-concept-card-modal.component.html
+++ b/core/templates/components/question-directives/question-player/question-player-concept-card-modal.component.html
@@ -6,7 +6,8 @@
+ [index]="index"
+ (skillDescriptionLoaded)="onSkillDescriptionLoaded($event)">
diff --git a/core/templates/components/question-directives/question-player/question-player-concept-card-modal.component.spec.ts b/core/templates/components/question-directives/question-player/question-player-concept-card-modal.component.spec.ts
index 7ed508d127201..67a0ecdbbc003 100644
--- a/core/templates/components/question-directives/question-player/question-player-concept-card-modal.component.spec.ts
+++ b/core/templates/components/question-directives/question-player/question-player-concept-card-modal.component.spec.ts
@@ -116,6 +116,24 @@ describe('Question Player Concept Card Modal component', () => {
})
);
+ it('should show the translated skill description in the header', () => {
+ component.skills = ['name1', 'name2'];
+ component.ngOnInit();
+
+ component.onSkillDescriptionLoaded('nombre1');
+
+ expect(component.modalHeader).toEqual('nombre1');
+ });
+
+ it('should keep the original header when no description is loaded', () => {
+ component.skills = ['name1', 'name2'];
+ component.ngOnInit();
+
+ component.onSkillDescriptionLoaded('');
+
+ expect(component.modalHeader).toEqual('name1');
+ });
+
it('should refresh page when retrying a practice test', () => {
spyOn(urlService, 'getUrlParams').and.returnValue({
selected_subtopic_ids: 'selected_subtopic_ids',
diff --git a/core/templates/components/question-directives/question-player/question-player-concept-card-modal.component.ts b/core/templates/components/question-directives/question-player/question-player-concept-card-modal.component.ts
index 4a7df4a1e95ec..6e9169b1a1686 100644
--- a/core/templates/components/question-directives/question-player/question-player-concept-card-modal.component.ts
+++ b/core/templates/components/question-directives/question-player/question-player-concept-card-modal.component.ts
@@ -49,6 +49,14 @@ export class QuestionPlayerConceptCardModalComponent
this.modalHeader = this.skills[this.index];
}
+ // The backend returns the skill description translated into the learner's
+ // language, so it is preferred over the untranslated description passed in.
+ onSkillDescriptionLoaded(skillDescription: string): void {
+ if (skillDescription) {
+ this.modalHeader = skillDescription;
+ }
+ }
+
isLastConceptCard(): boolean {
return this.index === this.skills.length - 1;
}
diff --git a/core/templates/components/summary-tile/exploration-summary-tile.component.css b/core/templates/components/summary-tile/exploration-summary-tile.component.css
index 3d92dabfeea1f..290662bbce77a 100644
--- a/core/templates/components/summary-tile/exploration-summary-tile.component.css
+++ b/core/templates/components/summary-tile/exploration-summary-tile.component.css
@@ -3,14 +3,14 @@
compilation, here are some additional rules that can be added to the CSS files:
https://rtlcss.com/learn/usage-guide/control-directives .
*/
-oppia-exploration-summary-tile .oppia-exploration-dashboard-card {
+.oppia-exploration-dashboard-card {
position: relative;
text-align: left;
width: 198px;
z-index: 5;
}
-oppia-exploration-summary-tile .oppia-play-exploration-button {
+.oppia-play-exploration-button {
display: block;
margin: auto;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
@@ -19,7 +19,7 @@ oppia-exploration-summary-tile .oppia-play-exploration-button {
user-select: none;
}
-oppia-exploration-summary-tile .oppia-return-to-parent-button {
+.oppia-return-to-parent-button {
margin-top: 12px;
}
@@ -29,7 +29,7 @@ oppia-exploration-summary-tile .oppia-return-to-parent-button {
to replace the value of the input style to whatever is specified in the
comment.
*/
-oppia-exploration-summary-tile .oppia-arrow-icon {
+.oppia-arrow-icon {
font-size: 19px;
padding-top: 1.5px;
-moz-transform: scaleX(1) /*rtl:scaleX(-1)*/;
@@ -38,7 +38,7 @@ oppia-exploration-summary-tile .oppia-arrow-icon {
transform: scaleX(1) /*rtl:scaleX(-1)*/;
}
-oppia-exploration-summary-tile .oppia-learner-confirm-button {
+.oppia-learner-confirm-button {
background: #0d48a1;
color: #fff;
font-size: 16px;
@@ -46,31 +46,31 @@ oppia-exploration-summary-tile .oppia-learner-confirm-button {
padding: 6px 12px;
}
-oppia-exploration-summary-tile .mobile-activity-summary-card {
+.mobile-activity-summary-card {
box-shadow: slategrey 2px 4px 4px 0;
padding: 0 0 0 0;
width: 340px;
}
-oppia-exploration-summary-tile .mobile-exploration-card-image-container {
+.mobile-exploration-card-image-container {
align-self: center;
width: 63px;
}
-oppia-exploration-summary-tile .mobile-exploration-card-image {
+.mobile-exploration-card-image {
height: 100%;
width: auto;
}
-oppia-exploration-summary-tile .views-text {
+.views-text {
margin-left: 6px;
}
-oppia-exploration-summary-tile .thumbnail-image {
+.thumbnail-image {
display: block;
}
-oppia-exploration-summary-tile .exploration-summary-btn {
+.exploration-summary-btn {
align-items: center;
background-color: #fff;
border: none;
@@ -88,29 +88,29 @@ oppia-exploration-summary-tile .exploration-summary-btn {
width: 325px;
}
-oppia-exploration-summary-tile a:hover {
+a:hover {
text-decoration: none;
}
-oppia-exploration-summary-tile .exploration-summary-btn p {
+.exploration-summary-btn p {
text-align: right;
}
-oppia-exploration-summary-tile .arrow-icon {
+.arrow-icon {
font-size: 24px;
margin-right: 10px;
}
-oppia-exploration-summary-tile .star-icon {
+.star-icon {
margin-right: 5px;
}
-oppia-exploration-summary-tile .exploration-summary-btn span {
+.exploration-summary-btn span {
font-family: "Roboto", Arial, sans-serif;
font-size: 16px;
}
-oppia-exploration-summary-tile .oppia-exploration-dashboard-card .layout-row {
+.oppia-exploration-dashboard-card .layout-row {
align-items: center;
display: flex;
flex-direction: row;
@@ -118,14 +118,14 @@ oppia-exploration-summary-tile .oppia-exploration-dashboard-card .layout-row {
max-width: 100%;
}
-oppia-exploration-summary-tile .tags-section {
+.tags-section {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 8px;
}
-oppia-exploration-summary-tile .tag-badge {
+.tag-badge {
background: #f0f0f0;
border: 1px solid #d0d0d0;
border-radius: 4px;
@@ -136,16 +136,16 @@ oppia-exploration-summary-tile .tag-badge {
}
@media (max-width: 480px) {
- oppia-exploration-summary-tile .exploration-summary-btn {
+ .exploration-summary-btn {
font-size: 13px;
width: 240px;
}
- oppia-exploration-summary-tile .arrow-icon {
+ .arrow-icon {
font-size: 18px;
}
- oppia-exploration-summary-tile .exploration-summary-btn span {
+ .exploration-summary-btn span {
font-size: 13px;
}
diff --git a/core/templates/components/summary-tile/exploration-summary-tile.component.spec.ts b/core/templates/components/summary-tile/exploration-summary-tile.component.spec.ts
index dd9f5383df7b6..66c178b27886e 100644
--- a/core/templates/components/summary-tile/exploration-summary-tile.component.spec.ts
+++ b/core/templates/components/summary-tile/exploration-summary-tile.component.spec.ts
@@ -41,6 +41,7 @@ import {MockTranslatePipe} from 'tests/unit-test-utils';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {UserInfo} from 'domain/user/user-info.model';
import {I18nLanguageCodeService} from 'services/i18n-language-code.service';
+import {TranslatableExplorationMetadataField} from 'domain/summary/learner-exploration-summary.model';
@Pipe({name: 'truncateAndCapitalize'})
class MockTruncateAndCapitalizePipe implements PipeTransform {
@@ -313,6 +314,23 @@ describe('Exploration Summary Tile Component', () => {
expect(hackyTranslationIsDisplayed).toBe(true);
}));
+ it('should not display hacky translation when backend translated the field', () => {
+ component.translatedMetadataFields = [
+ TranslatableExplorationMetadataField.TITLE,
+ TranslatableExplorationMetadataField.OBJECTIVE,
+ ];
+ spyOn(
+ i18nLanguageCodeService,
+ 'isHackyTranslationAvailable'
+ ).and.returnValue(true);
+ spyOn(i18nLanguageCodeService, 'isCurrentLanguageEnglish').and.returnValue(
+ false
+ );
+
+ expect(component.isHackyExpTitleTranslationDisplayed()).toBe(false);
+ expect(component.isHackyExpObjectiveTranslationDisplayed()).toBe(false);
+ });
+
it(
'should intialize the component and set mobileCutoffPx to 0' +
' if it is undefined',
diff --git a/core/templates/components/summary-tile/exploration-summary-tile.component.ts b/core/templates/components/summary-tile/exploration-summary-tile.component.ts
index 93d64b8374280..6906d21782492 100644
--- a/core/templates/components/summary-tile/exploration-summary-tile.component.ts
+++ b/core/templates/components/summary-tile/exploration-summary-tile.component.ts
@@ -20,7 +20,10 @@ import {Component, Input, OnDestroy, OnInit} from '@angular/core';
import {AppConstants} from 'app.constants';
import {RatingComputationService} from 'components/ratings/rating-computation/rating-computation.service';
-import {ExplorationRatings} from 'domain/summary/learner-exploration-summary.model';
+import {
+ ExplorationRatings,
+ TranslatableExplorationMetadataField,
+} from 'domain/summary/learner-exploration-summary.model';
import {UrlInterpolationService} from 'domain/utilities/url-interpolation.service';
import {UrlService} from 'services/contextual/url.service';
import {WindowRef} from 'services/contextual/window-ref.service';
@@ -75,6 +78,11 @@ export class ExplorationSummaryTileComponent implements OnInit, OnDestroy {
@Input() isContainerNarrow: boolean = false;
@Input() isOwnedByCurrentUser: boolean = false;
@Input() tags: string[] = [];
+ // The metadata fields that the backend already returned translated into the
+ // learner's site language. Fields that are absent are still in English, so
+ // the hardcoded translation bundle is used for them instead.
+ @Input() translatedMetadataFields: TranslatableExplorationMetadataField[] =
+ [];
activityType!: string;
resizeSubscription!: Subscription;
@@ -252,18 +260,20 @@ export class ExplorationSummaryTileComponent implements OnInit, OnDestroy {
}
isHackyExpTitleTranslationDisplayed(): boolean {
- return (
- this.i18nLanguageCodeService.isHackyTranslationAvailable(
- this.expTitleTranslationKey
- ) && !this.i18nLanguageCodeService.isCurrentLanguageEnglish()
+ return this.i18nLanguageCodeService.isHackyTranslationDisplayed(
+ this.expTitleTranslationKey,
+ this.translatedMetadataFields.includes(
+ TranslatableExplorationMetadataField.TITLE
+ )
);
}
isHackyExpObjectiveTranslationDisplayed(): boolean {
- return (
- this.i18nLanguageCodeService.isHackyTranslationAvailable(
- this.expObjectiveTranslationKey
- ) && !this.i18nLanguageCodeService.isCurrentLanguageEnglish()
+ return this.i18nLanguageCodeService.isHackyTranslationDisplayed(
+ this.expObjectiveTranslationKey,
+ this.translatedMetadataFields.includes(
+ TranslatableExplorationMetadataField.OBJECTIVE
+ )
);
}
diff --git a/core/templates/components/summary-tile/story-summary-tile.component.css b/core/templates/components/summary-tile/story-summary-tile.component.css
index 4a16f00c40554..f4a16e2f6534c 100644
--- a/core/templates/components/summary-tile/story-summary-tile.component.css
+++ b/core/templates/components/summary-tile/story-summary-tile.component.css
@@ -3,33 +3,33 @@
compilation, here are sme additional rules that can be added to the CSS files:
https://rtlcss.com/learn/usage-guide/control-directives .
*/
-oppia-story-summary-tile .chapter-identifier {
+.chapter-identifier {
color: #00645c;
font-weight: 500;
}
-oppia-story-summary-tile .story-progress-bar {
+.story-progress-bar {
float: left;
}
-oppia-story-summary-tile .story-progress-svg {
+.story-progress-svg {
transform: rotate(-90deg);
}
-oppia-story-summary-tile .story-progress-circle {
+.story-progress-circle {
fill-opacity: 0;
stroke-width: 3;
}
-oppia-story-summary-tile .total-circle {
+.total-circle {
stroke: #adadad;
}
-oppia-story-summary-tile .completed-circle {
+.completed-circle {
stroke: #be563c;
}
-oppia-story-summary-tile .oppia-story-title {
+.oppia-story-title {
display: inline-block;
font-size: 1.4em;
padding-bottom: 15px;
@@ -37,7 +37,7 @@ oppia-story-summary-tile .oppia-story-title {
padding-top: 7px;
}
-oppia-story-summary-tile .completed-icon {
+.completed-icon {
color: #00645c;
float: left;
font-size: 20px;
@@ -45,7 +45,7 @@ oppia-story-summary-tile .completed-icon {
width: 24px;
}
-oppia-story-summary-tile .view-button {
+.view-button {
color: #00645c;
cursor: pointer;
font-weight: bold;
@@ -54,28 +54,28 @@ oppia-story-summary-tile .view-button {
width: 100%;
}
-oppia-story-summary-tile .view-button:focus {
+.view-button:focus {
outline: 0;
}
-oppia-story-summary-tile .chapter-title {
+.chapter-title {
display: block;
font-size: 1.1em;
margin-bottom: 0.7vh;
}
-oppia-story-summary-tile .incomplete-chapter {
+.incomplete-chapter {
display: flex;
justify-content: space-between;
margin-left: 30px;
}
-oppia-story-summary-tile .complete-chapter {
+.complete-chapter {
display: inline-block;
width: 84%;
}
-oppia-story-summary-tile .chapter-details {
+.chapter-details {
border: 1px solid #7b7b7b;
border-radius: 0.25rem;
display: inline-block;
@@ -85,40 +85,40 @@ oppia-story-summary-tile .chapter-details {
width: 65%;
}
-oppia-story-summary-tile .story-details {
+.story-details {
display: flex;
}
-oppia-story-summary-tile .story-summary-tile-desktop {
+.story-summary-tile-desktop {
margin-left: 3vw;
padding-bottom: 7vh;
}
-oppia-story-summary-tile .story-summary-tile-mobile {
+.story-summary-tile-mobile {
padding-bottom: 7vh;
}
-oppia-story-summary-tile .title-text {
+.title-text {
border-right: 1px solid #7b7b7b;
font-weight: 500;
padding-right: 10px;
}
-oppia-story-summary-tile .disabled-link,
-oppia-story-summary-tile .disabled-link:hover,
-oppia-story-summary-tile .disabled-link:active {
+.disabled-link,
+.disabled-link:hover,
+.disabled-link:active {
color: #000;
pointer-events: none;
text-decoration: none;
}
-oppia-story-summary-tile .img-thumbnail {
+.img-thumbnail {
border: none;
max-height: 100%;
max-width: 100%;
}
-oppia-story-summary-tile .story-thumbnail {
+.story-thumbnail {
border-radius: 0.4em;
display: table-cell;
float: left;
@@ -128,81 +128,81 @@ oppia-story-summary-tile .story-thumbnail {
width: 225px;
}
-oppia-story-summary-tile a {
+a {
color: #000;
cursor: pointer;
}
-oppia-story-summary-tile a:hover {
+a:hover {
text-decoration: none;
}
-oppia-story-summary-tile .title-text a:hover,
-oppia-story-summary-tile .pending-chapter a:hover,
-oppia-story-summary-tile .incomplete-chapter a:hover,
-oppia-story-summary-tile .complete-chapter a:hover {
+.title-text a:hover,
+.pending-chapter a:hover,
+.incomplete-chapter a:hover,
+.complete-chapter a:hover {
text-decoration: underline;
}
-oppia-story-summary-tile .oppia-chapters-count {
+.oppia-chapters-count {
color: #404040;
font-size: 0.75em;
padding-left: 10px;
}
-oppia-story-summary-tile .chapters-list {
+.chapters-list {
margin-bottom: 20px;
}
-oppia-story-summary-tile .coming-soon-chapters {
+.coming-soon-chapters {
opacity: 0.4;
pointer-events: auto;
}
-oppia-story-summary-tile .chapters-heading {
+.chapters-heading {
display: flex;
justify-content: space-between;
padding: 0 10%;
}
-oppia-story-summary-tile .heading-separator {
+.heading-separator {
background-color: #525252;
height: 1px;
margin-top: 0;
width: 85%;
}
-oppia-story-summary-tile .heading-label {
+.heading-label {
font-size: 1.2em;
font-weight: 1000;
}
-oppia-story-summary-tile .chapters-count {
+.chapters-count {
color: #959595;
font-size: 1.1em;
font-weight: 1000;
}
-oppia-story-summary-tile .tablet-view {
+.tablet-view {
padding: 0;
width: 100%;
}
@media (max-width: 800px) {
- oppia-story-summary-tile .oppia-story-title {
+ .oppia-story-title {
padding-top: 0;
width: 75%;
}
- oppia-story-summary-tile .title-text,
- oppia-story-summary-tile .pending-chapter,
- oppia-story-summary-tile .incomplete-chapter,
- oppia-story-summary-tile .complete-chapter {
+ .title-text,
+ .pending-chapter,
+ .incomplete-chapter,
+ .complete-chapter {
text-decoration: underline;
}
}
-oppia-story-summary-tile .stories-can-play {
+.stories-can-play {
display: inline-block;
font-size: 1.4em;
font-weight: 500;
@@ -210,33 +210,33 @@ oppia-story-summary-tile .stories-can-play {
}
@media (max-width: 768px) {
- oppia-story-summary-tile .story-details {
+ .story-details {
border-radius: 0.4em;
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.2);
display: inherit;
width: 100%;
}
- oppia-story-summary-tile .story-progress-circle {
+ .story-progress-circle {
fill-opacity: 0;
stroke-width: 3;
}
- oppia-story-summary-tile .completed-circle {
+ .completed-circle {
stroke: #3cb8be;
}
- oppia-story-summary-tile .total-circle {
+ .total-circle {
stroke: rgba(78, 77, 77, 0.62);
}
- oppia-story-summary-tile .chapter-title {
+ .chapter-title {
display: block;
font-size: 1.1em;
margin-bottom: 0;
}
- oppia-story-summary-tile .view-button {
+ .view-button {
color: #00645c;
cursor: pointer;
font-weight: bold;
@@ -246,7 +246,7 @@ oppia-story-summary-tile .stories-can-play {
width: 100%;
}
- oppia-story-summary-tile .oppia-story-title {
+ .oppia-story-title {
display: inline-block;
font-size: 1.4em;
padding-bottom: 0;
@@ -255,38 +255,38 @@ oppia-story-summary-tile .stories-can-play {
width: 70%;
}
- oppia-story-summary-tile .title-text {
+ .title-text {
border-right: 0;
font-weight: 500;
padding-right: 10px;
text-decoration: none;
}
- oppia-story-summary-tile .story-thumbnail {
+ .story-thumbnail {
border: none;
border-radius: 0.4em 0.4em 0 0;
height: 160px;
width: 100%;
}
- oppia-story-summary-tile .chapter-details {
+ .chapter-details {
border: none;
margin-left: 0;
padding: 2vh 3vw 2vh 4vw;
width: 225px;
}
- oppia-story-summary-tile .story-progress-bar {
+ .story-progress-bar {
float: right;
padding-right: 12px;
padding-top: 5px;
}
- oppia-story-summary-tile .chapter-container {
+ .chapter-container {
background-color: rgba(230, 230, 230, 0.38);
}
- oppia-story-summary-tile .story-details-box {
+ .story-details-box {
border: none;
display: inline-block;
margin-left: 0;
@@ -294,19 +294,19 @@ oppia-story-summary-tile .stories-can-play {
width: 100%;
}
- oppia-story-summary-tile .bottom-edge-shadow {
+ .bottom-edge-shadow {
border-bottom: 0.8px solid rgb(255, 254, 254);
border-radius: 0.4em;
box-shadow: 0 6px 7px -4px #606060;
}
- oppia-story-summary-tile .oppia-chapters-count-text {
+ .oppia-chapters-count-text {
margin-bottom: 0;
margin-top: 10px;
width: 50%;
}
- oppia-story-summary-tile .chapter-specific-box {
+ .chapter-specific-box {
background-color: white;
border-radius: 0 0.4em 0.4em 0;
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.2);
@@ -314,7 +314,7 @@ oppia-story-summary-tile .stories-can-play {
padding: 0;
}
- oppia-story-summary-tile .chapter-icon {
+ .chapter-icon {
background-color: #4c9e8b;
border-radius: 0 10px 0 0;
float: left;
@@ -323,13 +323,13 @@ oppia-story-summary-tile .stories-can-play {
width: fit-content;
}
- oppia-story-summary-tile .chapter-icon-number {
+ .chapter-icon-number {
color: white;
font-weight: bolder;
padding: 2px;
}
- oppia-story-summary-tile .chapters-container-box {
+ .chapters-container-box {
border: none;
display: inline-block;
margin-left: 0;
@@ -337,34 +337,30 @@ oppia-story-summary-tile .stories-can-play {
width: 100%;
}
- oppia-story-summary-tile .chapter-text {
+ .chapter-text {
display: flex;
flex: 1;
justify-content: space-between;
margin-top: 10px;
}
- oppia-story-summary-tile .story-status-element {
+ .story-status-element {
font-size: 10px;
margin-bottom: 0;
margin-top: 3px;
}
}
-oppia-story-summary-tile .oppia-topic-viewer-container .background-banner-position {
- position: inherit;
-}
-
-oppia-story-summary-tile .down-caret-position {
+.down-caret-position {
vertical-align: middle;
width: 24px;
}
-oppia-story-summary-tile .up-caret-position {
+.up-caret-position {
width: 24px;
}
-oppia-story-summary-tile .classroom-new-chapter {
+.classroom-new-chapter {
align-items: center;
background: #106502;
border-radius: 4px;
@@ -377,7 +373,7 @@ oppia-story-summary-tile .classroom-new-chapter {
width: 42px;
}
-oppia-story-summary-tile .classroom-new-chapter .classroom-status {
+.classroom-new-chapter .classroom-status {
color: #fff;
font-family: 'Roboto', Arial, sans-serif;
font-size: 13px;
diff --git a/core/templates/components/summary-tile/story-summary-tile.component.ts b/core/templates/components/summary-tile/story-summary-tile.component.ts
index 99539a52bfb40..3f7f2d5775fd7 100644
--- a/core/templates/components/summary-tile/story-summary-tile.component.ts
+++ b/core/templates/components/summary-tile/story-summary-tile.component.ts
@@ -16,7 +16,7 @@
* @fileoverview Component for a canonical story tile.
*/
-import {Component, OnInit, ViewEncapsulation} from '@angular/core';
+import {Component, OnInit} from '@angular/core';
import {UrlInterpolationService} from 'domain/utilities/url-interpolation.service';
import {TopicViewerDomainConstants} from 'domain/topic_viewer/topic-viewer-domain.constants';
import {Input} from '@angular/core';
@@ -39,7 +39,6 @@ import constants from 'assets/constants';
selector: 'oppia-story-summary-tile',
templateUrl: './story-summary-tile.component.html',
styleUrls: ['./story-summary-tile.component.css'],
- encapsulation: ViewEncapsulation.None,
})
export class StorySummaryTileComponent implements OnInit {
// These properties are initialized using Angular lifecycle hooks
diff --git a/core/templates/domain/certificate-assessment/certificate-assessment-offering-backend-api.service.spec.ts b/core/templates/domain/certificate-assessment/certificate-assessment-offering-backend-api.service.spec.ts
index e7accf5910366..80d67279eda5e 100644
--- a/core/templates/domain/certificate-assessment/certificate-assessment-offering-backend-api.service.spec.ts
+++ b/core/templates/domain/certificate-assessment/certificate-assessment-offering-backend-api.service.spec.ts
@@ -1113,6 +1113,39 @@ describe('Certificate Assessment Offering backend api service', () => {
);
}));
+ it('should propagate the structured cooldown error body when starting a certificate assessment fails with a cooldown', fakeAsync(() => {
+ caos
+ .attemptCertificateAssessmentAsync('mock_certificate_id')
+ .then(successHandler, failHandler);
+
+ const req = httpTestingController.expectOne(
+ CertificateAssessmentDomainConstants.START_CERTIFICATE_ASSESSMENT_HANDLER_URL
+ );
+ expect(req.request.method).toEqual('POST');
+ req.flush(
+ {
+ error: 'I18N_CERTIFICATE_ASSESSMENT_COOLDOWN_ERROR',
+ error_type: 'cooldown',
+ remaining_minutes: 6,
+ status_code: 429,
+ },
+ {
+ status: 429,
+ statusText: 'Too Many Requests',
+ }
+ );
+
+ flushMicrotasks();
+
+ expect(successHandler).not.toHaveBeenCalled();
+ expect(failHandler).toHaveBeenCalledWith({
+ error: 'I18N_CERTIFICATE_ASSESSMENT_COOLDOWN_ERROR',
+ error_type: 'cooldown',
+ remaining_minutes: 6,
+ status_code: 429,
+ });
+ }));
+
it('should successfully submit a certificate assessment attempt', fakeAsync(() => {
caos
.submitCertificateAssessmentAttemptAsync('mock_attempt_id', [
diff --git a/core/templates/domain/certificate-assessment/certificate-assessment-offering-backend-api.service.ts b/core/templates/domain/certificate-assessment/certificate-assessment-offering-backend-api.service.ts
index 7f0fa4d3043fc..4473c3ff3a613 100644
--- a/core/templates/domain/certificate-assessment/certificate-assessment-offering-backend-api.service.ts
+++ b/core/templates/domain/certificate-assessment/certificate-assessment-offering-backend-api.service.ts
@@ -408,7 +408,18 @@ export class CertificateAssessmentOfferingBackendApiService {
questions: response.questions,
});
} catch (errorResponse) {
- throw errorResponse?.error?.error || errorResponse.message;
+ // Propagate a structured backend error (with error_type and
+ // remaining_minutes) so the caller can translate it; otherwise fall
+ // back to the usual error string.
+ const backendError = errorResponse?.error;
+ if (
+ backendError &&
+ typeof backendError === 'object' &&
+ (backendError as {error_type?: string}).error_type !== undefined
+ ) {
+ throw backendError;
+ }
+ throw errorResponse?.error?.error || errorResponse?.message;
}
}
diff --git a/core/templates/domain/cloud-task/cloud-task-run.model.spec.ts b/core/templates/domain/cloud-task/cloud-task-run.model.spec.ts
index e3d8d4f738816..82122a03ce771 100644
--- a/core/templates/domain/cloud-task/cloud-task-run.model.spec.ts
+++ b/core/templates/domain/cloud-task/cloud-task-run.model.spec.ts
@@ -148,4 +148,231 @@ describe('Cloud Task Run', () => {
expect(cloudTaskRun.getJobStatusMaterialIconCode()).toBe('check_circle');
expect(cloudTaskRun.getJobStatusMaterialThemeColor()).toBe('primary');
});
+
+ it('should return the language accents when provided', () => {
+ let cloudTaskRun = new CloudTaskRun(
+ '123',
+ 'Test Task',
+ 'SUCCEEDED',
+ 'function_456',
+ [],
+ 0,
+ new Date('2025-01-01T00:00:00Z'),
+ new Date('2025-01-01T00:00:00Z'),
+ {language_accents: 'en-US, es-ES'}
+ );
+
+ expect(cloudTaskRun.getLanguageAccents()).toBe('en-US, es-ES');
+ });
+
+ it(
+ 'should return the default message when language accents are not ' +
+ 'provided',
+ () => {
+ let cloudTaskRun = new CloudTaskRun(
+ '123',
+ 'Test Task',
+ 'SUCCEEDED',
+ 'function_456',
+ [],
+ 0,
+ new Date('2025-01-01T00:00:00Z'),
+ new Date('2025-01-01T00:00:00Z')
+ );
+
+ expect(cloudTaskRun.getLanguageAccents()).toBe(
+ 'No language accents available'
+ );
+ }
+ );
+
+ it(
+ 'should return the correct event name when exploration content is ' +
+ 'updated without additional context',
+ () => {
+ let cloudTaskRun = new CloudTaskRun(
+ '123',
+ 'Test Task',
+ 'SUCCEEDED',
+ 'regenerate_voiceovers_on_exploration_update',
+ [],
+ 0,
+ new Date('2025-01-01T00:00:00Z'),
+ new Date('2025-01-01T00:00:00Z')
+ );
+
+ expect(cloudTaskRun.getEventName()).toBe('Exploration content updated');
+ }
+ );
+
+ it(
+ 'should return the correct event name when exploration content is ' +
+ 'updated with additional context',
+ () => {
+ let cloudTaskRun = new CloudTaskRun(
+ '123',
+ 'Test Task',
+ 'SUCCEEDED',
+ 'regenerate_voiceovers_on_exploration_update',
+ [],
+ 0,
+ new Date('2025-01-01T00:00:00Z'),
+ new Date('2025-01-01T00:00:00Z'),
+ {exploration_title: 'Test Exploration'}
+ );
+
+ expect(cloudTaskRun.getEventName()).toBe(
+ 'Voiceovers regenerated for "Test Exploration" due to content ' +
+ 'being updated'
+ );
+ }
+ );
+
+ it(
+ 'should return the correct event name when exploration is added to ' +
+ 'topic without additional context',
+ () => {
+ let cloudTaskRun = new CloudTaskRun(
+ '123',
+ 'Test Task',
+ 'SUCCEEDED',
+ 'regenerate_voiceovers_on_exploration_added_to_topic',
+ [],
+ 0,
+ new Date('2025-01-01T00:00:00Z'),
+ new Date('2025-01-01T00:00:00Z')
+ );
+
+ expect(cloudTaskRun.getEventName()).toBe('Exploration added to topic');
+ }
+ );
+
+ it(
+ 'should return the correct event name when exploration is added to ' +
+ 'topic with additional context',
+ () => {
+ let cloudTaskRun = new CloudTaskRun(
+ '123',
+ 'Test Task',
+ 'SUCCEEDED',
+ 'regenerate_voiceovers_on_exploration_added_to_topic',
+ [],
+ 0,
+ new Date('2025-01-01T00:00:00Z'),
+ new Date('2025-01-01T00:00:00Z'),
+ {
+ exploration_title: 'Test Exploration',
+ topic_name: 'Test Topic',
+ }
+ );
+
+ expect(cloudTaskRun.getEventName()).toBe(
+ 'All voiceovers generated for "Test Exploration" because it was ' +
+ 'added to topic "Test Topic"'
+ );
+ }
+ );
+
+ it(
+ 'should return the correct event name for regeneration from ' +
+ 'voiceover admin page without additional context',
+ () => {
+ let cloudTaskRun = new CloudTaskRun(
+ '123',
+ 'Test Task',
+ 'SUCCEEDED',
+ 'regenerate_voiceovers_of_exploration_for_given_language_accent',
+ [],
+ 0,
+ new Date('2025-01-01T00:00:00Z'),
+ new Date('2025-01-01T00:00:00Z')
+ );
+
+ expect(cloudTaskRun.getEventName()).toBe(
+ 'Regeneration from voiceover admin page'
+ );
+ }
+ );
+
+ it(
+ 'should return the correct event name for regeneration from ' +
+ 'voiceover admin page with additional context',
+ () => {
+ let cloudTaskRun = new CloudTaskRun(
+ '123',
+ 'Test Task',
+ 'SUCCEEDED',
+ 'regenerate_voiceovers_of_exploration_for_given_language_accent',
+ [],
+ 0,
+ new Date('2025-01-01T00:00:00Z'),
+ new Date('2025-01-01T00:00:00Z'),
+ {exploration_title: 'Test Exploration'}
+ );
+
+ expect(cloudTaskRun.getEventName()).toBe(
+ 'All voiceovers regenerated for "Test Exploration" by voiceover ' +
+ 'admin'
+ );
+ }
+ );
+
+ it(
+ 'should return the correct event name for regeneration after ' +
+ 'accepting suggestion without additional context',
+ () => {
+ let cloudTaskRun = new CloudTaskRun(
+ '123',
+ 'Test Task',
+ 'SUCCEEDED',
+ 'regenerate_voiceovers_after_accepting_suggestion',
+ [],
+ 0,
+ new Date('2025-01-01T00:00:00Z'),
+ new Date('2025-01-01T00:00:00Z')
+ );
+
+ expect(cloudTaskRun.getEventName()).toBe(
+ 'Regeneration after accepting translation'
+ );
+ }
+ );
+
+ it(
+ 'should return the correct event name for regeneration after ' +
+ 'accepting suggestion with additional context',
+ () => {
+ let cloudTaskRun = new CloudTaskRun(
+ '123',
+ 'Test Task',
+ 'SUCCEEDED',
+ 'regenerate_voiceovers_after_accepting_suggestion',
+ [],
+ 0,
+ new Date('2025-01-01T00:00:00Z'),
+ new Date('2025-01-01T00:00:00Z'),
+ {exploration_title: 'Test Exploration'}
+ );
+
+ expect(cloudTaskRun.getEventName()).toBe(
+ 'Voiceover generated for "Test Exploration" because translation ' +
+ 'suggestion was accepted'
+ );
+ }
+ );
+
+ it('should return an empty string for an unrecognized function id', () => {
+ let cloudTaskRun = new CloudTaskRun(
+ '123',
+ 'Test Task',
+ 'SUCCEEDED',
+ 'some_unknown_function_id',
+ [],
+ 0,
+ new Date('2025-01-01T00:00:00Z'),
+ new Date('2025-01-01T00:00:00Z')
+ );
+
+ expect(cloudTaskRun.getEventName()).toBe('');
+ });
});
diff --git a/core/templates/domain/cloud-task/cloud-task-run.model.ts b/core/templates/domain/cloud-task/cloud-task-run.model.ts
index 8988e1d47f252..10ffa9f4d7175 100644
--- a/core/templates/domain/cloud-task/cloud-task-run.model.ts
+++ b/core/templates/domain/cloud-task/cloud-task-run.model.ts
@@ -16,6 +16,9 @@
* @fileoverview Frontend Model for Cloud task run.
*/
+export interface AdditionalContextualInformation {
+ [key: string]: string;
+}
export interface CloudTaskRunBackendDict {
task_run_id: string;
cloud_task_name: string;
@@ -25,6 +28,7 @@ export interface CloudTaskRunBackendDict {
current_retry_attempt: number;
last_updated: Date;
created_on: Date;
+ additional_contextual_information?: AdditionalContextualInformation;
}
export class MatIcon {
@@ -45,6 +49,7 @@ export class CloudTaskRun {
lastUpdated: Date;
createdOn: Date;
matIcon!: MatIcon;
+ additionalContextualInformation!: AdditionalContextualInformation;
constructor(
taskRunId: string,
@@ -54,7 +59,8 @@ export class CloudTaskRun {
exceptionMessagesForFailedRuns: string[],
currentRetryAttempt: number,
lastUpdated: Date,
- createdOn: Date
+ createdOn: Date,
+ additionalContextualInformation?: AdditionalContextualInformation
) {
this.id = taskRunId;
this.cloudTaskName = cloudTaskName;
@@ -64,6 +70,8 @@ export class CloudTaskRun {
this.currentRetryAttempt = currentRetryAttempt;
this.lastUpdated = lastUpdated;
this.createdOn = new Date(createdOn);
+ this.additionalContextualInformation =
+ additionalContextualInformation || {};
switch (this.latestJobState) {
case 'RUNNING':
@@ -95,7 +103,8 @@ export class CloudTaskRun {
backendDict.exception_messages_for_failed_runs,
backendDict.current_retry_attempt,
backendDict.last_updated,
- backendDict.created_on
+ backendDict.created_on,
+ backendDict.additional_contextual_information || {}
);
}
@@ -110,4 +119,71 @@ export class CloudTaskRun {
getJobStatusMaterialThemeColor(): string | null {
return this.matIcon.color;
}
+
+ getLanguageAccents(): string {
+ return (
+ this.additionalContextualInformation?.language_accents ||
+ 'No language accents available'
+ );
+ }
+
+ getEventName(): string {
+ const additionalContextualInformation =
+ this.additionalContextualInformation || {};
+ const isAdditionalContextProvided =
+ Object.keys(additionalContextualInformation).length > 0;
+
+ if (
+ this.functionId === 'regenerate_voiceovers_on_exploration_update' &&
+ !isAdditionalContextProvided
+ ) {
+ return 'Exploration content updated';
+ } else if (
+ this.functionId === 'regenerate_voiceovers_on_exploration_update' &&
+ isAdditionalContextProvided
+ ) {
+ return `Voiceovers regenerated for "${additionalContextualInformation.exploration_title}" due to content being updated`;
+ }
+
+ if (
+ this.functionId ===
+ 'regenerate_voiceovers_on_exploration_added_to_topic' &&
+ !isAdditionalContextProvided
+ ) {
+ return 'Exploration added to topic';
+ } else if (
+ this.functionId ===
+ 'regenerate_voiceovers_on_exploration_added_to_topic' &&
+ isAdditionalContextProvided
+ ) {
+ return `All voiceovers generated for "${additionalContextualInformation.exploration_title}" because it was added to topic "${additionalContextualInformation.topic_name}"`;
+ }
+
+ if (
+ this.functionId ===
+ 'regenerate_voiceovers_of_exploration_for_given_language_accent' &&
+ !isAdditionalContextProvided
+ ) {
+ return 'Regeneration from voiceover admin page';
+ } else if (
+ this.functionId ===
+ 'regenerate_voiceovers_of_exploration_for_given_language_accent' &&
+ isAdditionalContextProvided
+ ) {
+ return `All voiceovers regenerated for "${additionalContextualInformation.exploration_title}" by voiceover admin`;
+ }
+
+ if (
+ this.functionId === 'regenerate_voiceovers_after_accepting_suggestion' &&
+ !isAdditionalContextProvided
+ ) {
+ return 'Regeneration after accepting translation';
+ } else if (
+ this.functionId === 'regenerate_voiceovers_after_accepting_suggestion' &&
+ isAdditionalContextProvided
+ ) {
+ return `Voiceover generated for "${additionalContextualInformation.exploration_title}" because translation suggestion was accepted`;
+ }
+ return '';
+ }
}
diff --git a/core/templates/domain/skill/concept-card-backend-api.service.spec.ts b/core/templates/domain/skill/concept-card-backend-api.service.spec.ts
index c25193b11d7fc..175dd04b6e4fa 100644
--- a/core/templates/domain/skill/concept-card-backend-api.service.spec.ts
+++ b/core/templates/domain/skill/concept-card-backend-api.service.spec.ts
@@ -128,6 +128,77 @@ describe('Concept card backend API service', () => {
expect(failHandler).not.toHaveBeenCalled();
}));
+ it('should send the language code when a non-English language is requested', fakeAsync(() => {
+ let successHandler = jasmine.createSpy('success');
+ let failHandler = jasmine.createSpy('fail');
+
+ conceptCardBackendApiService
+ .loadConceptCardsAsync(['1'], 'es')
+ .then(successHandler, failHandler);
+ var req = httpTestingController.expectOne(
+ '/concept_card_handler/' +
+ encodeURIComponent('["1"]') +
+ '?language_code=es'
+ );
+ expect(req.request.method).toEqual('GET');
+ expect(req.request.params.get('language_code')).toEqual('es');
+ req.flush(sampleResponse1);
+
+ flushMicrotasks();
+
+ expect(successHandler).toHaveBeenCalledWith(conceptCardSampleResponse1);
+ expect(failHandler).not.toHaveBeenCalled();
+ }));
+
+ it('should not send a language code when English is requested', fakeAsync(() => {
+ let successHandler = jasmine.createSpy('success');
+ let failHandler = jasmine.createSpy('fail');
+
+ conceptCardBackendApiService
+ .loadConceptCardsAsync(['1'], 'en')
+ .then(successHandler, failHandler);
+ var req = httpTestingController.expectOne(
+ '/concept_card_handler/' + encodeURIComponent('["1"]')
+ );
+ expect(req.request.params.has('language_code')).toBeFalse();
+ req.flush(sampleResponse1);
+
+ flushMicrotasks();
+
+ expect(successHandler).toHaveBeenCalledWith(conceptCardSampleResponse1);
+ expect(failHandler).not.toHaveBeenCalled();
+ }));
+
+ it('should not reuse a cached concept card across languages', fakeAsync(() => {
+ let successHandler = jasmine.createSpy('success');
+ let failHandler = jasmine.createSpy('fail');
+
+ conceptCardBackendApiService
+ .loadConceptCardsAsync(['1'])
+ .then(successHandler, failHandler);
+ var englishReq = httpTestingController.expectOne(
+ '/concept_card_handler/' + encodeURIComponent('["1"]')
+ );
+ englishReq.flush(sampleResponse1);
+ flushMicrotasks();
+
+ // The same skill in a different language must be fetched again rather
+ // than served from the English cache.
+ conceptCardBackendApiService
+ .loadConceptCardsAsync(['1'], 'es')
+ .then(successHandler, failHandler);
+ var spanishReq = httpTestingController.expectOne(
+ '/concept_card_handler/' +
+ encodeURIComponent('["1"]') +
+ '?language_code=es'
+ );
+ expect(spanishReq.request.method).toEqual('GET');
+ spanishReq.flush(sampleResponse1);
+ flushMicrotasks();
+
+ expect(failHandler).not.toHaveBeenCalled();
+ }));
+
it('should succesfully fetch multiple concept cards from the backend', fakeAsync(() => {
let successHandler = jasmine.createSpy('success');
let failHandler = jasmine.createSpy('fail');
diff --git a/core/templates/domain/skill/concept-card-backend-api.service.ts b/core/templates/domain/skill/concept-card-backend-api.service.ts
index 080523284778f..9d35e86dc08e5 100644
--- a/core/templates/domain/skill/concept-card-backend-api.service.ts
+++ b/core/templates/domain/skill/concept-card-backend-api.service.ts
@@ -26,6 +26,7 @@ import {
ConceptCard,
ConceptCardBackendDict,
} from 'domain/skill/concept-card.model';
+import {AppConstants} from 'app.constants';
import {SkillDomainConstants} from 'domain/skill/skill-domain.constants';
import {UrlInterpolationService} from 'domain/utilities/url-interpolation.service';
@@ -47,6 +48,7 @@ export class ConceptCardBackendApiService {
private _fetchConceptCards(
skillIds: string[],
+ languageCode: string,
successCallback: (value: ConceptCard[]) => void,
errorCallback: (reason: string) => void
): void {
@@ -58,9 +60,15 @@ export class ConceptCardBackendApiService {
);
const conceptCardObjects: ConceptCard[] = [];
+ const params: Record = {};
+ // English is the language in which the content is authored, so there is
+ // nothing to look up for it.
+ if (languageCode !== AppConstants.DEFAULT_LANGUAGE_CODE) {
+ params.language_code = languageCode;
+ }
this.http
- .get(conceptCardDataUrl)
+ .get(conceptCardDataUrl, {params})
.toPromise()
.then(
response => {
@@ -82,29 +90,44 @@ export class ConceptCardBackendApiService {
);
}
- private _isCached(skillId: string): boolean {
- return this._conceptCardCache.hasOwnProperty(skillId);
+ // Concept cards are cached per language, since the same skill renders
+ // different content depending on the language it is requested in.
+ private _getCacheKey(skillId: string, languageCode: string): string {
+ return `${languageCode}:${skillId}`;
}
- private _getUncachedSkillIds(skillIds: string[]): string[] {
+ private _isCached(skillId: string, languageCode: string): boolean {
+ return this._conceptCardCache.hasOwnProperty(
+ this._getCacheKey(skillId, languageCode)
+ );
+ }
+
+ private _getUncachedSkillIds(
+ skillIds: string[],
+ languageCode: string
+ ): string[] {
const uncachedSkillIds: string[] = [];
skillIds.forEach(skillId => {
- if (!this._isCached(skillId)) {
+ if (!this._isCached(skillId, languageCode)) {
uncachedSkillIds.push(skillId);
}
});
return uncachedSkillIds;
}
- async loadConceptCardsAsync(skillIds: string[]): Promise {
+ async loadConceptCardsAsync(
+ skillIds: string[],
+ languageCode: string = AppConstants.DEFAULT_LANGUAGE_CODE
+ ): Promise {
return new Promise((resolve, reject) => {
- var uncachedSkillIds = this._getUncachedSkillIds(skillIds);
+ var uncachedSkillIds = this._getUncachedSkillIds(skillIds, languageCode);
const conceptCards: ConceptCard[] = [];
if (uncachedSkillIds.length !== 0) {
// Case where only part (or none) of the concept cards are cached
// locally.
this._fetchConceptCards(
uncachedSkillIds,
+ languageCode,
uncachedConceptCards => {
skillIds.forEach(skillId => {
if (uncachedSkillIds.includes(skillId)) {
@@ -112,11 +135,19 @@ export class ConceptCardBackendApiService {
uncachedConceptCards[uncachedSkillIds.indexOf(skillId)]
);
// Save the fetched conceptCards to avoid future fetches.
- this._conceptCardCache[skillId] = cloneDeep(
+ this._conceptCardCache[
+ this._getCacheKey(skillId, languageCode)
+ ] = cloneDeep(
uncachedConceptCards[uncachedSkillIds.indexOf(skillId)]
);
} else {
- conceptCards.push(cloneDeep(this._conceptCardCache[skillId]));
+ conceptCards.push(
+ cloneDeep(
+ this._conceptCardCache[
+ this._getCacheKey(skillId, languageCode)
+ ]
+ )
+ );
}
});
if (resolve) {
@@ -128,7 +159,11 @@ export class ConceptCardBackendApiService {
} else {
// Case where all of the concept cards are cached locally.
skillIds.forEach(skillId => {
- conceptCards.push(cloneDeep(this._conceptCardCache[skillId]));
+ conceptCards.push(
+ cloneDeep(
+ this._conceptCardCache[this._getCacheKey(skillId, languageCode)]
+ )
+ );
});
if (resolve) {
resolve(cloneDeep(conceptCards));
diff --git a/core/templates/domain/skill/concept-card.model.spec.ts b/core/templates/domain/skill/concept-card.model.spec.ts
index 2fbe350d6c221..fa8aa4a7f7168 100644
--- a/core/templates/domain/skill/concept-card.model.spec.ts
+++ b/core/templates/domain/skill/concept-card.model.spec.ts
@@ -37,6 +37,7 @@ describe('Concept card object factory', () => {
explanation: {},
},
},
+ skill_description: 'test description',
};
});
@@ -45,6 +46,19 @@ describe('Concept card object factory', () => {
expect(conceptCard.getExplanation()).toEqual(
SubtitledHtml.createDefault('test explanation', 'explanation')
);
+ expect(conceptCard.getSkillDescription()).toEqual('test description');
+ });
+
+ it('should default the skill description when the backend omits it', () => {
+ // A skill's own contents reuse this dict shape and carry no description.
+ const dictWithoutDescription = {
+ explanation: conceptCardDict.explanation,
+ recorded_voiceovers: conceptCardDict.recorded_voiceovers,
+ };
+ const conceptCard = ConceptCard.createFromBackendDict(
+ dictWithoutDescription
+ );
+ expect(conceptCard.getSkillDescription()).toEqual('');
});
it('should set explanation', () => {
diff --git a/core/templates/domain/skill/concept-card.model.ts b/core/templates/domain/skill/concept-card.model.ts
index 1240feb0db4d8..1666a65e36837 100644
--- a/core/templates/domain/skill/concept-card.model.ts
+++ b/core/templates/domain/skill/concept-card.model.ts
@@ -29,25 +29,45 @@ import {
export interface ConceptCardBackendDict {
explanation: SubtitledHtmlBackendDict;
recorded_voiceovers: RecordedVoiceOverBackendDict;
+ // Only the concept card handler supplies this. The same dict shape is reused
+ // for a skill's contents in the editor, where the description lives on the
+ // skill itself rather than on its contents.
+ skill_description?: string;
}
export class ConceptCard {
_explanation: SubtitledHtml;
_recordedVoiceovers: RecordedVoiceovers;
+ _skillDescription: string;
constructor(
explanation: SubtitledHtml,
- recordedVoiceovers: RecordedVoiceovers
+ recordedVoiceovers: RecordedVoiceovers,
+ skillDescription: string = ''
) {
this._explanation = explanation;
this._recordedVoiceovers = recordedVoiceovers;
+ this._skillDescription = skillDescription;
}
toBackendDict(): ConceptCardBackendDict {
- return {
+ const conceptCardBackendDict: ConceptCardBackendDict = {
explanation: this._explanation.toBackendDict(),
recorded_voiceovers: this._recordedVoiceovers.toBackendDict(),
};
+ // A skill's contents carry no description, so the key is only added back
+ // when it came from the concept card handler. This keeps the dict a skill
+ // sends to the backend unchanged.
+ if (this._skillDescription) {
+ conceptCardBackendDict.skill_description = this._skillDescription;
+ }
+ return conceptCardBackendDict;
+ }
+
+ // The skill description is shown as the concept card's heading, and is
+ // translated alongside the explanation.
+ getSkillDescription(): string {
+ return this._skillDescription;
}
getExplanation(): SubtitledHtml {
@@ -69,7 +89,8 @@ export class ConceptCard {
SubtitledHtml.createFromBackendDict(conceptCardBackendDict.explanation),
RecordedVoiceovers.createFromBackendDict(
conceptCardBackendDict.recorded_voiceovers
- )
+ ),
+ conceptCardBackendDict.skill_description
);
}
}
diff --git a/core/templates/domain/summary/exploration-summary-backend-api.service.ts b/core/templates/domain/summary/exploration-summary-backend-api.service.ts
index 74038267f56eb..94fedc0c4f16b 100644
--- a/core/templates/domain/summary/exploration-summary-backend-api.service.ts
+++ b/core/templates/domain/summary/exploration-summary-backend-api.service.ts
@@ -23,6 +23,7 @@ import {Injectable} from '@angular/core';
import {AppConstants} from 'app.constants';
import {AlertsService} from 'services/alerts.service';
import {HumanReadableContributorsSummary} from 'domain/summary/creator-exploration-summary.model';
+import {TranslatableExplorationMetadataField} from 'domain/summary/learner-exploration-summary.model';
import {ValidatorsService} from 'services/validators.service';
export interface ExplorationSummaryBackendDict {
@@ -42,6 +43,7 @@ export interface ExplorationSummaryDict {
thumbnail_bg_color: string;
thumbnail_icon_url: string;
title: string;
+ translated_metadata_fields?: TranslatableExplorationMetadataField[];
}
@Injectable({
diff --git a/core/templates/domain/summary/learner-exploration-summary.model.ts b/core/templates/domain/summary/learner-exploration-summary.model.ts
index b413435900093..a83553ab99de6 100644
--- a/core/templates/domain/summary/learner-exploration-summary.model.ts
+++ b/core/templates/domain/summary/learner-exploration-summary.model.ts
@@ -26,6 +26,16 @@ export interface ExplorationRatings {
'5': number;
}
+// The metadata fields of an exploration summary that contributors can
+// translate. The backend reports which of them it returned translated, so that
+// clients only fall back to their hardcoded translation bundles for the rest.
+export enum TranslatableExplorationMetadataField {
+ TITLE = 'title',
+ OBJECTIVE = 'objective',
+ CATEGORY = 'category',
+ TAGS = 'tags',
+}
+
export interface LearnerExplorationSummaryBackendDict {
category: string;
community_owned: boolean;
@@ -45,6 +55,7 @@ export interface LearnerExplorationSummaryBackendDict {
title: string;
visited_checkpoints_count?: number;
total_checkpoints_count?: number;
+ translated_metadata_fields?: TranslatableExplorationMetadataField[];
}
export class LearnerExplorationSummary {
diff --git a/core/templates/pages/certificate-assessment-player-page/certificate-assessment-conversation-skin.component.html b/core/templates/pages/certificate-assessment-player-page/certificate-assessment-conversation-skin.component.html
index fde2a6964676c..ddead7d6798d3 100644
--- a/core/templates/pages/certificate-assessment-player-page/certificate-assessment-conversation-skin.component.html
+++ b/core/templates/pages/certificate-assessment-player-page/certificate-assessment-conversation-skin.component.html
@@ -34,7 +34,7 @@
class="certificate-assessment-interaction conversation-skin-interaction-container">
diff --git a/core/templates/pages/certificate-assessment-player-page/certificate-assessment-conversation-skin.component.spec.ts b/core/templates/pages/certificate-assessment-player-page/certificate-assessment-conversation-skin.component.spec.ts
index 7345594462f3b..7ba016e95567f 100644
--- a/core/templates/pages/certificate-assessment-player-page/certificate-assessment-conversation-skin.component.spec.ts
+++ b/core/templates/pages/certificate-assessment-player-page/certificate-assessment-conversation-skin.component.spec.ts
@@ -153,4 +153,47 @@ describe('CertificateAssessmentConversationSkinComponent', () => {
expect(() => component.onSubmitAssessment()).toThrowError('submit failed');
expect(component.submitAssessment.emit).not.toHaveBeenCalled();
});
+
+ it('should append null answer token when lastAnswer is null', () => {
+ component.interactionHtml =
+ '
';
+ component.lastAnswer = null;
+
+ expect(component.cachedInteractionHtml).toBe(
+ '
'
+ );
+ });
+
+ it('should append JSON-encoded answer token when lastAnswer is set', () => {
+ component.interactionHtml =
+ '
';
+ component.lastAnswer = 'hello';
+
+ expect(component.cachedInteractionHtml).toBe(
+ '
'
+ );
+ });
+
+ it('should replace double dashes with double underscores in the answer token', () => {
+ component.interactionHtml =
+ '
';
+ component.lastAnswer = 'a--b';
+
+ const expectedJson = JSON.stringify('a--b');
+ expect(component.cachedInteractionHtml).toBe(
+ `
`
+ );
+ });
+
+ it('should change cached HTML when lastAnswer changes to force interaction rebuild', () => {
+ component.interactionHtml =
+ '
';
+ component.lastAnswer = null;
+ const htmlWithNull = component.cachedInteractionHtml;
+
+ component.lastAnswer = 'new-value';
+ const htmlWithAnswer = component.cachedInteractionHtml;
+
+ expect(htmlWithNull).not.toBe(htmlWithAnswer);
+ });
});
diff --git a/core/templates/pages/certificate-assessment-player-page/certificate-assessment-conversation-skin.component.ts b/core/templates/pages/certificate-assessment-player-page/certificate-assessment-conversation-skin.component.ts
index 2403e903fb87a..a9ca303cda7e7 100644
--- a/core/templates/pages/certificate-assessment-player-page/certificate-assessment-conversation-skin.component.ts
+++ b/core/templates/pages/certificate-assessment-player-page/certificate-assessment-conversation-skin.component.ts
@@ -14,14 +14,15 @@
/**
* @fileoverview Component for the certificate assessment conversation skin,
- * i.e. the question-by-question player screen (progress bar,
- * question card, and navigation actions).
+ * i.e. the question-by-question player screen (progress bar, question card,
+ * and navigation actions).
*/
import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core';
import {UrlInterpolationService} from 'domain/utilities/url-interpolation.service';
import {AssessmentQuestion} from 'domain/certificate-assessment/certificate-assessment.model';
import {CurrentInteractionService} from 'pages/exploration-player-page/services/current-interaction.service';
+import {InteractionAnswer} from 'interactions/answer-defs';
import './certificate-assessment-conversation-skin.component.css';
@Component({
@@ -36,6 +37,11 @@ export class CertificateAssessmentConversationSkinComponent implements OnInit {
@Input() progressPercentage = 0;
@Input() isLastQuestion = false;
@Input() interactionHtml = '';
+ // Previously recorded answer for the current question, restored into the
+ // interaction so the learner sees their earlier input after navigating away
+ // and back. Consumed by the interaction via the `[last-answer]` binding in
+ // the generated interaction HTML (see exploration-html-formatter.service).
+ @Input() lastAnswer: InteractionAnswer | null = null;
@Output() previousQuestion = new EventEmitter
();
@Output() nextQuestion = new EventEmitter();
@@ -68,6 +74,19 @@ export class CertificateAssessmentConversationSkinComponent implements OnInit {
this.nextQuestion.emit();
}
+ // The interaction only reads `lastAnswer` when it is (re)built, so we must
+ // change `htmlData` whenever the stored answer changes to force a rebuild
+ // and re-apply the pre-populated value. A trailing comment with the encoded
+ // answer is harmless to the parsed interaction tag but makes the string
+ // unique per answer, guaranteeing the interaction refreshes.
+ get cachedInteractionHtml(): string {
+ const answerToken =
+ this.lastAnswer === null
+ ? 'null'
+ : JSON.stringify(this.lastAnswer).replace(/--/g, '__');
+ return this.interactionHtml + '';
+ }
+
onSubmitAssessment(): void {
if (this.currentInteractionService.isSubmitAnswerFnRegistered()) {
this.currentInteractionService.submitAnswer();
diff --git a/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-page-auth.guard.spec.ts b/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-page-auth.guard.spec.ts
index ce3c9dd356230..248431efa4d86 100644
--- a/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-page-auth.guard.spec.ts
+++ b/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-page-auth.guard.spec.ts
@@ -168,6 +168,10 @@ describe('CertificateAssessmentPlayerPageAuthGuard', () => {
const navigateSpy = spyOn(router, 'navigate').and.returnValue(
Promise.resolve(true)
);
+ // The guard replaces the browser state with the requested URL after
+ // redirecting, so the call must be intercepted to prevent it from
+ // mutating the real window history and leaking the URL to other tests.
+ const replaceStateSpy = spyOn(location, 'replaceState');
await guard.canActivate(
{} as never,
@@ -178,6 +182,9 @@ describe('CertificateAssessmentPlayerPageAuthGuard', () => {
expect(navigateSpy).toHaveBeenCalledWith([
`${AppConstants.PAGES_REGISTERED_WITH_FRONTEND.ERROR.ROUTE}/404`,
]);
+ expect(replaceStateSpy).toHaveBeenCalledWith(
+ '/certificate-assessment/cert-1'
+ );
});
it('should still replace the state if redirect navigation fails', async () => {
diff --git a/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-page-root.component.html b/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-page-root.component.html
index 3ef6a646b9a67..f61b748959baa 100644
--- a/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-page-root.component.html
+++ b/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-page-root.component.html
@@ -14,6 +14,9 @@
(retryAssessment)="onRetryAssessment()"
(resumeAssessment)="onResumeAssessment()">
+
+
diff --git a/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-page-root.component.spec.ts b/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-page-root.component.spec.ts
index b3450408c1ff9..4206af349cef2 100644
--- a/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-page-root.component.spec.ts
+++ b/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-page-root.component.spec.ts
@@ -18,6 +18,7 @@
import {fakeAsync, flushMicrotasks, TestBed, tick} from '@angular/core/testing';
import {ActivatedRoute, Router} from '@angular/router';
+import {EventEmitter} from '@angular/core';
import {TranslateService} from '@ngx-translate/core';
import {AppConstants} from 'app.constants';
import {CertificateAssessmentOfferingBackendApiService} from 'domain/certificate-assessment/certificate-assessment-offering-backend-api.service';
@@ -28,13 +29,17 @@ import {
import {ClassroomBackendApiService} from 'domain/classroom/classroom-backend-api.service';
import {PageHeadService} from 'services/page-head.service';
import {AlertsService} from 'services/alerts.service';
+import {InternetConnectivityService} from 'services/internet-connectivity.service';
import {CertificateAssessmentPlayerPageConstants} from './certificate-assessment-player-page.constants';
import {CertificateAssessmentPlayerPageRootComponent} from './certificate-assessment-player-page-root.component';
+import {CertificateAssessmentPlayerStateService} from './certificate-assessment-player-state.service';
describe('CertificateAssessmentPlayerPageRootComponent', () => {
let component: CertificateAssessmentPlayerPageRootComponent;
let alertsService: AlertsService;
let certificateAssessmentOfferingBackendApiService: CertificateAssessmentOfferingBackendApiService;
+ let playerStateService: CertificateAssessmentPlayerStateService;
+ let internetConnectivityService: InternetConnectivityService;
let router: Router;
let translateService: jasmine.SpyObj;
@@ -97,6 +102,13 @@ describe('CertificateAssessmentPlayerPageRootComponent', () => {
'addWarning',
]);
+ const internetConnectivityServiceSpy = jasmine.createSpyObj(
+ 'InternetConnectivityService',
+ ['startCheckingConnection']
+ );
+ internetConnectivityServiceSpy.onInternetStateChange =
+ new EventEmitter();
+
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [{provide: ActivatedRoute, useValue: activatedRouteStubValue}],
@@ -107,18 +119,24 @@ describe('CertificateAssessmentPlayerPageRootComponent', () => {
]);
translateServiceSpy.instant.and.callFake((key: string) => key);
+ const playerStateServiceInstance =
+ new CertificateAssessmentPlayerStateService();
component = new CertificateAssessmentPlayerPageRootComponent(
TestBed.inject(ActivatedRoute),
alertsServiceSpy,
certificateAssessmentOfferingBackendApiServiceSpy,
+ playerStateServiceInstance,
{} as ClassroomBackendApiService,
+ internetConnectivityServiceSpy,
{} as PageHeadService,
routerSpy,
translateServiceSpy
);
+ playerStateService = playerStateServiceInstance;
alertsService = alertsServiceSpy;
certificateAssessmentOfferingBackendApiService =
certificateAssessmentOfferingBackendApiServiceSpy;
+ internetConnectivityService = internetConnectivityServiceSpy;
router = routerSpy;
translateService = translateServiceSpy;
};
@@ -176,6 +194,78 @@ describe('CertificateAssessmentPlayerPageRootComponent', () => {
expect(component.currentStage).toBe('intro');
}));
+ it('should load the classroom fragment before starting a session attempt', fakeAsync(async () => {
+ await configureComponent('session');
+ const classroomBackendApiServiceSpy = jasmine.createSpyObj(
+ 'ClassroomBackendApiService',
+ ['getClassroomDataAsync']
+ );
+ let resolveClassroom!: (value: {
+ classroomDict: {urlFragment: string};
+ }) => void;
+ classroomBackendApiServiceSpy.getClassroomDataAsync.and.returnValue(
+ new Promise(resolve => {
+ resolveClassroom = resolve;
+ })
+ );
+
+ (
+ certificateAssessmentOfferingBackendApiService.attemptCertificateAssessmentAsync as jasmine.Spy
+ ).and.callFake(() => Promise.reject('Error'));
+ component = new CertificateAssessmentPlayerPageRootComponent(
+ TestBed.inject(ActivatedRoute),
+ alertsService,
+ certificateAssessmentOfferingBackendApiService,
+ playerStateService,
+ classroomBackendApiServiceSpy,
+ internetConnectivityService,
+ {} as PageHeadService,
+ router,
+ translateService
+ );
+
+ component.ngOnInit();
+ flushMicrotasks();
+
+ expect(
+ classroomBackendApiServiceSpy.getClassroomDataAsync
+ ).toHaveBeenCalled();
+
+ expect(
+ certificateAssessmentOfferingBackendApiService.attemptCertificateAssessmentAsync
+ ).not.toHaveBeenCalled();
+
+ resolveClassroom({classroomDict: {urlFragment: 'math'}});
+ flushMicrotasks();
+
+ expect(component.showAssessmentUnavailableModal).toBe(true);
+
+ component.onGoToAvailableCertificates();
+ flushMicrotasks();
+
+ expect(router.navigate).toHaveBeenCalledWith([
+ `/${AppConstants.PAGES_REGISTERED_WITH_FRONTEND.CERTIFICATE_OFFERING_AVAILABLE.ROUTE.replace(
+ ':classroomUrlFragment',
+ 'math'
+ )}`,
+ ]);
+ }));
+
+ it('should not start a session attempt when the offering fails to load', fakeAsync(async () => {
+ await configureComponent('session');
+ (
+ certificateAssessmentOfferingBackendApiService.getCertificateAssessmentOfferingAsync as jasmine.Spy
+ ).and.returnValue(Promise.reject('Error'));
+
+ component.ngOnInit();
+ flushMicrotasks();
+
+ expect(component.hasError).toBe(true);
+ expect(
+ certificateAssessmentOfferingBackendApiService.attemptCertificateAssessmentAsync
+ ).not.toHaveBeenCalled();
+ }));
+
it('should stay on the intro stage when the session route fails to start an attempt', fakeAsync(async () => {
await configureComponent('session');
(
@@ -187,12 +277,7 @@ describe('CertificateAssessmentPlayerPageRootComponent', () => {
expect(component.attempt).toBeNull();
expect(component.currentStage).toBe('intro');
- expect(translateService.instant).toHaveBeenCalledWith(
- 'I18N_CERTIFICATE_ASSESSMENT_START_WARNING'
- );
- expect(alertsService.addWarning).toHaveBeenCalledWith(
- 'I18N_CERTIFICATE_ASSESSMENT_START_WARNING'
- );
+ expect(component.showAssessmentUnavailableModal).toBe(true);
}));
it('should redirect to the 404 page when the offering fails to load', fakeAsync(() => {
@@ -243,119 +328,92 @@ describe('CertificateAssessmentPlayerPageRootComponent', () => {
expect(component.currentStage).toBe('questions');
}));
- it('should start the countdown timer when questions are available', fakeAsync(() => {
- spyOn(window, 'setInterval').and.returnValue(123 as never);
- component.attempt = mockAttempt;
- component.certificateOffering = mockOffering;
- component.currentStage =
- CertificateAssessmentPlayerPageConstants.STAGE_QUESTIONS;
-
- (
- component as unknown as {startTimerIfReady: () => void}
- ).startTimerIfReady();
+ // Arms a fully running countdown through the state service: registers
+ // the attempt (moving the learner to the questions stage) and applies
+ // the offering's one-hour time limit, which starts the interval.
+ const armCountdown = (): void => {
+ playerStateService.beginNewAttempt(mockAttempt);
+ playerStateService.configureForOffering(60);
+ };
- expect(component.remainingTimeInSeconds).toBe(3600);
- expect(window.setInterval).toHaveBeenCalled();
- }));
+ it('should reset stale timing state only when a replacement attempt succeeds', fakeAsync(() => {
+ spyOn(window, 'setInterval').and.callThrough();
+ spyOn(window, 'clearInterval').and.callThrough();
+ component.certificateId = 'cert-123';
+ armCountdown();
+ tick(3600000);
+ expect(component.isTimeExpired).toBe(true);
- it('should decrement the countdown and mark the assessment expired at zero', fakeAsync(() => {
- component.attempt = mockAttempt;
- component.certificateOffering = mockOffering;
- component.currentStage =
- CertificateAssessmentPlayerPageConstants.STAGE_QUESTIONS;
+ component.startAssessment();
+ flushMicrotasks();
- (
- component as unknown as {startTimerIfReady: () => void}
- ).startTimerIfReady();
+ expect(component.isTimeExpired).toBe(false);
expect(component.remainingTimeInSeconds).toBe(3600);
-
- tick(2000);
- expect(component.remainingTimeInSeconds).toBe(3598);
- expect(component.isTimeExpired).toBeFalse();
-
- tick(3598000);
- expect(component.remainingTimeInSeconds).toBe(0);
- expect(component.isTimeExpired).toBeTrue();
+ expect(window.clearInterval).toHaveBeenCalled();
+ expect(window.setInterval).toHaveBeenCalledTimes(2);
+ component.ngOnDestroy();
}));
- it('should not start the countdown without an attempt', () => {
- spyOn(window, 'setInterval');
- component.certificateOffering = mockOffering;
- component.currentStage =
- CertificateAssessmentPlayerPageConstants.STAGE_QUESTIONS;
-
- (
- component as unknown as {startTimerIfReady: () => void}
- ).startTimerIfReady();
-
- expect(window.setInterval).not.toHaveBeenCalled();
- expect(component.remainingTimeInSeconds).toBe(0);
- });
-
- it('should not start the countdown without a positive time limit', () => {
- spyOn(window, 'setInterval');
- component.attempt = mockAttempt;
- component.currentStage =
- CertificateAssessmentPlayerPageConstants.STAGE_QUESTIONS;
-
+ it('should leave the existing time window untouched when starting a new attempt fails', fakeAsync(() => {
+ spyOn(window, 'setInterval').and.callThrough();
+ component.certificateId = 'cert-123';
+ armCountdown();
+ tick(60000);
(
- component as unknown as {startTimerIfReady: () => void}
- ).startTimerIfReady();
+ certificateAssessmentOfferingBackendApiService.attemptCertificateAssessmentAsync as jasmine.Spy
+ ).and.returnValue(Promise.reject('Error'));
- expect(window.setInterval).not.toHaveBeenCalled();
- expect(component.remainingTimeInSeconds).toBe(0);
- });
+ component.startAssessment();
+ flushMicrotasks();
- it('should not start the countdown outside the questions stage', () => {
- spyOn(window, 'setInterval');
- component.attempt = mockAttempt;
- component.certificateOffering = mockOffering;
- component.currentStage =
- CertificateAssessmentPlayerPageConstants.STAGE_INTRO;
+ // A failed start request must neither wipe nor extend the current
+ // window: resetting is reserved for successfully begun attempts.
+ expect(component.isTimeExpired).toBe(false);
+ expect(playerStateService.getAttempt()).toEqual(mockAttempt);
+ expect(alertsService.addWarning).toHaveBeenCalledWith(
+ 'I18N_CERTIFICATE_ASSESSMENT_START_WARNING'
+ );
+ component.ngOnDestroy();
+ }));
- (
- component as unknown as {startTimerIfReady: () => void}
- ).startTimerIfReady();
+ it('should not touch timing state on retry and reset only when the new attempt begins', fakeAsync(() => {
+ spyOn(window, 'setInterval').and.callThrough();
+ spyOn(window, 'clearInterval').and.callThrough();
+ component.certificateId = 'cert-123';
+ armCountdown();
+ tick(3600000);
+ expect(component.isTimeExpired).toBe(true);
- expect(window.setInterval).not.toHaveBeenCalled();
- expect(component.remainingTimeInSeconds).toBe(0);
- });
+ component.onRetryAssessment();
- it('should not restart an already running countdown', () => {
- spyOn(window, 'setInterval').and.returnValue(123 as never);
- component.attempt = mockAttempt;
- component.certificateOffering = mockOffering;
- component.currentStage =
- CertificateAssessmentPlayerPageConstants.STAGE_QUESTIONS;
+ // Retry is pure navigation: the learner goes back to the intro and
+ // the expired window lingers until a new attempt actually begins.
+ expect(component.currentStage).toBe(
+ CertificateAssessmentPlayerPageConstants.STAGE_INTRO
+ );
+ expect(component.showAssessmentInterruptCard).toBe(false);
+ expect(component.isTimeExpired).toBe(true);
+ expect(window.clearInterval).toHaveBeenCalledTimes(1);
- (
- component as unknown as {startTimerIfReady: () => void}
- ).startTimerIfReady();
- (
- component as unknown as {startTimerIfReady: () => void}
- ).startTimerIfReady();
+ component.startAssessment();
+ flushMicrotasks();
- expect(window.setInterval).toHaveBeenCalledTimes(1);
- });
+ expect(component.isTimeExpired).toBe(false);
+ expect(component.remainingTimeInSeconds).toBe(3600);
+ expect(window.setInterval).toHaveBeenCalledTimes(2);
+ component.ngOnDestroy();
+ }));
it('should clear the countdown timer on destroy', fakeAsync(() => {
- component.attempt = mockAttempt;
- component.certificateOffering = mockOffering;
- component.currentStage =
- CertificateAssessmentPlayerPageConstants.STAGE_QUESTIONS;
-
- (
- component as unknown as {startTimerIfReady: () => void}
- ).startTimerIfReady();
- expect(
- (component as unknown as {timerId: number | null}).timerId
- ).not.toBeNull();
+ spyOn(window, 'setInterval').and.callThrough();
+ armCountdown();
+ expect(component.remainingTimeInSeconds).toBe(3600);
component.ngOnDestroy();
+ tick(2000);
- expect(
- (component as unknown as {timerId: number | null}).timerId
- ).toBeNull();
+ expect(component.remainingTimeInSeconds).toBe(3600);
+ expect(component.isTimeExpired).toBe(false);
}));
it('should not navigate to results when there is no attempt', fakeAsync(() => {
@@ -365,7 +423,7 @@ describe('CertificateAssessmentPlayerPageRootComponent', () => {
expect(router.navigate).not.toHaveBeenCalled();
}));
- it('should show a warning when starting the attempt fails', fakeAsync(() => {
+ it('should show the unavailable modal when starting the attempt fails', fakeAsync(() => {
(
certificateAssessmentOfferingBackendApiService.attemptCertificateAssessmentAsync as jasmine.Spy
).and.returnValue(Promise.reject('Error'));
@@ -374,16 +432,53 @@ describe('CertificateAssessmentPlayerPageRootComponent', () => {
flushMicrotasks();
expect(component.currentStage).toBe('intro');
+ expect(component.showAssessmentUnavailableModal).toBe(true);
+ }));
+
+ it('should show a localized cooldown warning when the attempt is within the cooldown', fakeAsync(() => {
+ (
+ certificateAssessmentOfferingBackendApiService.attemptCertificateAssessmentAsync as jasmine.Spy
+ ).and.returnValue(
+ Promise.reject({
+ error_type: 'cooldown',
+ remaining_minutes: 2,
+ error: 'I18N_CERTIFICATE_ASSESSMENT_COOLDOWN_ERROR',
+ status_code: 429,
+ })
+ );
+
+ component.startAssessment();
+ flushMicrotasks();
+
+ expect(component.currentStage).toBe('intro');
+ expect(component.showAssessmentUnavailableModal).toBe(false);
expect(translateService.instant).toHaveBeenCalledWith(
- 'I18N_CERTIFICATE_ASSESSMENT_START_WARNING'
+ 'I18N_CERTIFICATE_ASSESSMENT_COOLDOWN_ERROR',
+ {remainingMinutes: 2}
);
expect(alertsService.addWarning).toHaveBeenCalledWith(
- 'I18N_CERTIFICATE_ASSESSMENT_START_WARNING'
+ 'I18N_CERTIFICATE_ASSESSMENT_COOLDOWN_ERROR'
);
}));
+ it('should hide the unavailable modal and navigate to available certificates', fakeAsync(() => {
+ component.showAssessmentUnavailableModal = true;
+ component.classroomUrlFragment = 'math';
+
+ component.onGoToAvailableCertificates();
+ flushMicrotasks();
+
+ expect(component.showAssessmentUnavailableModal).toBe(false);
+ expect(router.navigate).toHaveBeenCalledWith([
+ `/${AppConstants.PAGES_REGISTERED_WITH_FRONTEND.CERTIFICATE_OFFERING_AVAILABLE.ROUTE.replace(
+ ':classroomUrlFragment',
+ 'math'
+ )}`,
+ ]);
+ }));
+
it('should submit the attempt and navigate to the result page on assessmentSubmitted', fakeAsync(() => {
- component.attempt = mockAttempt;
+ playerStateService.beginNewAttempt(mockAttempt);
const answers = [
{question_id: 'question_1', is_correct: true, selected_answer: 'b'},
];
@@ -400,9 +495,36 @@ describe('CertificateAssessmentPlayerPageRootComponent', () => {
]);
}));
+ it('should not suppress navigation when the timer expires during a pending submission', fakeAsync(() => {
+ let resolveSubmit: (value: object) => void = () => {};
+ (
+ certificateAssessmentOfferingBackendApiService.submitCertificateAssessmentAttemptAsync as jasmine.Spy
+ ).and.returnValue(
+ new Promise(resolve => {
+ resolveSubmit = resolve;
+ })
+ );
+ armCountdown();
+
+ const answers = [{question_id: 'question_1', is_correct: true}];
+ component.onAssessmentSubmitted(answers);
+ tick(3600000);
+ expect(component.isTimeExpired).toBe(true);
+
+ component.onAssessmentSubmitted(answers);
+ expect(
+ certificateAssessmentOfferingBackendApiService.submitCertificateAssessmentAttemptAsync
+ ).toHaveBeenCalledTimes(1);
+
+ resolveSubmit({attempt_id: 'attempt-1234', is_submitted: true});
+ flushMicrotasks();
+
+ expect(router.navigate).toHaveBeenCalled();
+ }));
+
it('should keep the user on the assessment page after an auto-submit', fakeAsync(() => {
- component.attempt = mockAttempt;
- component.isTimeExpired = true;
+ playerStateService.beginNewAttempt(mockAttempt);
+ playerStateService.isTimeExpired = true;
const answers = [{question_id: 'question_1', is_correct: true}];
component.onAssessmentSubmitted(answers);
@@ -421,7 +543,7 @@ describe('CertificateAssessmentPlayerPageRootComponent', () => {
}));
it('should show a warning when submitting the attempt fails', fakeAsync(() => {
- component.attempt = mockAttempt;
+ playerStateService.beginNewAttempt(mockAttempt);
(
certificateAssessmentOfferingBackendApiService.submitCertificateAssessmentAttemptAsync as jasmine.Spy
).and.returnValue(Promise.reject('Error'));
@@ -439,7 +561,64 @@ describe('CertificateAssessmentPlayerPageRootComponent', () => {
}));
it('should navigate to the result page when view results is requested', fakeAsync(() => {
- component.attempt = mockAttempt;
+ playerStateService.beginNewAttempt(mockAttempt);
+
+ component.onViewResults();
+ flushMicrotasks();
+
+ expect(router.navigate).toHaveBeenCalledWith([
+ `/${AppConstants.PAGES_REGISTERED_WITH_FRONTEND.CERTIFICATE_ASSESSMENT_RESULT.ROUTE.split('/')[0]}`,
+ 'attempt-1234',
+ ]);
+ }));
+
+ it('should wait for an in-flight submission before navigating to results', fakeAsync(() => {
+ let resolveSubmit: (value: object) => void = () => {};
+ (
+ certificateAssessmentOfferingBackendApiService.submitCertificateAssessmentAttemptAsync as jasmine.Spy
+ ).and.returnValue(
+ new Promise(resolve => {
+ resolveSubmit = resolve;
+ })
+ );
+ playerStateService.beginNewAttempt(mockAttempt);
+ playerStateService.isTimeExpired = true;
+
+ component.onAssessmentSubmitted([
+ {question_id: 'question_1', is_correct: true},
+ ]);
+
+ let viewResultsResolved = false;
+ component.onViewResults().then(() => {
+ viewResultsResolved = true;
+ });
+ flushMicrotasks();
+
+ expect(viewResultsResolved).toBe(false);
+ expect(router.navigate).not.toHaveBeenCalled();
+
+ resolveSubmit({attempt_id: 'attempt-1234', is_submitted: true});
+ flushMicrotasks();
+
+ expect(viewResultsResolved).toBe(true);
+ expect(router.navigate).toHaveBeenCalledWith([
+ `/${AppConstants.PAGES_REGISTERED_WITH_FRONTEND.CERTIFICATE_ASSESSMENT_RESULT.ROUTE.split('/')[0]}`,
+ 'attempt-1234',
+ ]);
+ }));
+
+ it('should navigate to results once a failed submission settles', fakeAsync(() => {
+ (
+ certificateAssessmentOfferingBackendApiService.submitCertificateAssessmentAttemptAsync as jasmine.Spy
+ ).and.returnValue(Promise.reject('Error'));
+ playerStateService.beginNewAttempt(mockAttempt);
+ playerStateService.isTimeExpired = true;
+
+ component.onAssessmentSubmitted([
+ {question_id: 'question_1', is_correct: true},
+ ]);
+ flushMicrotasks();
+ expect(alertsService.addWarning).toHaveBeenCalled();
component.onViewResults();
flushMicrotasks();
@@ -460,8 +639,9 @@ describe('CertificateAssessmentPlayerPageRootComponent', () => {
}));
it('should reset to the intro stage on retry', () => {
- component.showAssessmentInterruptCard = true;
- component.currentStage = 'questions';
+ playerStateService.showAssessmentInterruptCard = true;
+ playerStateService.currentStage =
+ CertificateAssessmentPlayerPageConstants.STAGE_QUESTIONS;
component.onRetryAssessment();
@@ -470,8 +650,7 @@ describe('CertificateAssessmentPlayerPageRootComponent', () => {
});
it('should resume to the questions stage on resume', () => {
- component.showAssessmentInterruptCard = true;
- component.currentStage = 'intro';
+ playerStateService.showAssessmentInterruptCard = true;
component.onResumeAssessment();
@@ -480,7 +659,8 @@ describe('CertificateAssessmentPlayerPageRootComponent', () => {
});
it('should switch to the intro stage on showIntro', () => {
- component.currentStage = 'questions';
+ playerStateService.currentStage =
+ CertificateAssessmentPlayerPageConstants.STAGE_QUESTIONS;
component.showIntro();
@@ -500,7 +680,9 @@ describe('CertificateAssessmentPlayerPageRootComponent', () => {
TestBed.inject(ActivatedRoute),
alertsService,
certificateAssessmentOfferingBackendApiService,
+ playerStateService,
classroomBackendApiServiceSpy,
+ internetConnectivityService,
{} as PageHeadService,
router,
translateService
@@ -528,7 +710,9 @@ describe('CertificateAssessmentPlayerPageRootComponent', () => {
TestBed.inject(ActivatedRoute),
alertsService,
certificateAssessmentOfferingBackendApiService,
+ playerStateService,
classroomBackendApiServiceSpy,
+ internetConnectivityService,
{} as PageHeadService,
router,
translateService
@@ -573,4 +757,39 @@ describe('CertificateAssessmentPlayerPageRootComponent', () => {
CertificateAssessmentPlayerPageConstants
);
});
+
+ it('should start checking the connection and react to network changes on init', fakeAsync(() => {
+ component.ngOnInit();
+ flushMicrotasks();
+
+ expect(
+ internetConnectivityService.startCheckingConnection
+ ).toHaveBeenCalled();
+ }));
+
+ it('should pause the countdown when the learner goes offline', fakeAsync(() => {
+ component.ngOnInit();
+ flushMicrotasks();
+
+ spyOn(playerStateService, 'pauseForNetworkLoss');
+ (
+ internetConnectivityService.onInternetStateChange as EventEmitter
+ ).emit(false);
+
+ expect(playerStateService.pauseForNetworkLoss).toHaveBeenCalled();
+ component.ngOnDestroy();
+ }));
+
+ it('should surface the interrupt card when the learner reconnects after a pause', fakeAsync(() => {
+ component.ngOnInit();
+ flushMicrotasks();
+
+ spyOn(playerStateService, 'handleReconnect');
+ (
+ internetConnectivityService.onInternetStateChange as EventEmitter
+ ).emit(true);
+
+ expect(playerStateService.handleReconnect).toHaveBeenCalled();
+ component.ngOnDestroy();
+ }));
});
diff --git a/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-page-root.component.ts b/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-page-root.component.ts
index df89ce7b16bbe..9d892fd346d47 100644
--- a/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-page-root.component.ts
+++ b/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-page-root.component.ts
@@ -28,16 +28,18 @@ import {
import {ClassroomBackendApiService} from 'domain/classroom/classroom-backend-api.service';
import {BaseRootComponent, MetaTagData} from 'pages/base-root.component';
import {AlertsService} from 'services/alerts.service';
+import {InternetConnectivityService} from 'services/internet-connectivity.service';
import {PageHeadService} from 'services/page-head.service';
import {TranslateService} from '@ngx-translate/core';
import {CertificateAssessmentPlayerPageConstants} from './certificate-assessment-player-page.constants';
-
-type CertificateAssessmentStage =
- (typeof CertificateAssessmentPlayerPageConstants)[keyof typeof CertificateAssessmentPlayerPageConstants];
+import {CertificateAssessmentPlayerStateService} from './certificate-assessment-player-state.service';
@Component({
selector: 'oppia-certificate-assessment-player-page-root',
templateUrl: './certificate-assessment-player-page-root.component.html',
+ // The state service is scoped to this component so that its countdown
+ // interval is torn down together with the page it belongs to.
+ providers: [CertificateAssessmentPlayerStateService],
})
export class CertificateAssessmentPlayerPageRootComponent
extends BaseRootComponent
@@ -58,26 +60,22 @@ export class CertificateAssessmentPlayerPageRootComponent
certificateId = '';
certificateOffering: CertificateAssessmentOfferingData =
CertificateAssessmentOfferingData.createEmpty();
- attempt: CertificateAssessmentAttemptData | null = null;
classroomUrlFragment = '';
- currentStage: CertificateAssessmentStage =
- CertificateAssessmentPlayerPageConstants.STAGE_INTRO;
- // TODO(#24717-M2.20): This flag value is by default set as false so the
- // interrupt card does not render. In the future, this flag will change its
- // value based on whether an in-progress attempt is detected on page load.
- showAssessmentInterruptCard = false;
+ showAssessmentUnavailableModal = false;
isLoading = true;
hasError = false;
- remainingTimeInSeconds = 0;
- isTimeExpired = false;
- private timerId: number | null = null;
- private hasStartedTimer = false;
+ isSubmissionInProgress = false;
+ // Tracks the most recent submission so that result navigation can wait
+ // until the final answers have actually been persisted.
+ private pendingSubmission: Promise = Promise.resolve();
constructor(
private activatedRoute: ActivatedRoute,
private alertsService: AlertsService,
private certificateAssessmentOfferingBackendApiService: CertificateAssessmentOfferingBackendApiService,
+ private certificateAssessmentPlayerStateService: CertificateAssessmentPlayerStateService,
private classroomBackendApiService: ClassroomBackendApiService,
+ private internetConnectivityService: InternetConnectivityService,
protected pageHeadService: PageHeadService,
private router: Router,
protected translateService: TranslateService
@@ -85,14 +83,54 @@ export class CertificateAssessmentPlayerPageRootComponent
super(pageHeadService, translateService);
}
+ get currentStage(): string {
+ return this.certificateAssessmentPlayerStateService.currentStage;
+ }
+
+ get attempt(): CertificateAssessmentAttemptData | null {
+ return this.certificateAssessmentPlayerStateService.getAttempt();
+ }
+
+ get showAssessmentInterruptCard(): boolean {
+ return this.certificateAssessmentPlayerStateService
+ .showAssessmentInterruptCard;
+ }
+
+ get isTimeExpired(): boolean {
+ return this.certificateAssessmentPlayerStateService.isTimeExpired;
+ }
+
+ get remainingTimeInSeconds(): number {
+ return this.certificateAssessmentPlayerStateService.remainingTimeInSeconds;
+ }
+
async ngOnInit(): Promise {
+ this.internetConnectivityService.startCheckingConnection();
+ this.directiveSubscriptions.add(
+ this.internetConnectivityService.onInternetStateChange.subscribe(
+ isOnline => this.handleNetworkStateChange(isOnline)
+ )
+ );
this.certificateId =
this.activatedRoute.snapshot.paramMap.get('certificate_id') || '';
const currentRoute = this.activatedRoute.snapshot.url[0]?.path || '';
- if (currentRoute === 'session') {
+ await this.loadCertificateOffering();
+ if (currentRoute === 'session' && !this.hasError) {
await this.startAssessment();
}
- await this.loadCertificateOffering();
+ }
+
+ /**
+ * Pauses the assessment countdown while the learner is offline and surfaces
+ * a resume option once they reconnect, so disconnected time is never counted
+ * towards the assessment expiry window.
+ */
+ private handleNetworkStateChange(isOnline: boolean): void {
+ if (isOnline) {
+ this.certificateAssessmentPlayerStateService.handleReconnect();
+ } else {
+ this.certificateAssessmentPlayerStateService.pauseForNetworkLoss();
+ }
}
private async loadCertificateOffering(): Promise {
@@ -102,7 +140,9 @@ export class CertificateAssessmentPlayerPageRootComponent
this.certificateId
);
await this.loadClassroomUrlFragment();
- this.startTimerIfReady();
+ this.certificateAssessmentPlayerStateService.configureForOffering(
+ this.certificateOffering.timeLimitInMinutes
+ );
} catch {
this.hasError = true;
await this.redirectToNotFound();
@@ -111,6 +151,7 @@ export class CertificateAssessmentPlayerPageRootComponent
}
}
+ /** Sends the learner to the 404 page when the offering can't be loaded. */
private async redirectToNotFound(): Promise {
try {
await this.router.navigate([
@@ -131,68 +172,109 @@ export class CertificateAssessmentPlayerPageRootComponent
}
showInstructions(): void {
- this.currentStage =
- CertificateAssessmentPlayerPageConstants.STAGE_INSTRUCTIONS;
+ this.certificateAssessmentPlayerStateService.showInstructions();
}
showIntro(): void {
- this.currentStage = CertificateAssessmentPlayerPageConstants.STAGE_INTRO;
+ this.certificateAssessmentPlayerStateService.showIntro();
}
+ /**
+ * Starts a new attempt on the server. The learner only moves to the
+ * questions once the server confirms the attempt; that confirmation is
+ * also what arms a fresh time window for them (see
+ * `beginNewAttempt`), so a failed start leaves any existing timing
+ * state untouched.
+ */
async startAssessment(): Promise {
try {
- this.attempt =
+ const attempt =
await this.certificateAssessmentOfferingBackendApiService.attemptCertificateAssessmentAsync(
this.certificateId
);
- this.currentStage =
- CertificateAssessmentPlayerPageConstants.STAGE_QUESTIONS;
- this.startTimerIfReady();
- } catch {
- this.alertsService.addWarning(
- this.translateService.instant(
- 'I18N_CERTIFICATE_ASSESSMENT_START_WARNING'
- )
- );
+ this.certificateAssessmentPlayerStateService.beginNewAttempt(attempt);
+ } catch (error) {
+ // The backend returns a structured error for cooldowns (with an I18N
+ // key and remaining_minutes) and a generic error otherwise; both are
+ // translated client-side so the user sees a localized string.
+ const errorBody = (
+ typeof error === 'object' && error !== null ? error : {}
+ ) as {error_type?: string; remaining_minutes?: number};
+ if (errorBody.error_type === 'cooldown') {
+ this.alertsService.addWarning(
+ this.translateService.instant(
+ 'I18N_CERTIFICATE_ASSESSMENT_COOLDOWN_ERROR',
+ {remainingMinutes: errorBody.remaining_minutes}
+ )
+ );
+ } else {
+ this.alertsService.addWarning(
+ this.translateService.instant(
+ 'I18N_CERTIFICATE_ASSESSMENT_START_WARNING'
+ )
+ );
+ this.showAssessmentUnavailableModal = true;
+ }
}
}
+ onGoToAvailableCertificates(): void {
+ this.showAssessmentUnavailableModal = false;
+ this.router.navigate([
+ `/${AppConstants.PAGES_REGISTERED_WITH_FRONTEND.CERTIFICATE_OFFERING_AVAILABLE.ROUTE.replace(
+ ':classroomUrlFragment',
+ this.classroomUrlFragment
+ )}`,
+ ]);
+ }
+
+ /**
+ * Submits the learner's final answers exactly once and navigates to the
+ * result page, unless the submission raced against the expiry of the
+ * time window (in which case the auto-submit keeps them on the page).
+ */
async onAssessmentSubmitted(
answers: SubmitCertificateAssessmentAnswerBackendDict[]
): Promise {
- if (this.attempt === null) {
+ const attempt = this.attempt;
+ if (attempt === null || this.isSubmissionInProgress) {
return;
}
- try {
- await this.certificateAssessmentOfferingBackendApiService.submitCertificateAssessmentAttemptAsync(
- this.attempt.attemptId,
- answers
- );
- if (!this.isTimeExpired) {
- await this.navigateToResultPage();
+ const submittedBeforeExpiry = !this.isTimeExpired;
+ const attemptId = attempt.attemptId;
+ this.isSubmissionInProgress = true;
+ this.pendingSubmission = (async () => {
+ try {
+ await this.certificateAssessmentOfferingBackendApiService.submitCertificateAssessmentAttemptAsync(
+ attemptId,
+ answers
+ );
+ if (submittedBeforeExpiry) {
+ await this.navigateToResultPage();
+ }
+ } catch {
+ this.alertsService.addWarning(
+ this.translateService.instant(
+ 'I18N_CERTIFICATE_ASSESSMENT_SUBMIT_WARNING'
+ )
+ );
+ } finally {
+ this.isSubmissionInProgress = false;
}
- } catch {
- this.alertsService.addWarning(
- this.translateService.instant(
- 'I18N_CERTIFICATE_ASSESSMENT_SUBMIT_WARNING'
- )
- );
- }
+ })();
+ await this.pendingSubmission;
}
onRetryAssessment(): void {
- this.showAssessmentInterruptCard = false;
- this.currentStage = CertificateAssessmentPlayerPageConstants.STAGE_INTRO;
+ this.certificateAssessmentPlayerStateService.returnToIntroAfterRetry();
}
onResumeAssessment(): void {
- this.showAssessmentInterruptCard = false;
- this.currentStage =
- CertificateAssessmentPlayerPageConstants.STAGE_QUESTIONS;
- this.startTimerIfReady();
+ this.certificateAssessmentPlayerStateService.resumeQuestionsStage();
}
- onViewResults(): Promise {
+ async onViewResults(): Promise {
+ await this.pendingSubmission;
return this.navigateToResultPage();
}
@@ -201,38 +283,9 @@ export class CertificateAssessmentPlayerPageRootComponent
}
ngOnDestroy(): void {
- this.clearTimer();
- }
-
- private startTimerIfReady(): void {
- if (
- this.hasStartedTimer ||
- this.attempt === null ||
- this.certificateOffering.timeLimitInMinutes <= 0 ||
- this.currentStage !==
- CertificateAssessmentPlayerPageConstants.STAGE_QUESTIONS
- ) {
- return;
- }
- this.hasStartedTimer = true;
- this.remainingTimeInSeconds =
- this.certificateOffering.timeLimitInMinutes * 60;
- this.timerId = window.setInterval(() => {
- if (this.remainingTimeInSeconds > 0) {
- this.remainingTimeInSeconds -= 1;
- }
- if (this.remainingTimeInSeconds === 0) {
- this.isTimeExpired = true;
- this.clearTimer();
- }
- }, 1000);
- }
-
- private clearTimer(): void {
- if (this.timerId !== null) {
- window.clearInterval(this.timerId);
- this.timerId = null;
- }
+ // Stops the countdown before the base class unsubscribes its listeners.
+ this.certificateAssessmentPlayerStateService.ngOnDestroy();
+ super.ngOnDestroy();
}
private async navigateToLearnerDashboard(): Promise {
@@ -242,12 +295,13 @@ export class CertificateAssessmentPlayerPageRootComponent
}
private async navigateToResultPage(): Promise {
- if (this.attempt === null) {
+ const attempt = this.attempt;
+ if (attempt === null) {
return false;
}
return this.router.navigate([
`/${AppConstants.PAGES_REGISTERED_WITH_FRONTEND.CERTIFICATE_ASSESSMENT_RESULT.ROUTE.split('/')[0]}`,
- this.attempt.attemptId,
+ attempt.attemptId,
]);
}
}
diff --git a/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-page.component.html b/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-page.component.html
index 1181ab5080c68..7c59b28f6b07b 100644
--- a/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-page.component.html
+++ b/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-page.component.html
@@ -21,6 +21,7 @@
[progressPercentage]="progressPercentage"
[isLastQuestion]="isLastQuestion"
[interactionHtml]="getInteractionHtml()"
+ [lastAnswer]="lastAnswerForCurrentQuestion"
(previousQuestion)="previousQuestion()"
(nextQuestion)="nextQuestion()"
(submitAssessment)="submitAssessment()">
diff --git a/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-page.component.spec.ts b/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-page.component.spec.ts
index 4b82eea8cd685..232c77be5564c 100644
--- a/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-page.component.spec.ts
+++ b/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-page.component.spec.ts
@@ -47,6 +47,7 @@ import {WindowDimensionsService} from 'services/contextual/window-dimensions.ser
import {TimeExpiredModalComponent} from 'components/certificate-assessment-offering-helper/time-expired-modal.component';
import {UnansweredQuestionModalComponent} from 'components/certificate-assessment-offering-helper/unanswered-question-modal.component';
import {CertificateAssessmentPlayerPageComponent} from './certificate-assessment-player-page.component';
+import {CertificateAssessmentPlayerPageConstants} from './certificate-assessment-player-page.constants';
const outcome = (labelledAsCorrect: boolean): OutcomeBackendDict => ({
dest: 'final',
@@ -358,7 +359,7 @@ describe('CertificateAssessmentPlayerPageComponent', () => {
expect(apiSpy.getCertificateAssessmentQuestionAsync).toHaveBeenCalledTimes(
1
);
- expect(component.isLoadingQuestion).toBeTrue();
+ expect(component.isLoadingQuestion).toBe(true);
(
component as unknown as {loadQuestion: (index: number) => void}
).loadQuestion(0);
@@ -367,7 +368,7 @@ describe('CertificateAssessmentPlayerPageComponent', () => {
);
resolve(questionResponse('q1'));
flushMicrotasks();
- expect(component.isLoadingQuestion).toBeFalse();
+ expect(component.isLoadingQuestion).toBe(false);
}));
it('should store questions at correct index for sparse loading', fakeAsync(() => {
@@ -395,7 +396,7 @@ describe('CertificateAssessmentPlayerPageComponent', () => {
Promise.reject(new Error('err'))
);
loadQ1();
- expect(component.loadError).toBeTrue();
+ expect(component.loadError).toBe(true);
apiSpy.getCertificateAssessmentQuestionAsync.and.returnValue(
Promise.resolve(questionResponse('q1'))
);
@@ -403,7 +404,7 @@ describe('CertificateAssessmentPlayerPageComponent', () => {
component as unknown as {loadQuestion: (index: number) => void}
).loadQuestion(0);
flushMicrotasks();
- expect(component.loadError).toBeFalse();
+ expect(component.loadError).toBe(false);
}));
it('should retry loading the current question after a failure', fakeAsync(() => {
@@ -411,7 +412,7 @@ describe('CertificateAssessmentPlayerPageComponent', () => {
Promise.reject(new Error('err'))
);
loadQ1();
- expect(component.loadError).toBeTrue();
+ expect(component.loadError).toBe(true);
apiSpy.getCertificateAssessmentQuestionAsync.and.returnValue(
Promise.resolve(questionResponse('q1'))
);
@@ -419,7 +420,7 @@ describe('CertificateAssessmentPlayerPageComponent', () => {
component.retryLoadQuestion();
flushMicrotasks();
- expect(component.loadError).toBeFalse();
+ expect(component.loadError).toBe(false);
expect(component.currentQuestion).not.toBeNull();
expect(apiSpy.getCertificateAssessmentQuestionAsync).toHaveBeenCalledTimes(
2
@@ -465,19 +466,19 @@ describe('CertificateAssessmentPlayerPageComponent', () => {
expect(component.currentQuestion).toEqual(component.questions[0]);
expect(component.totalQuestionCount).toBe(3);
expect(component.progressPercentage).toBe(Math.round((1 / 3) * 100));
- expect(component.isLastQuestion).toBeFalse();
+ expect(component.isLastQuestion).toBe(false);
}));
it('should recompute derived fields when navigating', fakeAsync(() => {
load();
component.currentQuestionIndex = 0;
component.nextQuestion();
- expect(component.isLastQuestion).toBeFalse();
+ expect(component.isLastQuestion).toBe(false);
component.nextQuestion();
- expect(component.isLastQuestion).toBeTrue();
+ expect(component.isLastQuestion).toBe(true);
expect(component.progressPercentage).toBe(100);
component.previousQuestion();
- expect(component.isLastQuestion).toBeFalse();
+ expect(component.isLastQuestion).toBe(false);
}));
it('should store answer via handleInteractionSubmit', fakeAsync(() => {
@@ -487,6 +488,34 @@ describe('CertificateAssessmentPlayerPageComponent', () => {
expect(component.answers.q1).toBe(1);
}));
+ it('should normalize object answers with toDict into plain dicts', fakeAsync(() => {
+ load();
+ component.currentQuestionIndex = 0;
+ const dictForm = {
+ isNegative: false,
+ wholeNumber: 3,
+ numerator: 1,
+ denominator: 2,
+ };
+ const answerWithToDict = {
+ ...dictForm,
+ toDict: jasmine.createSpy('toDict').and.returnValue(dictForm),
+ };
+
+ component.handleInteractionSubmit(answerWithToDict);
+
+ expect(answerWithToDict.toDict).toHaveBeenCalled();
+ expect(component.answers.q1).toEqual(dictForm);
+ }));
+
+ it('should store plain answer as-is when it has no toDict method', fakeAsync(() => {
+ load();
+ component.currentQuestionIndex = 0;
+ const plainAnswer: InteractionAnswer = 'plain text answer';
+ component.handleInteractionSubmit(plainAnswer);
+ expect(component.answers.q1).toBe('plain text answer');
+ }));
+
it('should not throw when no question loaded', () => {
expect(() => component.handleInteractionSubmit(1)).not.toThrowError();
});
@@ -500,6 +529,28 @@ describe('CertificateAssessmentPlayerPageComponent', () => {
expect(component.getInteractionHtml()).toBe('interaction
');
}));
+ it('should return null for lastAnswerForCurrentQuestion when no questions loaded', () => {
+ expect(component.lastAnswerForCurrentQuestion).toBeNull();
+ });
+
+ it('should return null for lastAnswerForCurrentQuestion when current question has no answer', fakeAsync(() => {
+ loadQ1();
+ expect(component.lastAnswerForCurrentQuestion).toBeNull();
+ }));
+
+ it('should return stored answer for lastAnswerForCurrentQuestion', fakeAsync(() => {
+ load();
+ component.currentQuestionIndex = 0;
+ component.handleInteractionSubmit('my answer');
+ expect(component.lastAnswerForCurrentQuestion).toBe('my answer');
+ }));
+
+ it('should return null for lastAnswerForCurrentQuestion when loaded question index is beyond array', fakeAsync(() => {
+ loadQ1();
+ component.currentQuestionIndex = 5;
+ expect(component.lastAnswerForCurrentQuestion).toBeNull();
+ }));
+
it('should return null when no questions loaded', () => {
expect(component.getCurrentQuestion()).toBeNull();
});
@@ -519,9 +570,9 @@ describe('CertificateAssessmentPlayerPageComponent', () => {
it('should report whether current question is last', fakeAsync(() => {
load();
component.currentQuestionIndex = 0;
- expect(component.isCurrentQuestionLast()).toBeFalse();
+ expect(component.isCurrentQuestionLast()).toBe(false);
component.currentQuestionIndex = 2;
- expect(component.isCurrentQuestionLast()).toBeTrue();
+ expect(component.isCurrentQuestionLast()).toBe(true);
}));
it('should return 0 when no questions', async () => {
@@ -587,6 +638,16 @@ describe('CertificateAssessmentPlayerPageComponent', () => {
expect(modalSpy.open).toHaveBeenCalledTimes(1);
}));
+ it('should ignore a repeated time-expiry handling request on re-init', fakeAsync(() => {
+ loadQ1();
+ spyOn(component.assessmentSubmitted, 'emit');
+ triggerTimeExpiry();
+ component.ngOnInit();
+
+ expect(component.assessmentSubmitted.emit).toHaveBeenCalledTimes(1);
+ expect(modalSpy.open).toHaveBeenCalledTimes(1);
+ }));
+
it('should not handle time expiry when the flag has not become true', fakeAsync(() => {
loadQ1();
component.ngOnChanges({});
@@ -610,7 +671,12 @@ describe('CertificateAssessmentPlayerPageComponent', () => {
loadQ1();
spyOn(component.assessmentSubmitted, 'emit');
spyOn(component.viewResults, 'emit');
- modalSpy.open.and.returnValue(modalRef(false, 'view-results'));
+ modalSpy.open.and.returnValue(
+ modalRef(
+ false,
+ CertificateAssessmentPlayerPageConstants.VIEW_RESULTS_RESULT
+ )
+ );
triggerTimeExpiry();
flushMicrotasks();
@@ -636,7 +702,8 @@ describe('CertificateAssessmentPlayerPageComponent', () => {
spyOn(component.viewResults, 'emit');
spyOn(component.assessmentEnded, 'emit');
bottomSheetSpy.open.and.returnValue({
- afterDismissed: () => of('view-results'),
+ afterDismissed: () =>
+ of(CertificateAssessmentPlayerPageConstants.VIEW_RESULTS_RESULT),
});
triggerTimeExpiry();
flushMicrotasks();
@@ -660,10 +727,35 @@ describe('CertificateAssessmentPlayerPageComponent', () => {
expect(component.assessmentEnded.emit).toHaveBeenCalled();
}));
- it('should open unanswered-question modal on desktop', fakeAsync(() => {
+ it('should not open any modal when the flag is false', fakeAsync(() => {
loadQ1();
- component.showUnansweredQuestionModal = true;
+ modalSpy.open.calls.reset();
component.ngOnInit();
+ expect(modalSpy.open).not.toHaveBeenCalled();
+ }));
+
+ it('should emit answers directly when all questions are answered', fakeAsync(() => {
+ load();
+ spyOn(component.assessmentSubmitted, 'emit');
+ component.answers.q1 = 1;
+ component.answers.q2 = ['a', 'b', 'd'];
+ component.answers.q3 = 'circle';
+ component.submitAssessment();
+ expect(modalSpy.open).not.toHaveBeenCalled();
+ expect(component.assessmentSubmitted.emit).toHaveBeenCalledWith([
+ {question_id: 'q1', is_correct: false, selected_answer: '1'},
+ {question_id: 'q2', is_correct: false, selected_answer: '["a","b","d"]'},
+ {question_id: 'q3', is_correct: false, selected_answer: 'circle'},
+ ]);
+ }));
+
+ it('should open unanswered-question modal with the unanswered count on desktop', fakeAsync(() => {
+ load();
+ spyOn(component.assessmentSubmitted, 'emit');
+ const ref = modalRef();
+ modalSpy.open.and.returnValue(ref);
+ component.answers.q1 = 1;
+ component.submitAssessment();
expect(modalSpy.open).toHaveBeenCalledWith(
UnansweredQuestionModalComponent,
{
@@ -672,32 +764,100 @@ describe('CertificateAssessmentPlayerPageComponent', () => {
windowClass: 'oppia-unanswered-question-modal',
}
);
+ expect(ref.componentInstance.unansweredQuestionCount).toBe(2);
+ expect(component.assessmentSubmitted.emit).not.toHaveBeenCalled();
+ }));
+
+ it('should return to the last unanswered question when the modal is dismissed', fakeAsync(() => {
+ load();
+ spyOn(component.assessmentSubmitted, 'emit');
+ modalSpy.open.and.returnValue(modalRef(true));
+ component.currentQuestionIndex = 0;
+ component.answers.q1 = 1;
+ component.submitAssessment();
+ flushMicrotasks();
+ expect(component.assessmentSubmitted.emit).not.toHaveBeenCalled();
+ expect(component.currentQuestionIndex).toBe(2);
+ }));
+
+ it('should return to the correct question when an intermediate question failed to load', fakeAsync(() => {
+ apiSpy.getCertificateAssessmentQuestionAsync.and.callFake(
+ (_attemptId: string, questionId: string) => {
+ if (questionId === 'q2') {
+ return Promise.reject(new Error('load failed'));
+ }
+ return Promise.resolve(questionResponse(questionId));
+ }
+ );
+ load();
+ spyOn(component.assessmentSubmitted, 'emit');
+ const ref = modalRef();
+ modalSpy.open.and.returnValue(ref);
+ component.answers.q1 = 1;
+ component.submitAssessment();
+ flushMicrotasks();
+ // Only q3 is loaded but unanswered, and its index within this.questions
+ // must be preserved even though it is first in the filtered list.
+ expect(ref.componentInstance.unansweredQuestionCount).toBe(1);
+ expect(component.currentQuestionIndex).toBe(2);
+ }));
+
+ it('should emit answers when submit anyway is confirmed', fakeAsync(() => {
+ load();
+ spyOn(component.assessmentSubmitted, 'emit');
+ modalSpy.open.and.returnValue(modalRef(false, 'submit-anyway'));
+ component.answers.q1 = 1;
+ component.submitAssessment();
+ flushMicrotasks();
+ expect(component.assessmentSubmitted.emit).toHaveBeenCalledWith([
+ {question_id: 'q1', is_correct: false, selected_answer: '1'},
+ {question_id: 'q2', is_correct: false},
+ {question_id: 'q3', is_correct: false},
+ ]);
}));
it('should open unanswered-question modal as bottom sheet on mobile', fakeAsync(() => {
- loadQ1();
+ load();
dimsSpy.getWidth.and.returnValue(400);
- component.showUnansweredQuestionModal = true;
- component.ngOnInit();
+ const ref = {
+ instance: {} as Record,
+ afterDismissed: () => of(null),
+ };
+ bottomSheetSpy.open.and.returnValue(ref);
+ component.answers.q1 = 1;
+ component.submitAssessment();
expect(bottomSheetSpy.open).toHaveBeenCalledWith(
UnansweredQuestionModalComponent
);
+ expect(ref.instance.unansweredQuestionCount).toBe(2);
}));
- it('should handle unanswered-question modal dismiss', fakeAsync(() => {
- loadQ1();
- modalSpy.open.and.returnValue(modalRef(true));
- component.showUnansweredQuestionModal = true;
- component.ngOnInit();
+ it('should emit answers when the bottom sheet is dismissed with submit-anyway', fakeAsync(() => {
+ load();
+ spyOn(component.assessmentSubmitted, 'emit');
+ dimsSpy.getWidth.and.returnValue(400);
+ bottomSheetSpy.open.and.returnValue({
+ instance: {} as Record,
+ afterDismissed: () => of('submit-anyway'),
+ });
+ component.answers.q1 = 1;
+ component.submitAssessment();
flushMicrotasks();
+ expect(component.assessmentSubmitted.emit).toHaveBeenCalled();
}));
- it('should not open any modal when both flags are false', fakeAsync(() => {
- loadQ1();
- modalSpy.open.calls.reset();
- component.showUnansweredQuestionModal = false;
- component.ngOnInit();
- expect(modalSpy.open).not.toHaveBeenCalled();
+ it('should return to the last unanswered question when the bottom sheet is dismissed', fakeAsync(() => {
+ load();
+ dimsSpy.getWidth.and.returnValue(400);
+ bottomSheetSpy.open.and.returnValue({
+ instance: {} as Record,
+ afterDismissed: () => of(null),
+ });
+ component.currentQuestionIndex = 0;
+ component.answers.q1 = 1;
+ component.submitAssessment();
+ flushMicrotasks();
+ expect(component.currentQuestionIndex).toBe(2);
}));
it('should emit correct answers on submit', fakeAsync(() => {
@@ -713,11 +873,12 @@ describe('CertificateAssessmentPlayerPageComponent', () => {
);
component.answers.q1 = 1;
component.answers.q2 = ['a', 'b', 'd'];
+ component.answers.q3 = 'circle';
component.submitAssessment();
expect(component.assessmentSubmitted.emit).toHaveBeenCalledWith([
{question_id: 'q1', is_correct: true, selected_answer: '1'},
{question_id: 'q2', is_correct: true, selected_answer: '["a","b","d"]'},
- {question_id: 'q3', is_correct: false},
+ {question_id: 'q3', is_correct: true, selected_answer: 'circle'},
]);
}));
@@ -771,7 +932,9 @@ describe('CertificateAssessmentPlayerPageComponent', () => {
it('should omit selected_answer when answer is null', fakeAsync(() => {
load();
spyOn(component.assessmentSubmitted, 'emit');
+ modalSpy.open.and.returnValue(modalRef(false, 'submit-anyway'));
component.submitAssessment();
+ flushMicrotasks();
const answers = (
component.assessmentSubmitted.emit as jasmine.Spy
).calls.mostRecent().args[0];
diff --git a/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-page.component.ts b/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-page.component.ts
index e5b7e60fb9278..3a88c9db4000b 100644
--- a/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-page.component.ts
+++ b/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-page.component.ts
@@ -49,7 +49,11 @@ import {ExplorationHtmlFormatterService} from 'services/exploration-html-formatt
import {FocusManagerService} from 'services/stateful/focus-manager.service';
import {WindowDimensionsService} from 'services/contextual/window-dimensions.service';
import {TimeExpiredModalComponent} from 'components/certificate-assessment-offering-helper/time-expired-modal.component';
-import {UnansweredQuestionModalComponent} from 'components/certificate-assessment-offering-helper/unanswered-question-modal.component';
+import {
+ UnansweredQuestionModalComponent,
+ SUBMIT_ANYWAY_RESULT,
+} from 'components/certificate-assessment-offering-helper/unanswered-question-modal.component';
+import {CertificateAssessmentPlayerPageConstants} from './certificate-assessment-player-page.constants';
import './certificate-assessment-player-page.component.css';
const MOBILE_SCREEN_BREAKPOINT = 480;
@@ -74,12 +78,6 @@ export class CertificateAssessmentPlayerPageComponent
bannerTitleI18nKey = 'I18N_CERTIFICATE_ASSESSMENT';
bannerButtonI18nKey = 'I18N_CERTIFICATE_ASSESSMENT_EXIT_BUTTON';
- // TODO(#24717-m2.18-m2.19): The showUnansweredQuestionModal flag is
- // currently initialized with a default value. Update this flag based on the
- // appropriate condition once the logic for determining when the modal should
- // be shown is implemented.
- showUnansweredQuestionModal = false;
-
currentQuestionIndex = 0;
questions: AssessmentQuestion[] = [];
isLoadingQuestion = false;
@@ -114,9 +112,6 @@ export class CertificateAssessmentPlayerPageComponent
this.currentInteractionService.setOnSubmitFn(this.handleSubmitFn);
this.loadQuestion(0);
this.refreshComputedFields();
- if (this.showUnansweredQuestionModal) {
- this.openUnansweredQuestionModal();
- }
if (this.isTimeExpired) {
this.handleTimeExpiry();
}
@@ -208,7 +203,10 @@ export class CertificateAssessmentPlayerPageComponent
if (this.isMobileScreenSize()) {
const bottomSheetRef = this.bottomSheet.open(TimeExpiredModalComponent);
bottomSheetRef.afterDismissed().subscribe(result => {
- if (result === 'view-results') {
+ if (
+ result ===
+ CertificateAssessmentPlayerPageConstants.VIEW_RESULTS_RESULT
+ ) {
this.viewResults.emit();
} else {
this.assessmentEnded.emit();
@@ -223,7 +221,10 @@ export class CertificateAssessmentPlayerPageComponent
});
modalRef.result
.then(result => {
- if (result === 'view-results') {
+ if (
+ result ===
+ CertificateAssessmentPlayerPageConstants.VIEW_RESULTS_RESULT
+ ) {
this.viewResults.emit();
}
})
@@ -232,9 +233,23 @@ export class CertificateAssessmentPlayerPageComponent
});
}
- openUnansweredQuestionModal(): void {
+ openUnansweredQuestionModal(
+ unansweredQuestionCount: number,
+ onGoBack: () => void,
+ onSubmitAnyway: () => void
+ ): void {
if (this.isMobileScreenSize()) {
- this.bottomSheet.open(UnansweredQuestionModalComponent);
+ const bottomSheetRef = this.bottomSheet.open(
+ UnansweredQuestionModalComponent
+ );
+ bottomSheetRef.instance.unansweredQuestionCount = unansweredQuestionCount;
+ bottomSheetRef.afterDismissed().subscribe(result => {
+ if (result === SUBMIT_ANYWAY_RESULT) {
+ onSubmitAnyway();
+ } else {
+ onGoBack();
+ }
+ });
return;
}
const modalRef = this.ngbModal.open(UnansweredQuestionModalComponent, {
@@ -242,11 +257,17 @@ export class CertificateAssessmentPlayerPageComponent
centered: true,
windowClass: 'oppia-unanswered-question-modal',
});
- // The unanswered-question count is mocked until the backend is integrated.
- modalRef.componentInstance.unansweredQuestionCount = 3;
- // TODO(#24717-m2.19): Wire the submitAnyway and goBackToAssessment actions
- // once the backend is integrated.
- modalRef.result.catch(() => null);
+ modalRef.componentInstance.unansweredQuestionCount =
+ unansweredQuestionCount;
+ modalRef.result
+ .then(result => {
+ if (result === SUBMIT_ANYWAY_RESULT) {
+ onSubmitAnyway();
+ }
+ })
+ .catch(() => {
+ onGoBack();
+ });
}
nextQuestion(): void {
@@ -266,11 +287,11 @@ export class CertificateAssessmentPlayerPageComponent
this.refreshComputedFields();
}
- submitAssessment(): void {
+ private collectAnswers(): SubmitCertificateAssessmentAnswerBackendDict[] {
const loadedQuestions = this.questions.filter(
(question): question is AssessmentQuestion => question !== undefined
);
- const answers = loadedQuestions.map(question => {
+ return loadedQuestions.map(question => {
const answer = this.answers[question.id] ?? null;
let isCorrect = false;
if (answer !== null) {
@@ -298,7 +319,33 @@ export class CertificateAssessmentPlayerPageComponent
: {}),
};
});
- this.assessmentSubmitted.emit(answers);
+ }
+
+ submitAssessment(): void {
+ const answers = this.collectAnswers();
+ const unansweredQuestionIndexes = this.questions
+ .map((question, index) => ({question, index}))
+ .filter(
+ ({question}) =>
+ question !== undefined && (this.answers[question.id] ?? null) === null
+ )
+ .map(({index}) => index);
+ if (unansweredQuestionIndexes.length === 0) {
+ this.assessmentSubmitted.emit(answers);
+ return;
+ }
+ const lastUnansweredQuestionIndex =
+ unansweredQuestionIndexes[unansweredQuestionIndexes.length - 1];
+ this.openUnansweredQuestionModal(
+ unansweredQuestionIndexes.length,
+ () => {
+ this.currentQuestionIndex = lastUnansweredQuestionIndex;
+ this.refreshComputedFields();
+ },
+ () => {
+ this.assessmentSubmitted.emit(answers);
+ }
+ );
}
private handleTimeExpiry(): void {
@@ -307,7 +354,7 @@ export class CertificateAssessmentPlayerPageComponent
}
this.hasHandledTimeExpiry = true;
this.openTimeExpiredModal();
- this.submitAssessment();
+ this.assessmentSubmitted.emit(this.collectAnswers());
}
handleInteractionSubmit(answer: InteractionAnswer): void {
@@ -315,7 +362,16 @@ export class CertificateAssessmentPlayerPageComponent
if (question === null) {
return;
}
- this.answers[question.id] = answer;
+ // Store the answer in its canonical dict form. Object-based answers (e.g.
+ // NumberWithUnits) are submitted as class instances, but interaction
+ // components reconstruct the value via `fromDict`, which expects a plain
+ // dict. Normalizing here guarantees pre-population works uniformly.
+ const normalizedAnswer =
+ answer !== null &&
+ typeof (answer as {toDict?: unknown}).toDict === 'function'
+ ? (answer as unknown as {toDict: () => InteractionAnswer}).toDict()
+ : answer;
+ this.answers[question.id] = normalizedAnswer;
this.refreshComputedFields();
}
@@ -327,6 +383,14 @@ export class CertificateAssessmentPlayerPageComponent
return this.interactionHtmls[question.id] ?? '';
}
+ // The previously recorded answer for the current question, passed to the
+ // conversation skin so it can be restored into the interaction when the
+ // learner navigates back to a question they already answered.
+ get lastAnswerForCurrentQuestion(): InteractionAnswer | null {
+ const question = this.getCurrentQuestion();
+ return question ? this.answers[question.id] ?? null : null;
+ }
+
private formatAnswerForBackend(answer: InteractionAnswer): string {
if (typeof answer === 'string') {
return answer;
diff --git a/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-page.constants.ts b/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-page.constants.ts
index d7da7d8542a2c..47abd956d36d6 100644
--- a/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-page.constants.ts
+++ b/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-page.constants.ts
@@ -20,6 +20,9 @@ export const CertificateAssessmentPlayerPageConstants = {
STAGE_INTRO: 'intro',
STAGE_INSTRUCTIONS: 'instructions',
STAGE_QUESTIONS: 'questions',
+ // Result token emitted when the learner chooses to view their results from
+ // the time-expired modal, and consumed by the player page that opened it.
+ VIEW_RESULTS_RESULT: 'view-results',
} as const;
export type CertificateAssessmentStage =
diff --git a/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-state.service.spec.ts b/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-state.service.spec.ts
new file mode 100644
index 0000000000000..8469d69f4dde8
--- /dev/null
+++ b/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-state.service.spec.ts
@@ -0,0 +1,343 @@
+// Copyright 2026 The Oppia Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS-IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/**
+ * @fileoverview Unit tests for CertificateAssessmentPlayerStateService.
+ */
+
+import {fakeAsync, TestBed, tick} from '@angular/core/testing';
+import {
+ CertificateAssessmentAttemptData,
+ CertificateAssessmentOfferingData,
+} from 'domain/certificate-assessment/certificate-assessment.model';
+import {CertificateAssessmentPlayerPageConstants} from './certificate-assessment-player-page.constants';
+import {CertificateAssessmentPlayerStateService} from './certificate-assessment-player-state.service';
+
+describe('CertificateAssessmentPlayerStateService', () => {
+ let service: CertificateAssessmentPlayerStateService;
+
+ const mockOffering = new CertificateAssessmentOfferingData(
+ 'cert-123',
+ 'Everyday Arithmetic & Number Confidence',
+ 'Certificate description.',
+ 'math_classroom_01',
+ {topic_place_values: 1},
+ 12,
+ 60,
+ ['Understanding of numbers'],
+ 'Available',
+ 1
+ );
+
+ const mockAttempt = CertificateAssessmentAttemptData.createFromBackendDict({
+ attempt_id: 'attempt-1234',
+ questions: [
+ {question_id: 'question_1', question_version: 1},
+ {question_id: 'question_2', question_version: 2},
+ ],
+ });
+
+ // The spies must call through so zone.js still schedules the interval on
+ // the fakeAsync clock; they only exist to count invocations. They are
+ // installed inside each test because installing them around the fakeAsync
+ // boundary makes the spied properties unrecognizable to matchers.
+ const spyOnTimers = (): void => {
+ spyOn(window, 'setInterval').and.callThrough();
+ spyOn(window, 'clearInterval').and.callThrough();
+ };
+
+ // Arms a fully running countdown: registers an attempt (which moves the
+ // learner to the questions stage) and applies the offering's one-hour
+ // time limit, which is what finally starts the interval.
+ const armCountdown = (): void => {
+ service.beginNewAttempt(mockAttempt);
+ service.configureForOffering(mockOffering.timeLimitInMinutes);
+ };
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ providers: [CertificateAssessmentPlayerStateService],
+ });
+ service = TestBed.inject(CertificateAssessmentPlayerStateService);
+ });
+
+ afterEach(() => {
+ service.ngOnDestroy();
+ });
+
+ it('should initialize with a clean slate', () => {
+ expect(service.currentStage).toBe(
+ CertificateAssessmentPlayerPageConstants.STAGE_INTRO
+ );
+ expect(service.showAssessmentInterruptCard).toBeFalse();
+ expect(service.remainingTimeInSeconds).toBe(0);
+ expect(service.isTimeExpired).toBeFalse();
+ expect(service.getAttempt()).toBeNull();
+ });
+
+ it('should expose the registered attempt', () => {
+ service.beginNewAttempt(mockAttempt);
+ expect(service.getAttempt()).toEqual(mockAttempt);
+ });
+
+ it('should switch stages through the intro and instructions steps', () => {
+ service.showInstructions();
+ expect(service.currentStage).toBe(
+ CertificateAssessmentPlayerPageConstants.STAGE_INSTRUCTIONS
+ );
+
+ service.showIntro();
+ expect(service.currentStage).toBe(
+ CertificateAssessmentPlayerPageConstants.STAGE_INTRO
+ );
+ });
+
+ describe('beginning a new attempt', () => {
+ it('should move to the questions stage and start a fresh window', fakeAsync(() => {
+ spyOnTimers();
+ armCountdown();
+
+ expect(service.currentStage).toBe(
+ CertificateAssessmentPlayerPageConstants.STAGE_QUESTIONS
+ );
+ expect(service.remainingTimeInSeconds).toBe(3600);
+ expect(window.setInterval).toHaveBeenCalledTimes(1);
+
+ tick(2000);
+ expect(service.remainingTimeInSeconds).toBe(3598);
+ service.ngOnDestroy();
+ }));
+
+ it('should derive the countdown from the deadline when callbacks are throttled', fakeAsync(() => {
+ armCountdown();
+
+ tick(60000);
+
+ expect(service.remainingTimeInSeconds).toBe(3540);
+ expect(service.isTimeExpired).toBeFalse();
+ service.ngOnDestroy();
+ }));
+
+ it('should mark the window expired and stop at zero', fakeAsync(() => {
+ spyOnTimers();
+ armCountdown();
+
+ tick(3600000);
+
+ expect(service.remainingTimeInSeconds).toBe(0);
+ expect(service.isTimeExpired).toBeTrue();
+ expect(window.clearInterval).toHaveBeenCalled();
+ }));
+
+ it('should wipe stale timing state from any previous attempt', fakeAsync(() => {
+ spyOnTimers();
+ armCountdown();
+ tick(3600000);
+ expect(service.isTimeExpired).toBeTrue();
+
+ armCountdown();
+
+ expect(service.isTimeExpired).toBeFalse();
+ expect(service.remainingTimeInSeconds).toBe(3600);
+ expect(window.clearInterval).toHaveBeenCalled();
+ expect(window.setInterval).toHaveBeenCalledTimes(2);
+ service.ngOnDestroy();
+ }));
+ });
+
+ describe('starting the countdown', () => {
+ it('should not start before an attempt exists', () => {
+ spyOnTimers();
+ service.configureForOffering(mockOffering.timeLimitInMinutes);
+
+ expect(window.setInterval).not.toHaveBeenCalled();
+ expect(service.remainingTimeInSeconds).toBe(0);
+ });
+
+ it('should not start without a positive time limit', () => {
+ spyOnTimers();
+ service.beginNewAttempt(mockAttempt);
+ service.configureForOffering(0);
+
+ expect(window.setInterval).not.toHaveBeenCalled();
+ expect(service.remainingTimeInSeconds).toBe(0);
+ });
+
+ it('should not start outside the questions stage', () => {
+ spyOnTimers();
+ service.beginNewAttempt(mockAttempt);
+ service.showIntro();
+ service.configureForOffering(mockOffering.timeLimitInMinutes);
+
+ expect(window.setInterval).not.toHaveBeenCalled();
+ expect(service.remainingTimeInSeconds).toBe(0);
+ });
+
+ it('should not restart a window that is already running', fakeAsync(() => {
+ spyOnTimers();
+ armCountdown();
+ service.resumeQuestionsStage();
+ service.configureForOffering(mockOffering.timeLimitInMinutes);
+
+ expect(window.setInterval).toHaveBeenCalledTimes(1);
+
+ tick(2000);
+ expect(service.remainingTimeInSeconds).toBe(3598);
+ service.ngOnDestroy();
+ }));
+ });
+
+ describe('retry and resume after an interruption', () => {
+ it('should return to the intro on retry without touching timing state', fakeAsync(() => {
+ spyOnTimers();
+ armCountdown();
+ tick(3600000);
+ expect(service.isTimeExpired).toBeTrue();
+ service.showAssessmentInterruptCard = true;
+
+ service.returnToIntroAfterRetry();
+
+ expect(service.showAssessmentInterruptCard).toBeFalse();
+ expect(service.currentStage).toBe(
+ CertificateAssessmentPlayerPageConstants.STAGE_INTRO
+ );
+ // Retry deliberately leaves the expired window alone; only a
+ // successfully begun replacement attempt resets it.
+ expect(service.isTimeExpired).toBeTrue();
+ expect(service.remainingTimeInSeconds).toBe(0);
+ expect(window.clearInterval).toHaveBeenCalledTimes(1);
+ }));
+
+ it('should return to the questions on resume keeping the remaining time', fakeAsync(() => {
+ armCountdown();
+ tick(30000);
+ service.showAssessmentInterruptCard = true;
+ service.returnToIntroAfterRetry();
+
+ service.resumeQuestionsStage();
+
+ expect(service.showAssessmentInterruptCard).toBeFalse();
+ expect(service.currentStage).toBe(
+ CertificateAssessmentPlayerPageConstants.STAGE_QUESTIONS
+ );
+ expect(service.isTimeExpired).toBeFalse();
+
+ tick(2000);
+ // The countdown kept running while the learner was away.
+ expect(service.remainingTimeInSeconds).toBeLessThan(3600);
+ service.ngOnDestroy();
+ }));
+ });
+
+ it('should stop the countdown when destroyed', fakeAsync(() => {
+ spyOnTimers();
+ armCountdown();
+ expect(service.remainingTimeInSeconds).toBe(3600);
+
+ service.ngOnDestroy();
+ tick(2000);
+
+ expect(service.remainingTimeInSeconds).toBe(3600);
+ expect(service.isTimeExpired).toBeFalse();
+ expect(window.clearInterval).toHaveBeenCalled();
+ }));
+
+ it('should tolerate being destroyed without a running countdown', () => {
+ spyOnTimers();
+ expect(() => service.ngOnDestroy()).not.toThrowError();
+ expect(window.clearInterval).not.toHaveBeenCalled();
+ });
+
+ describe('network loss handling', () => {
+ it('should pause the countdown while offline and freeze the remaining time', fakeAsync(() => {
+ spyOnTimers();
+ armCountdown();
+ tick(30000);
+ expect(service.remainingTimeInSeconds).toBe(3570);
+ const frozenRemaining = service.remainingTimeInSeconds;
+
+ service.pauseForNetworkLoss();
+
+ expect(window.clearInterval).toHaveBeenCalled();
+ // The disconnected duration is not counted down.
+ tick(60000);
+ expect(service.remainingTimeInSeconds).toBe(frozenRemaining);
+ service.ngOnDestroy();
+ }));
+
+ it('should not pause the countdown when not in the questions stage', fakeAsync(() => {
+ spyOnTimers();
+ armCountdown();
+ service.showIntro();
+
+ service.pauseForNetworkLoss();
+
+ // No-op when not actively answering questions.
+ tick(2000);
+ expect(service.remainingTimeInSeconds).toBe(3598);
+ service.ngOnDestroy();
+ }));
+
+ it('should surface the interrupt card when reconnecting after a pause', fakeAsync(() => {
+ spyOnTimers();
+ armCountdown();
+ tick(30000);
+ service.pauseForNetworkLoss();
+ expect(service.showAssessmentInterruptCard).toBeFalse();
+
+ service.handleReconnect();
+
+ expect(service.showAssessmentInterruptCard).toBeTrue();
+ service.ngOnDestroy();
+ }));
+
+ it('should not surface the interrupt card on reconnect when not paused', () => {
+ service.handleReconnect();
+
+ expect(service.showAssessmentInterruptCard).toBeFalse();
+ });
+
+ it('should resume from the frozen remaining time after a network-loss pause', fakeAsync(() => {
+ spyOnTimers();
+ armCountdown();
+ tick(30000);
+ expect(service.remainingTimeInSeconds).toBe(3570);
+ service.pauseForNetworkLoss();
+
+ service.resumeQuestionsStage();
+
+ expect(service.showAssessmentInterruptCard).toBeFalse();
+ expect(service.currentStage).toBe(
+ CertificateAssessmentPlayerPageConstants.STAGE_QUESTIONS
+ );
+ tick(2000);
+ expect(service.remainingTimeInSeconds).toBe(3568);
+ service.ngOnDestroy();
+ }));
+
+ it('should mark the window expired when the resumed countdown reaches zero', fakeAsync(() => {
+ spyOnTimers();
+ armCountdown();
+ tick(30000);
+ service.pauseForNetworkLoss();
+ service.resumeQuestionsStage();
+
+ tick(3570000);
+
+ expect(service.remainingTimeInSeconds).toBe(0);
+ expect(service.isTimeExpired).toBeTrue();
+ expect(window.clearInterval).toHaveBeenCalled();
+ }));
+ });
+});
diff --git a/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-state.service.ts b/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-state.service.ts
new file mode 100644
index 0000000000000..0613c80e50a74
--- /dev/null
+++ b/core/templates/pages/certificate-assessment-player-page/certificate-assessment-player-state.service.ts
@@ -0,0 +1,232 @@
+// Copyright 2026 The Oppia Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS-IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/**
+ * @fileoverview Single source of truth for the learner's position in the
+ * certificate assessment journey (intro -> instructions -> questions) and
+ * for the time window of their current attempt.
+ *
+ * Every "fresh start" decision lives here on purpose so that components
+ * cannot drift apart: a timing window belongs to exactly one attempt. It
+ * is wiped once, when a replacement attempt successfully begins (see
+ * `beginNewAttempt`), and never by mere navigation like retry or resume.
+ */
+
+import {Injectable, OnDestroy} from '@angular/core';
+import {CertificateAssessmentAttemptData} from 'domain/certificate-assessment/certificate-assessment.model';
+import {
+ CertificateAssessmentPlayerPageConstants,
+ CertificateAssessmentStage,
+} from './certificate-assessment-player-page.constants';
+
+@Injectable()
+export class CertificateAssessmentPlayerStateService implements OnDestroy {
+ currentStage: CertificateAssessmentStage =
+ CertificateAssessmentPlayerPageConstants.STAGE_INTRO;
+ showAssessmentInterruptCard = false;
+ remainingTimeInSeconds = 0;
+ isTimeExpired = false;
+
+ private attempt: CertificateAssessmentAttemptData | null = null;
+ private timerId: number | null = null;
+ private expiryTimestampMs: number | null = null;
+ private hasStartedTimer = false;
+ private hasPausedForNetworkLoss = false;
+ private timeLimitInSeconds = 0;
+
+ /**
+ * Remembers the offering's time limit so the countdown can be armed as
+ * soon as both an attempt and its configuration are known. Also covers
+ * learners who land directly on the session route: their attempt already
+ * exists, so the timer starts here once the offering finally arrives.
+ */
+ configureForOffering(timeLimitInMinutes: number): void {
+ this.timeLimitInSeconds = timeLimitInMinutes * 60;
+ this.startTimerIfReady();
+ }
+
+ /** Returns the attempt the learner is currently working on, if any. */
+ getAttempt(): CertificateAssessmentAttemptData | null {
+ return this.attempt;
+ }
+
+ /**
+ * Marks the start of a brand-new attempt. This is the only place that
+ * resets timing state, so a fresh attempt always gets a full window and
+ * a failed start request can never silently extend an old one.
+ */
+ beginNewAttempt(attempt: CertificateAssessmentAttemptData): void {
+ this.attempt = attempt;
+ this.resetTimerState();
+ this.currentStage =
+ CertificateAssessmentPlayerPageConstants.STAGE_QUESTIONS;
+ this.startTimerIfReady();
+ }
+
+ /** Shows the assessment instructions stage. */
+ showInstructions(): void {
+ this.currentStage =
+ CertificateAssessmentPlayerPageConstants.STAGE_INSTRUCTIONS;
+ }
+
+ /** Returns the learner to the intro stage. */
+ showIntro(): void {
+ this.currentStage = CertificateAssessmentPlayerPageConstants.STAGE_INTRO;
+ }
+
+ /**
+ * Handles "retry" after an interruption: the learner goes back to the
+ * intro to start over. Timing state is deliberately left untouched here,
+ * because the old window only ends when the replacement attempt begins
+ * (see `beginNewAttempt`); partial answers need no explicit cleanup since
+ * they live inside the questions component, which the stage change
+ * destroys along with them.
+ */
+ returnToIntroAfterRetry(): void {
+ this.showAssessmentInterruptCard = false;
+ this.showIntro();
+ }
+
+ /**
+ * Handles "resume" after a network-loss interruption: the learner returns
+ * to the questions of their existing attempt, picking up whatever time
+ * remains (the disconnected duration was never counted down).
+ */
+ resumeQuestionsStage(): void {
+ this.showAssessmentInterruptCard = false;
+ this.hasPausedForNetworkLoss = false;
+ this.currentStage =
+ CertificateAssessmentPlayerPageConstants.STAGE_QUESTIONS;
+ this.resumeTimer();
+ }
+
+ /**
+ * Freezes the countdown while the learner is offline so that disconnected
+ * time is not counted towards the assessment expiry window. The elapsed
+ * time already shown is preserved and continues once the learner reconnects
+ * and chooses to resume.
+ */
+ pauseForNetworkLoss(): void {
+ if (
+ this.currentStage !==
+ CertificateAssessmentPlayerPageConstants.STAGE_QUESTIONS ||
+ this.timerId === null ||
+ this.isTimeExpired
+ ) {
+ return;
+ }
+ this.hasPausedForNetworkLoss = true;
+ this.clearTimer();
+ }
+
+ /**
+ * Surfaces the assessment interrupt card (with a resume option) when the
+ * learner comes back online, but only if the countdown was actually paused
+ * for a network loss and the window has not already expired.
+ */
+ handleReconnect(): void {
+ if (this.hasPausedForNetworkLoss && !this.isTimeExpired) {
+ this.showAssessmentInterruptCard = true;
+ }
+ }
+
+ ngOnDestroy(): void {
+ this.clearTimer();
+ }
+
+ /**
+ * Starts the countdown if, and only if, everything it depends on is in
+ * place: a known attempt, a positive time limit, the questions stage and
+ * no window already running for this attempt.
+ */
+ private startTimerIfReady(): void {
+ if (
+ this.hasStartedTimer ||
+ this.attempt === null ||
+ this.timeLimitInSeconds <= 0 ||
+ this.currentStage !==
+ CertificateAssessmentPlayerPageConstants.STAGE_QUESTIONS
+ ) {
+ return;
+ }
+ this.hasStartedTimer = true;
+ // Derive the remaining time from an absolute deadline rather than
+ // decrementing per callback, because browsers throttle intervals in
+ // inactive tabs and would otherwise let the assessment run longer than
+ // its configured duration.
+ const expiryTimestampMs = Date.now() + this.timeLimitInSeconds * 1000;
+ this.expiryTimestampMs = expiryTimestampMs;
+ this.remainingTimeInSeconds = this.timeLimitInSeconds;
+ this.timerId = window.setInterval(() => {
+ this.remainingTimeInSeconds = Math.max(
+ 0,
+ Math.ceil((expiryTimestampMs - Date.now()) / 1000)
+ );
+ if (this.remainingTimeInSeconds === 0) {
+ this.isTimeExpired = true;
+ this.clearTimer();
+ }
+ }, 1000);
+ }
+
+ private clearTimer(): void {
+ if (this.timerId !== null) {
+ window.clearInterval(this.timerId);
+ this.timerId = null;
+ }
+ }
+
+ /**
+ * Restarts the countdown from the time that was left when it was paused for
+ * a network loss, rather than from the full configured limit.
+ */
+ private resumeTimer(): void {
+ if (
+ this.currentStage !==
+ CertificateAssessmentPlayerPageConstants.STAGE_QUESTIONS ||
+ this.timerId !== null ||
+ this.remainingTimeInSeconds <= 0 ||
+ this.isTimeExpired
+ ) {
+ return;
+ }
+ this.hasStartedTimer = true;
+ const expiryTimestampMs = Date.now() + this.remainingTimeInSeconds * 1000;
+ this.expiryTimestampMs = expiryTimestampMs;
+ this.timerId = window.setInterval(() => {
+ this.remainingTimeInSeconds = Math.max(
+ 0,
+ Math.ceil((expiryTimestampMs - Date.now()) / 1000)
+ );
+ if (this.remainingTimeInSeconds === 0) {
+ this.isTimeExpired = true;
+ this.clearTimer();
+ }
+ }, 1000);
+ }
+
+ /**
+ * Wipes all attempt-scoped timing state: the running interval, the
+ * deadline, the displayed countdown and the expiry flag. Only ever
+ * called while arming a replacement attempt.
+ */
+ private resetTimerState(): void {
+ this.clearTimer();
+ this.hasStartedTimer = false;
+ this.hasPausedForNetworkLoss = false;
+ this.isTimeExpired = false;
+ this.remainingTimeInSeconds = 0;
+ this.expiryTimestampMs = null;
+ }
+}
diff --git a/core/templates/pages/collection-player-page/collection-player-page-root.component.css b/core/templates/pages/collection-player-page/collection-player-page-root.component.css
index 87368fd1ecf98..086ee0667c57d 100644
--- a/core/templates/pages/collection-player-page/collection-player-page-root.component.css
+++ b/core/templates/pages/collection-player-page/collection-player-page-root.component.css
@@ -2,62 +2,9 @@ html, body {
background: #eee no-repeat center center fixed;
background-size: cover;
}
-.oppia-collection-path-section {
- background: #fff;
- border-radius: 2px;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.14), 0 2px 4px rgba(0, 0, 0, 0.23);
- float: left;
- margin-top: 20px;
- max-width: 675px;
- padding: 40px 10px 60px 20px;
- position: absolute;
- text-align: left;
- top: 168px;
-}
-.oppia-collection-table {
- margin: 0 auto;
- max-width: 90%;
- padding: 50px 20px 420px 20px;
- position: relative;
- text-align: left;
- top: 35px;
- width: 100%;
-}
-.collection-mascot {
- left: 40px;
- position: absolute;
- top: 40px;
- width: 120px;
-}
-@media only screen and (max-width : 600px) {
- .oppia-collection-player-title-font {
- font-size: 32px;
- }
-}
+
/* To avoid double shadows caused by overlaying preview card
-over the blank preview card */
+-over the blank preview card */
.exploration-summary-tile mat-card {
box-shadow: none;
}
-.mobile-path-segment {
- margin: 0 auto;
- width: 250px;
-}
-.mobile-lesson-icon {
- left: 50%;
- position: absolute;
- transform: translate(-50%, -50%);
- width: 100px;
-}
-
-@media only screen and (max-width: 998px) {
- .oppia-activity-summary-tile-mobile-background-mask {
- align-items: center;
- background: rgba(0,0,0,0.25);
- display: flex;
- height: 100vh;
- justify-content: space-around;
- width: 100vw;
- z-index: -1;
- }
-}
diff --git a/core/templates/pages/collection-player-page/collection-player-page-root.component.ts b/core/templates/pages/collection-player-page/collection-player-page-root.component.ts
index 57cf00c2eb543..5fe1b4f96aba4 100644
--- a/core/templates/pages/collection-player-page/collection-player-page-root.component.ts
+++ b/core/templates/pages/collection-player-page/collection-player-page-root.component.ts
@@ -25,6 +25,7 @@ import './collection-player-page-root.component.css';
@Component({
selector: 'oppia-collection-player-page-root',
templateUrl: './collection-player-page-root.component.html',
+ styleUrls: ['./collection-player-page-root.component.css'],
})
export class CollectionPlayerPageRootComponent extends BaseRootComponent {
title: string =
diff --git a/core/templates/pages/collection-player-page/collection-player-page.component.css b/core/templates/pages/collection-player-page/collection-player-page.component.css
index 0831abded4722..88e80ef447767 100644
--- a/core/templates/pages/collection-player-page/collection-player-page.component.css
+++ b/core/templates/pages/collection-player-page/collection-player-page.component.css
@@ -89,6 +89,19 @@
z-index: 1;
}
+.oppia-collection-path-section {
+ background: #fff;
+ border-radius: 2px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.14), 0 2px 4px rgba(0, 0, 0, 0.23);
+ float: left;
+ margin-top: 20px;
+ max-width: 675px;
+ padding: 40px 10px 60px 20px;
+ position: absolute;
+ text-align: left;
+ top: 168px;
+}
+
.no-exploration-created .oppia-collection-path-section {
color: red;
font-size: 20px;
@@ -97,6 +110,35 @@
top: 228px;
}
+.oppia-collection-table {
+ margin: 0 auto;
+ max-width: 90%;
+ padding: 50px 20px 420px 20px;
+ position: relative;
+ text-align: left;
+ top: 35px;
+ width: 100%;
+}
+
+.collection-mascot {
+ left: 40px;
+ position: absolute;
+ top: 40px;
+ width: 120px;
+}
+
+.mobile-path-segment {
+ margin: 0 auto;
+ width: 250px;
+}
+
+.mobile-lesson-icon {
+ left: 50%;
+ position: absolute;
+ transform: translate(-50%, -50%);
+ width: 100px;
+}
+
.oppia-card-preview-panel {
background: #fff;
border-radius: 4px;
@@ -121,3 +163,21 @@
display: none;
}
}
+
+@media only screen and (max-width: 600px) {
+ .oppia-collection-player-title-font {
+ font-size: 32px;
+ }
+}
+
+@media only screen and (max-width: 998px) {
+ .oppia-activity-summary-tile-mobile-background-mask {
+ align-items: center;
+ background: rgba(0,0,0,0.25);
+ display: flex;
+ height: 100vh;
+ justify-content: space-around;
+ width: 100vw;
+ z-index: -1;
+ }
+}
diff --git a/core/templates/pages/contributor-dashboard-page/modal-templates/translation-modal.component.spec.ts b/core/templates/pages/contributor-dashboard-page/modal-templates/translation-modal.component.spec.ts
index e8c0522381905..cfdfab244af57 100644
--- a/core/templates/pages/contributor-dashboard-page/modal-templates/translation-modal.component.spec.ts
+++ b/core/templates/pages/contributor-dashboard-page/modal-templates/translation-modal.component.spec.ts
@@ -126,6 +126,10 @@ describe('Translation Modal Component', () => {
addEventListener: jasmine.Spy;
removeEventListener: jasmine.Spy;
gtag: jasmine.Spy;
+ location: {
+ pathname: string;
+ href: string;
+ };
};
const opportunity: TranslationOpportunity = {
@@ -157,6 +161,10 @@ describe('Translation Modal Component', () => {
addEventListener: jasmine.createSpy('addEventListener'),
removeEventListener: jasmine.createSpy('removeEventListener'),
gtag: jasmine.createSpy('gtag'),
+ location: {
+ pathname: '/signup',
+ href: '',
+ },
};
TestBed.configureTestingModule({
diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/exploration-player-page-root.component.ts b/core/templates/pages/exploration-player-page/current-lesson-player/exploration-player-page-root.component.ts
index aa7d843959549..6aab38accb754 100644
--- a/core/templates/pages/exploration-player-page/current-lesson-player/exploration-player-page-root.component.ts
+++ b/core/templates/pages/exploration-player-page/current-lesson-player/exploration-player-page-root.component.ts
@@ -16,13 +16,12 @@
* @fileoverview Exploration player page root component.
*/
-import {Component, ViewEncapsulation} from '@angular/core';
+import {Component} from '@angular/core';
import './exploration-player-page-root.component.css';
@Component({
selector: 'oppia-exploration-player-page-root',
templateUrl: './exploration-player-page-root.component.html',
styleUrls: ['./exploration-player-page-root.component.css'],
- encapsulation: ViewEncapsulation.None,
})
export class ExplorationPlayerPageRootComponent {}
diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/layout-directives/exploration-footer.component.css b/core/templates/pages/exploration-player-page/current-lesson-player/layout-directives/exploration-footer.component.css
index a1f099234a847..9bc3226a3cd6f 100644
--- a/core/templates/pages/exploration-player-page/current-lesson-player/layout-directives/exploration-footer.component.css
+++ b/core/templates/pages/exploration-player-page/current-lesson-player/layout-directives/exploration-footer.component.css
@@ -272,37 +272,6 @@
font-weight: normal;
}
-.oppia-lesson-info-card {
- border-bottom-left-radius: 4px;
- border-bottom-right-radius: 4px;
- padding: 0 0 6px 0;
- text-align: left;
-}
-
-.oppia-lesson-info-card .oppia-lesson-exploration-progress-info {
- padding: 0 20px 20px 20px;
-}
-
-.oppia-lesson-info-card .oppia-story-title {
- color: #3b3b3b;
- font-family: "Capriola", "Roboto", Arial, sans-serif;
- font-size: 13px;
- margin-bottom: -10px;
- padding: 20px 20px 0;
- text-align: left;
-}
-
-.oppia-lesson-info-card .oppia-lesson-info-content {
- padding-bottom: 0;
-}
-
-.oppia-lesson-info-card .oppia-exploration-description {
- color: #333;
- font-size: 15px;
- font-weight: normal;
- padding-bottom: 10px;
-}
-
.oppia-lesson-info .oppia-exploration-info-icon {
cursor: pointer;
}
diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/layout-directives/exploration-footer.component.ts b/core/templates/pages/exploration-player-page/current-lesson-player/layout-directives/exploration-footer.component.ts
index 697ba9f9aa1a5..21707bce6a011 100644
--- a/core/templates/pages/exploration-player-page/current-lesson-player/layout-directives/exploration-footer.component.ts
+++ b/core/templates/pages/exploration-player-page/current-lesson-player/layout-directives/exploration-footer.component.ts
@@ -17,12 +17,7 @@
* in exploration player.
*/
-import {
- Component,
- ElementRef,
- ViewChild,
- ViewEncapsulation,
-} from '@angular/core';
+import {Component, ElementRef, ViewChild} from '@angular/core';
import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
import {EditableExplorationBackendApiService} from 'domain/exploration/editable-exploration-backend-api.service';
import {
@@ -58,7 +53,6 @@ import {QuestionPlayerEngineService} from 'pages/exploration-player-page/service
selector: 'oppia-exploration-footer',
templateUrl: './exploration-footer.component.html',
styleUrls: ['./exploration-footer.component.css'],
- encapsulation: ViewEncapsulation.None,
})
export class ExplorationFooterComponent {
directiveSubscriptions = new Subscription();
diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/layout-directives/learner-view-info.component.css b/core/templates/pages/exploration-player-page/current-lesson-player/layout-directives/learner-view-info.component.css
index cb71e7f3b86a2..dfaa1aef939ee 100644
--- a/core/templates/pages/exploration-player-page/current-lesson-player/layout-directives/learner-view-info.component.css
+++ b/core/templates/pages/exploration-player-page/current-lesson-player/layout-directives/learner-view-info.component.css
@@ -3,27 +3,27 @@
compilation, here are sme additional rules that can be added to the CSS files:
https://rtlcss.com/learn/usage-guide/control-directives .
*/
-oppia-learner-view-info .oppia-navbar-breadcrumb {
+.oppia-navbar-breadcrumb {
padding-top: 10px;
}
-oppia-learner-view-info .oppia-learner-navbar-container {
+.oppia-learner-navbar-container {
display: flex;
}
-oppia-learner-view-info .lesson-info-section {
+.lesson-info-section {
display: contents;
}
-oppia-learner-view-info .oppia-navbar-title-container {
+.oppia-navbar-title-container {
display: flex;
flex-direction: column;
}
-oppia-learner-view-info .oppia-exploration-h1 {
+.oppia-exploration-h1 {
color: #fff;
display: inline;
font-size: 18px;
font-weight: normal;
margin-bottom: 1px;
}
-oppia-learner-view-info .oppia-topic-title {
+.oppia-topic-title {
color: #fff;
display: flex;
font-size: 12px;
diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/layout-directives/learner-view-info.component.ts b/core/templates/pages/exploration-player-page/current-lesson-player/layout-directives/learner-view-info.component.ts
index 91290e8a15a67..7d11481a680f9 100644
--- a/core/templates/pages/exploration-player-page/current-lesson-player/layout-directives/learner-view-info.component.ts
+++ b/core/templates/pages/exploration-player-page/current-lesson-player/layout-directives/learner-view-info.component.ts
@@ -17,7 +17,7 @@
* footer.
*/
-import {Component, ViewEncapsulation} from '@angular/core';
+import {Component} from '@angular/core';
import {ClassroomDomainConstants} from 'domain/classroom/classroom-domain.constants';
import {ReadOnlyExplorationBackendApiService} from 'domain/exploration/read-only-exploration-backend-api.service';
import {StoryPlaythrough} from 'domain/story_viewer/story-playthrough.model';
@@ -39,7 +39,6 @@ import {StatsReportingService} from '../../services/stats-reporting.service';
selector: 'oppia-learner-view-info',
templateUrl: './learner-view-info.component.html',
styleUrls: ['./learner-view-info.component.css'],
- encapsulation: ViewEncapsulation.None,
})
export class LearnerViewInfoComponent {
// These properties are initialized using Angular lifecycle hooks
@@ -166,10 +165,8 @@ export class LearnerViewInfoComponent {
}
isHackyExpTitleTranslationDisplayed(): boolean {
- return (
- this.i18nLanguageCodeService.isHackyTranslationAvailable(
- this.explorationTitleTranslationKey
- ) && !this.i18nLanguageCodeService.isCurrentLanguageEnglish()
+ return this.i18nLanguageCodeService.isHackyTranslationDisplayed(
+ this.explorationTitleTranslationKey
);
}
diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/learner-experience/conversation-skin.component.css b/core/templates/pages/exploration-player-page/current-lesson-player/learner-experience/conversation-skin.component.css
index f90b618928e68..81da5b3d4f6ca 100644
--- a/core/templates/pages/exploration-player-page/current-lesson-player/learner-experience/conversation-skin.component.css
+++ b/core/templates/pages/exploration-player-page/current-lesson-player/learner-experience/conversation-skin.component.css
@@ -18,7 +18,7 @@ otherwise they will interfere with the iframed conversation skin directive. */
z-index: 16;
}
-oppia-conversation-skin .supplemental-card-parent-container {
+.supplemental-card-parent-container {
min-width: 50%;
}
@@ -138,7 +138,7 @@ button.oppia-button-raised.e2e-test-next-lesson-button.mat-focus-indicator.mat-r
width: 32px;
}
-oppia-conversation-skin .oppia-button-icon {
+.oppia-button-icon {
color: #fff;
font-size: 42px;
}
@@ -183,24 +183,4 @@ oppia-conversation-skin .oppia-button-icon {
transform: translateX(-50%);
width: 100%;
}
-
- .conversation-skin-oppia-avatar.show-tutor-card {
- border-radius: 50%;
- box-shadow: 0 3px 3px grey, 0 5px 3px grey;
- display: block;
- height: 40px;
- left: 100%;
- position: absolute;
- transform: translate(-100%, -100%);
- width: 40px;
- z-index: 16;
- }
-
- .conversation-skin-oppia-avatar.show-tutor-card:hover {
- opacity: 0.8;
- }
-
- oppia-conversation-skin .conversation-skin-oppia-avatar {
- display: none;
- }
}
diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/learner-experience/conversation-skin.component.ts b/core/templates/pages/exploration-player-page/current-lesson-player/learner-experience/conversation-skin.component.ts
index e5f8db2fcd4c8..3eee754b4dbcc 100644
--- a/core/templates/pages/exploration-player-page/current-lesson-player/learner-experience/conversation-skin.component.ts
+++ b/core/templates/pages/exploration-player-page/current-lesson-player/learner-experience/conversation-skin.component.ts
@@ -18,12 +18,7 @@
import {Subscription} from 'rxjs';
import {StateCard} from 'domain/state_card/state-card.model';
-import {
- ChangeDetectorRef,
- Component,
- Input,
- ViewEncapsulation,
-} from '@angular/core';
+import {ChangeDetectorRef, Component, Input} from '@angular/core';
import {WindowRef} from 'services/contextual/window-ref.service';
import {AlertsService} from 'services/alerts.service';
import {PageContextService} from 'services/page-context.service';
@@ -75,7 +70,6 @@ import {InteractionAnswer} from 'interactions/answer-defs';
selector: 'oppia-conversation-skin',
templateUrl: './conversation-skin.component.html',
styleUrls: ['./conversation-skin.component.css'],
- encapsulation: ViewEncapsulation.None,
})
export class ConversationSkinComponent {
@Input() questionPlayerConfig!: QuestionPlayerConfig | null;
@@ -571,10 +565,8 @@ export class ConversationSkinComponent {
isHackyExpTitleTranslationDisplayed(explorationId: string): boolean {
let recommendedExpTitleTranslationKey =
this.getRecommendedExpTitleTranslationKey(explorationId);
- return (
- this.i18nLanguageCodeService.isHackyTranslationAvailable(
- recommendedExpTitleTranslationKey
- ) && !this.i18nLanguageCodeService.isCurrentLanguageEnglish()
+ return this.i18nLanguageCodeService.isHackyTranslationDisplayed(
+ recommendedExpTitleTranslationKey
);
}
diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/learner-experience/input-response-pair.component.css b/core/templates/pages/exploration-player-page/current-lesson-player/learner-experience/input-response-pair.component.css
index fa9df11d11c36..61fe3ea622248 100644
--- a/core/templates/pages/exploration-player-page/current-lesson-player/learner-experience/input-response-pair.component.css
+++ b/core/templates/pages/exploration-player-page/current-lesson-player/learner-experience/input-response-pair.component.css
@@ -4,48 +4,48 @@
https://rtlcss.com/learn/usage-guide/control-directives .
*/
-oppia-input-response-pair .conversation-skin-learner-answer-container,
-oppia-input-response-pair .conversation-skin-oppia-feedback-container {
+.conversation-skin-learner-answer-container,
+.conversation-skin-oppia-feedback-container {
padding: 0 20px;
position: relative;
}
-oppia-input-response-pair .conversation-skin-oppia-avatar {
+.conversation-skin-oppia-avatar {
left: -87px;
}
-oppia-input-response-pair .conversation-skin-oppia-feedback-container .conversation-skin-oppia-avatar {
+.conversation-skin-oppia-feedback-container .conversation-skin-oppia-avatar {
height: 100px;
left: -87px;
top: -20px;
width: 100px;
}
-oppia-input-response-pair .conversation-skin-oppia-feedback-content,
-oppia-input-response-pair .conversation-skin-learner-answer-content {
+.conversation-skin-oppia-feedback-content,
+.conversation-skin-learner-answer-content {
margin-bottom: 12px;
overflow: auto;
padding: 8px 12px;
word-wrap: break-word;
}
-oppia-input-response-pair .conversation-skin-oppia-feedback-content,
-oppia-input-response-pair .conversation-skin-help-card-content {
+.conversation-skin-oppia-feedback-content,
+.conversation-skin-help-card-content {
background-color: rgba(224,242,241,1);
border-top-left-radius: 0;
}
-oppia-input-response-pair .conversation-skin-learner-answer-content {
+.conversation-skin-learner-answer-content {
background-color: rgba(236,239,241,1);
border-bottom-right-radius: 0;
}
-oppia-input-response-pair .conversation-skin-learner-answer-container {
+.conversation-skin-learner-answer-container {
text-align: right;
}
-oppia-input-response-pair .conversation-skin-learner-answer-container .conversation-skin-user-avatar {
+.conversation-skin-learner-answer-container .conversation-skin-user-avatar {
border: 2px solid #094142;
height: 100px;
right: -98px;
@@ -54,12 +54,12 @@ oppia-input-response-pair .conversation-skin-learner-answer-container .conversat
}
@media screen and (max-width: 959px) {
- oppia-input-response-pair .conversation-skin-user-avatar {
+ .conversation-skin-user-avatar {
display: none;
}
}
-oppia-input-response-pair .oppia-popover {
+.oppia-popover {
cursor: pointer;
display: inline-block;
}
@@ -68,9 +68,9 @@ oppia-input-response-pair .oppia-popover {
max-width: 300px;
}
-oppia-input-response-pair .oppia-loading-dot-one,
-oppia-input-response-pair .oppia-loading-dot-two,
-oppia-input-response-pair .oppia-loading-dot-three {
+.oppia-loading-dot-one,
+.oppia-loading-dot-two,
+.oppia-loading-dot-three {
background-color: #666;
border-radius: 50%;
display: inline-block;
@@ -80,7 +80,7 @@ oppia-input-response-pair .oppia-loading-dot-three {
width: 5px;
}
-oppia-input-response-pair .oppia-loading-dot-one {
+.oppia-loading-dot-one {
-webkit-animation: dot 1.0s infinite;
-moz-animation: dot 1.0s infinite;
animation: dot 1.0s infinite;
@@ -90,7 +90,7 @@ oppia-input-response-pair .oppia-loading-dot-one {
opacity: 0;
}
-oppia-input-response-pair .oppia-loading-dot-two {
+.oppia-loading-dot-two {
-webkit-animation: dot 1.0s infinite;
-moz-animation: dot 1.0s infinite;
animation: dot 1.0s infinite;
@@ -100,7 +100,7 @@ oppia-input-response-pair .oppia-loading-dot-two {
opacity: 0;
}
-oppia-input-response-pair .oppia-loading-dot-three {
+.oppia-loading-dot-three {
-moz-animation: dot 1.0s infinite;
-webkit-animation: dot 1.0s infinite;
animation: dot 1.0s infinite;
@@ -111,12 +111,12 @@ oppia-input-response-pair .oppia-loading-dot-three {
opacity: 0;
}
-oppia-input-response-pair .conversation-skin-oppia-feedback.ng-enter {
+.conversation-skin-oppia-feedback.ng-enter {
opacity: 0;
-webkit-transition: all .5s;
transition: all .5s;
}
-oppia-input-response-pair .conversation-skin-oppia-feedback.ng-enter.ng-enter-active {
+.conversation-skin-oppia-feedback.ng-enter.ng-enter-active {
opacity: 1;
}
diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/learner-experience/tutor-card.component.css b/core/templates/pages/exploration-player-page/current-lesson-player/learner-experience/tutor-card.component.css
index 8d12e13da7a58..6a59796fef298 100644
--- a/core/templates/pages/exploration-player-page/current-lesson-player/learner-experience/tutor-card.component.css
+++ b/core/templates/pages/exploration-player-page/current-lesson-player/learner-experience/tutor-card.component.css
@@ -4,17 +4,17 @@
https://rtlcss.com/learn/usage-guide/control-directives .
*/
-oppia-tutor-card .oppia-learner-view-card-content .oppia-learner-confirm-button {
+.oppia-learner-view-card-content .oppia-learner-confirm-button {
margin-bottom: 10px;
margin-left: 10px;
}
-oppia-tutor-card .oppia-learner-view-card-top-section {
+.oppia-learner-view-card-top-section {
padding: 0 20px;
position: relative;
}
-oppia-tutor-card .oppia-learner-view-card {
+.oppia-learner-view-card {
background: #fff;
border-radius: 2px;
max-width: 100vw;
@@ -24,7 +24,7 @@ oppia-tutor-card .oppia-learner-view-card {
text-align: left;
}
-oppia-tutor-card .oppia-learner-view-card-top-content {
+.oppia-learner-view-card-top-content {
background-color: rgba(224,242,241,1);
border-top-left-radius: 0;
margin-bottom: 12px;
@@ -32,53 +32,53 @@ oppia-tutor-card .oppia-learner-view-card-top-content {
width: 100%;
}
-oppia-tutor-card .oppia-content-language-selector-container {
+.oppia-content-language-selector-container {
float: right;
margin-bottom: 12px;
margin-right: 0;
}
-oppia-tutor-card .oppia-content-language-selector-icon {
+.oppia-content-language-selector-icon {
color: rgb(75, 74, 74);
font-size: 25px;
margin-right: 4px;
}
-oppia-tutor-card .oppia-content-language-selector {
+.oppia-content-language-selector {
border-radius: 9px;
font-size: 15px;
margin-left: 5px;
padding-left: 3px;
}
-oppia-tutor-card .oppia-exploration-language {
+.oppia-exploration-language {
padding: 0 5px 0 5px;
}
-oppia-tutor-card .oppia-learner-view-card-content {
+.oppia-learner-view-card-content {
word-wrap: break-word;
}
-oppia-tutor-card .conversation-skin-instruction-disabled {
+.conversation-skin-instruction-disabled {
color: gray;
}
-oppia-tutor-card .oppia-learner-view-card-top-section .conversation-skin-oppia-avatar {
+.oppia-learner-view-card-top-section .conversation-skin-oppia-avatar {
height: 100px;
left: -87px;
top: -20px;
width: 100px;
}
-oppia-tutor-card .conversation-skin-tutor-card-middle-section .conversation-skin-user-avatar,
-oppia-tutor-card .conversation-skin-tutor-card-middle-section .conversation-skin-oppia-avatar {
+.conversation-skin-tutor-card-middle-section .conversation-skin-user-avatar,
+.conversation-skin-tutor-card-middle-section .conversation-skin-oppia-avatar {
display: none;
}
-oppia-tutor-card .oppia-learner-view-card .instructions-button {
+.oppia-learner-view-card .instructions-button {
background: inherit;
border: none;
white-space: normal;
}
-oppia-tutor-card .oppia-learner-view-card.animate-card-width {
+.oppia-learner-view-card.animate-card-width {
-webkit-transition: width 500ms;
transition: width 500ms;
}
@@ -88,17 +88,17 @@ oppia-tutor-card .oppia-learner-view-card.animate-card-width {
word-wrap: break-word;
}
-oppia-tutor-card .oppia-learner-view-card-top-content p:not(:last-child) {
+.oppia-learner-view-card-top-content p:not(:last-child) {
line-height: 1.5;
margin-bottom: 18px;
}
/* This should match the value defined in AUDIO_HIGHLIGHT_CSS_CLASS. */
-oppia-tutor-card .conversation-skin-audio-highlight {
+.conversation-skin-audio-highlight {
border: 3px solid #f3d140;
}
-oppia-tutor-card .conversation-skin-future-tutor-card {
+.conversation-skin-future-tutor-card {
background: #fff;
border-radius: 2px;
margin-left: 87px;
@@ -106,38 +106,38 @@ oppia-tutor-card .conversation-skin-future-tutor-card {
text-align: left;
}
-oppia-tutor-card .conversation-skin-tutor-card-middle-section .conversation-skin-learner-answer-content {
+.conversation-skin-tutor-card-middle-section .conversation-skin-learner-answer-content {
background-color: rgba(236,239,241,0.4);
}
-oppia-tutor-card .conversation-skin-tutor-card-middle-section .conversation-skin-oppia-feedback-content {
+.conversation-skin-tutor-card-middle-section .conversation-skin-oppia-feedback-content {
background-color: rgba(224,242,241,0.4);
}
-oppia-tutor-card .conversation-skin-tutor-card-middle-section .conversation-skin-oppia-feedback-content::before,
-oppia-tutor-card .conversation-skin-tutor-card-middle-section .conversation-skin-learner-answer-content::before {
+.conversation-skin-tutor-card-middle-section .conversation-skin-oppia-feedback-content::before,
+.conversation-skin-tutor-card-middle-section .conversation-skin-learner-answer-content::before {
display: none;
}
-oppia-tutor-card .check-mark-hidden {
+.check-mark-hidden {
display: none;
}
-oppia-tutor-card .check-mark-skipped {
+.check-mark-skipped {
opacity: 0;
}
-oppia-tutor-card .end-chapter-check-mark {
+.end-chapter-check-mark {
transition-duration: 0.5s;
transition-property: opacity;
}
-oppia-tutor-card .learner-view-card-transition-class {
+.learner-view-card-transition-class {
transition-duration: 1s;
transition-property: transform, opacity;
}
-oppia-tutor-card .card-content-hidden {
+.card-content-hidden {
opacity: 0;
position: fixed;
transform: translate(0, 7px);
@@ -151,14 +151,14 @@ oppia-tutor-card .card-content-hidden {
text-align: center;
}
-oppia-tutor-card .conversation-skin-responses-dropdown-text {
+.conversation-skin-responses-dropdown-text {
background-color: #fff;
color: #000;
font-size: 0.8em;
padding: 0 10px;
}
-oppia-tutor-card .conversation-skin-responses-dropdown-icon {
+.conversation-skin-responses-dropdown-icon {
font-size: 13px;
margin-bottom: -2px;
-moz-transition: all 0.2s;
@@ -167,7 +167,7 @@ oppia-tutor-card .conversation-skin-responses-dropdown-icon {
transition: all 0.2s;
}
-oppia-tutor-card .conversation-skin-responses-dropdown-icon-rotated {
+.conversation-skin-responses-dropdown-icon-rotated {
margin-bottom: -2px;
-moz-transform: rotate(90deg);
-o-transform: rotate(90deg);
@@ -176,19 +176,19 @@ oppia-tutor-card .conversation-skin-responses-dropdown-icon-rotated {
transition: transform 0.2s ease-in-out;
}
-oppia-tutor-card .conversation-skin-inline-interaction {
+.conversation-skin-inline-interaction {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
padding: 8px 20px 25px;
position: relative;
}
-oppia-tutor-card .conversation-skin-inline-interaction .conversation-skin-user-avatar {
+.conversation-skin-inline-interaction .conversation-skin-user-avatar {
left: -20px;
top: 14px;
}
-oppia-tutor-card .conversation-skin-milestone-message {
+.conversation-skin-milestone-message {
align-items: center;
display: flex;
justify-content: center;
@@ -196,11 +196,11 @@ oppia-tutor-card .conversation-skin-milestone-message {
padding: 0 20px;
}
-oppia-tutor-card .conversation-skin-milestone-message.milestone-progress-message {
+.conversation-skin-milestone-message.milestone-progress-message {
flex-direction: column;
}
-oppia-tutor-card .conversation-skin-milestone-message .milestone-message-star-container {
+.conversation-skin-milestone-message .milestone-message-star-container {
align-items: center;
display: flex;
flex-direction: row;
@@ -210,31 +210,31 @@ oppia-tutor-card .conversation-skin-milestone-message .milestone-message-star-co
width: 72px;
}
-oppia-tutor-card .conversation-skin-milestone-message .milestone-message-star-container .star-icon {
+.conversation-skin-milestone-message .milestone-message-star-container .star-icon {
position: absolute;
transform: scale(1.5);
}
-oppia-tutor-card .conversation-skin-milestone-message .completed-chapter-count {
+.conversation-skin-milestone-message .completed-chapter-count {
font-weight: 500;
margin-top: 9px;
position: relative;
}
-oppia-tutor-card .conversation-skin-milestone-message .chapters-left-count {
+.conversation-skin-milestone-message .chapters-left-count {
font-weight: 500;
margin-top: 9px;
position: relative;
}
-oppia-tutor-card .conversation-skin-milestone-message .milestone-message-text {
+.conversation-skin-milestone-message .milestone-message-text {
font-size: 1.1em;
font-weight: 800;
margin-top: 9px;
text-align: center;
}
-oppia-tutor-card .conversation-skin-milestone-message .milestone-message-progress-bar-container {
+.conversation-skin-milestone-message .milestone-message-progress-bar-container {
align-items: center;
display: flex;
justify-content: center;
@@ -242,48 +242,48 @@ oppia-tutor-card .conversation-skin-milestone-message .milestone-message-progres
width: 100%;
}
-oppia-tutor-card .milestone-message-progress-bar-container .milestone-progress-bar-outer {
+.milestone-message-progress-bar-container .milestone-progress-bar-outer {
border: 2px solid #00645c;
height: 20px;
margin-right: 30px;
width: 300px;
}
-oppia-tutor-card .milestone-message-progress-bar-container .milestone-progress-bar-inner {
+.milestone-message-progress-bar-container .milestone-progress-bar-inner {
background: #a6dacf;
height: 100%;
}
-oppia-tutor-card .milestone-message-progress-bar-container .milestone-message-star-container {
+.milestone-message-progress-bar-container .milestone-message-star-container {
margin-bottom: 9px;
margin-left: -57px;
}
-oppia-tutor-card .oppia-conversation-skin-inline-interaction {
+.oppia-conversation-skin-inline-interaction {
opacity: 0.8;
}
-oppia-tutor-card .oppia-conversation-skin-instruction-container {
+.oppia-conversation-skin-instruction-container {
padding: 6px 12px;
}
-oppia-tutor-card .oppia-no-interaction-text {
+.oppia-no-interaction-text {
color: #f00;
}
-oppia-tutor-card .conversation-skin-oppia-completed-state-card {
+.conversation-skin-oppia-completed-state-card {
margin: 60px 0 40px;
padding: 0 20px;
position: relative;
}
-oppia-tutor-card .conversation-skin-oppia-completed-state-card .conversation-skin-oppia-feedback-content {
+.conversation-skin-oppia-completed-state-card .conversation-skin-oppia-feedback-content {
color: #888;
font-style: italic;
}
-oppia-tutor-card .checkpoint-celebration-modal-container {
+.checkpoint-celebration-modal-container {
position: absolute;
}
-oppia-tutor-card .oppia-mcq-question-description-text {
+.oppia-mcq-question-description-text {
display: block;
padding: 0 20px;
}
@@ -293,33 +293,53 @@ oppia-tutor-card .oppia-mcq-question-description-text {
Correctness footer and the page cannot be scrolled down further.
So this padding allows to scroll down the tutor card. */
@media(max-width: 770px) {
- oppia-tutor-card .oppia-learner-view-card {
+ .oppia-learner-view-card {
padding-bottom: 30px;
}
}
@media screen and (max-width: 959px) {
- oppia-tutor-card .oppia-learner-view-card .instructions-button {
+ .oppia-learner-view-card .instructions-button {
background-color: #0d48a1;
color: #fff;
padding: 6px 12px;
white-space: normal;
}
- oppia-tutor-card .oppia-learner-view-card button.mat-button.mat-default-theme.instructions-button:focus,
- oppia-tutor-card .oppia-learner-view-card button.mat-button.mat-default-theme.instructions-button:hover {
+ .oppia-learner-view-card button.mat-button.mat-default-theme.instructions-button:focus,
+ .oppia-learner-view-card button.mat-button.mat-default-theme.instructions-button:hover {
background-color: #115fd4;
}
- oppia-tutor-card .oppia-learner-view-card {
+ .oppia-learner-view-card {
margin-left: 0;
padding-bottom: 0;
padding-left: 0;
}
+
+ .conversation-skin-oppia-avatar.show-tutor-card {
+ border-radius: 50%;
+ box-shadow: 0 3px 3px grey, 0 5px 3px grey;
+ display: block;
+ height: 40px;
+ left: 100%;
+ position: absolute;
+ transform: translate(-100%, -100%);
+ width: 40px;
+ z-index: 16;
+ }
+
+ .conversation-skin-oppia-avatar.show-tutor-card:hover {
+ opacity: 0.8;
+ }
+
+ .conversation-skin-oppia-avatar {
+ display: none;
+ }
}
@media screen and (max-width: 768px) {
- oppia-tutor-card .oppia-learner-view-card {
+ .oppia-learner-view-card {
margin-top: 30px;
}
}
diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/learner-experience/tutor-card.component.ts b/core/templates/pages/exploration-player-page/current-lesson-player/learner-experience/tutor-card.component.ts
index fa7b3c9873d67..91f371f3233cc 100644
--- a/core/templates/pages/exploration-player-page/current-lesson-player/learner-experience/tutor-card.component.ts
+++ b/core/templates/pages/exploration-player-page/current-lesson-player/learner-experience/tutor-card.component.ts
@@ -22,7 +22,6 @@ import {
SimpleChanges,
ViewChild,
Renderer2,
- ViewEncapsulation,
} from '@angular/core';
import {TranslateService} from '@ngx-translate/core';
import {AppConstants} from 'app.constants';
@@ -75,7 +74,6 @@ import {VoiceoverPlayerService} from '../../services/voiceover-player.service';
selector: 'oppia-tutor-card',
templateUrl: './tutor-card.component.html',
styleUrls: ['./tutor-card.component.css'],
- encapsulation: ViewEncapsulation.None,
animations: [
trigger('expandInOut', [
state(
diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/modals/flag-exploration-modal.component.css b/core/templates/pages/exploration-player-page/current-lesson-player/modals/flag-exploration-modal.component.css
new file mode 100644
index 0000000000000..1ec4f1488acd9
--- /dev/null
+++ b/core/templates/pages/exploration-player-page/current-lesson-player/modals/flag-exploration-modal.component.css
@@ -0,0 +1,17 @@
+.oppia-long-text h4 {
+ font-size: 16px;
+ margin-bottom: 20px;
+ margin-top: 20px;
+}
+.oppia-flag-modal-long-text input {
+ height: 15px;
+ margin-right: 5px;
+}
+.oppia-flag-modal-long-text label {
+ font-weight: lighter;
+ height: 15px;
+}
+.report-exploration-dialog-box-textarea {
+ height: 210px;
+ resize: none;
+}
diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/modals/flag-exploration-modal.component.html b/core/templates/pages/exploration-player-page/current-lesson-player/modals/flag-exploration-modal.component.html
index f3b6981734173..6fc6111ea92f2 100644
--- a/core/templates/pages/exploration-player-page/current-lesson-player/modals/flag-exploration-modal.component.html
+++ b/core/templates/pages/exploration-player-page/current-lesson-player/modals/flag-exploration-modal.component.html
@@ -52,23 +52,3 @@
{{ 'I18N_PLAYER_REPORT_MODAL_FOOTER_SUBMIT' | translate }}
-
-
diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/modals/flag-exploration-modal.component.ts b/core/templates/pages/exploration-player-page/current-lesson-player/modals/flag-exploration-modal.component.ts
index 0284966220d56..14b442801ef2c 100644
--- a/core/templates/pages/exploration-player-page/current-lesson-player/modals/flag-exploration-modal.component.ts
+++ b/core/templates/pages/exploration-player-page/current-lesson-player/modals/flag-exploration-modal.component.ts
@@ -21,6 +21,7 @@ import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-elements/confirm-or-cancel-modal.component';
import {FocusManagerService} from 'services/stateful/focus-manager.service';
import {PlayerPositionService} from '../../services/player-position.service';
+import './flag-exploration-modal.component.css';
export interface FlagExplorationModalResult {
report_type: boolean;
@@ -31,6 +32,7 @@ export interface FlagExplorationModalResult {
@Component({
selector: 'oppia-flag-exploration-modal',
templateUrl: './flag-exploration-modal.component.html',
+ styleUrls: ['./flag-exploration-modal.component.css'],
})
export class FlagExplorationModalComponent extends ConfirmOrCancelModal {
// These properties are initialized using Angular lifecycle hooks
diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/modals/refresher-exploration-confirmation-modal.component.css b/core/templates/pages/exploration-player-page/current-lesson-player/modals/refresher-exploration-confirmation-modal.component.css
new file mode 100644
index 0000000000000..ec2ad85473d35
--- /dev/null
+++ b/core/templates/pages/exploration-player-page/current-lesson-player/modals/refresher-exploration-confirmation-modal.component.css
@@ -0,0 +1,12 @@
+.oppia-learner-refresher-exploration-confirmation-body {
+ word-wrap: break-word;
+}
+
+.oppia-learner-refresher-exploration-confirmation-title {
+ color: #00786d;
+ font-family: 'Capriola', Roboto, arial, sans-serif;
+ font-size: 1.25em;
+ font-weight: bold;
+ padding-top: 20px;
+ text-align: center;
+}
diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/modals/refresher-exploration-confirmation-modal.component.html b/core/templates/pages/exploration-player-page/current-lesson-player/modals/refresher-exploration-confirmation-modal.component.html
index f5c0575c6dede..ada4203f790f6 100644
--- a/core/templates/pages/exploration-player-page/current-lesson-player/modals/refresher-exploration-confirmation-modal.component.html
+++ b/core/templates/pages/exploration-player-page/current-lesson-player/modals/refresher-exploration-confirmation-modal.component.html
@@ -18,18 +18,3 @@
-
-
diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/modals/refresher-exploration-confirmation-modal.component.ts b/core/templates/pages/exploration-player-page/current-lesson-player/modals/refresher-exploration-confirmation-modal.component.ts
index 73959fe6826a1..a452486504591 100644
--- a/core/templates/pages/exploration-player-page/current-lesson-player/modals/refresher-exploration-confirmation-modal.component.ts
+++ b/core/templates/pages/exploration-player-page/current-lesson-player/modals/refresher-exploration-confirmation-modal.component.ts
@@ -23,10 +23,12 @@ import {UrlInterpolationService} from 'domain/utilities/url-interpolation.servic
import {UrlService} from 'services/contextual/url.service';
import {WindowRef} from 'services/contextual/window-ref.service';
import {PageContextService} from 'services/page-context.service';
+import './refresher-exploration-confirmation-modal.component.css';
@Component({
selector: 'oppia-refresher-confirmation-modal',
templateUrl: './refresher-exploration-confirmation-modal.component.html',
+ styleUrls: ['./refresher-exploration-confirmation-modal.component.css'],
})
export class RefresherExplorationConfirmationModal extends ConfirmOrCancelModal {
confirmRedirectEventEmitter: EventEmitter = new EventEmitter();
diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/modals/switch-content-language-refresh-required-modal.component.css b/core/templates/pages/exploration-player-page/current-lesson-player/modals/switch-content-language-refresh-required-modal.component.css
new file mode 100644
index 0000000000000..ed99413f703d3
--- /dev/null
+++ b/core/templates/pages/exploration-player-page/current-lesson-player/modals/switch-content-language-refresh-required-modal.component.css
@@ -0,0 +1,3 @@
+.modal-body p {
+ line-height: 1.846;
+}
diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/modals/switch-content-language-refresh-required-modal.component.html b/core/templates/pages/exploration-player-page/current-lesson-player/modals/switch-content-language-refresh-required-modal.component.html
index 83eabb3a87e03..38dac65263eeb 100644
--- a/core/templates/pages/exploration-player-page/current-lesson-player/modals/switch-content-language-refresh-required-modal.component.html
+++ b/core/templates/pages/exploration-player-page/current-lesson-player/modals/switch-content-language-refresh-required-modal.component.html
@@ -17,9 +17,3 @@
-
-
diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/modals/switch-content-language-refresh-required-modal.component.ts b/core/templates/pages/exploration-player-page/current-lesson-player/modals/switch-content-language-refresh-required-modal.component.ts
index e9086f41b99e2..ccd532ad8e5e2 100644
--- a/core/templates/pages/exploration-player-page/current-lesson-player/modals/switch-content-language-refresh-required-modal.component.ts
+++ b/core/templates/pages/exploration-player-page/current-lesson-player/modals/switch-content-language-refresh-required-modal.component.ts
@@ -20,6 +20,7 @@
import {Component, Input} from '@angular/core';
import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
import {WindowRef} from 'services/contextual/window-ref.service';
+import './switch-content-language-refresh-required-modal.component.css';
export const INITIAL_CONTENT_LANGUAGE_CODE_URL_PARAM =
'initialContentLanguageCode';
@@ -28,7 +29,7 @@ export const INITIAL_CONTENT_LANGUAGE_CODE_URL_PARAM =
selector: 'switch-content-language-refresh-required-modal',
templateUrl:
'./switch-content-language-refresh-required-modal.component.html',
- styleUrls: [],
+ styleUrls: ['./switch-content-language-refresh-required-modal.component.css'],
})
export class SwitchContentLanguageRefreshRequiredModalComponent {
@Input() languageCode!: string;
diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/templates/lesson-information-card-modal.component.css b/core/templates/pages/exploration-player-page/current-lesson-player/templates/lesson-information-card-modal.component.css
index cb3dac223788e..4da69306b6213 100644
--- a/core/templates/pages/exploration-player-page/current-lesson-player/templates/lesson-information-card-modal.component.css
+++ b/core/templates/pages/exploration-player-page/current-lesson-player/templates/lesson-information-card-modal.component.css
@@ -27,6 +27,15 @@
padding-bottom: 10px;
}
+.oppia-lesson-info-card .oppia-story-title {
+ color: #3b3b3b;
+ font-family: "Capriola", "Roboto", Arial, sans-serif;
+ font-size: 13px;
+ margin-bottom: -10px;
+ padding: 20px 20px 0;
+ text-align: left;
+}
+
.oppia-info-card-exploration-contributors-profile img {
border: 3px solid #fff;
height: 22px;
diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/templates/lesson-information-card-modal.component.spec.ts b/core/templates/pages/exploration-player-page/current-lesson-player/templates/lesson-information-card-modal.component.spec.ts
index 134022c64cd53..aa9e6694ecc98 100644
--- a/core/templates/pages/exploration-player-page/current-lesson-player/templates/lesson-information-card-modal.component.spec.ts
+++ b/core/templates/pages/exploration-player-page/current-lesson-player/templates/lesson-information-card-modal.component.spec.ts
@@ -29,7 +29,10 @@ import {
import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
import {TranslateService} from '@ngx-translate/core';
import {MockTranslateService} from '../../../../components/forms/schema-based-editors/integration-tests/schema-based-editors.integration.spec';
-import {ExplorationRatings} from '../../../../domain/summary/learner-exploration-summary.model';
+import {
+ ExplorationRatings,
+ TranslatableExplorationMetadataField,
+} from '../../../../domain/summary/learner-exploration-summary.model';
import {UrlService} from '../../../../services/contextual/url.service';
import {UserService} from '../../../../services/user.service';
import {WindowRef} from '../../../../services/contextual/window-ref.service';
@@ -300,6 +303,15 @@ describe('Lesson Information card modal component', () => {
expect(hackyExpDescTranslationIsDisplayed).toBe(false);
});
+ it('should not display hacky translation when metadata field is translated by backend', () => {
+ componentInstance.expInfo.translated_metadata_fields = [
+ TranslatableExplorationMetadataField.TITLE,
+ TranslatableExplorationMetadataField.OBJECTIVE,
+ ];
+ expect(componentInstance.isHackyExpTitleTranslationDisplayed()).toBe(false);
+ expect(componentInstance.isHackyExpDescTranslationDisplayed()).toBe(false);
+ });
+
it("should determine if exploration isn't private upon initialization", () => {
componentInstance.expInfo.status = 'public';
diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/templates/lesson-information-card-modal.component.ts b/core/templates/pages/exploration-player-page/current-lesson-player/templates/lesson-information-card-modal.component.ts
index 1467281c32fba..7a60de5a9a259 100644
--- a/core/templates/pages/exploration-player-page/current-lesson-player/templates/lesson-information-card-modal.component.ts
+++ b/core/templates/pages/exploration-player-page/current-lesson-player/templates/lesson-information-card-modal.component.ts
@@ -17,11 +17,14 @@
*/
import {Clipboard} from '@angular/cdk/clipboard';
-import {Component, ViewEncapsulation} from '@angular/core';
+import {Component} from '@angular/core';
import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-elements/confirm-or-cancel-modal.component';
import {StateCard} from 'domain/state_card/state-card.model';
-import {LearnerExplorationSummaryBackendDict} from 'domain/summary/learner-exploration-summary.model';
+import {
+ LearnerExplorationSummaryBackendDict,
+ TranslatableExplorationMetadataField,
+} from 'domain/summary/learner-exploration-summary.model';
import {UrlService} from 'services/contextual/url.service';
import {UserService} from 'services/user.service';
import {WindowRef} from 'services/contextual/window-ref.service';
@@ -55,7 +58,6 @@ const EXPLORATION_STATUS_PRIVATE = 'private';
selector: 'oppia-lesson-information-card-modal',
templateUrl: './lesson-information-card-modal.component.html',
styleUrls: ['./lesson-information-card-modal.component.css'],
- encapsulation: ViewEncapsulation.None,
})
export class LessonInformationCardModalComponent extends ConfirmOrCancelModal {
// These properties below are initialized using Angular lifecycle hooks
@@ -237,19 +239,27 @@ export class LessonInformationCardModalComponent extends ConfirmOrCancelModal {
return this.urlInterpolationService.getStaticImageUrl(imageUrl);
}
+ private isMetadataFieldTranslatedByBackend(
+ field: TranslatableExplorationMetadataField
+ ): boolean {
+ return (this.expInfo.translated_metadata_fields || []).includes(field);
+ }
+
isHackyExpTitleTranslationDisplayed(): boolean {
- return (
- this.i18nLanguageCodeService.isHackyTranslationAvailable(
- this.expTitleTranslationKey
- ) && !this.i18nLanguageCodeService.isCurrentLanguageEnglish()
+ return this.i18nLanguageCodeService.isHackyTranslationDisplayed(
+ this.expTitleTranslationKey,
+ this.isMetadataFieldTranslatedByBackend(
+ TranslatableExplorationMetadataField.TITLE
+ )
);
}
isHackyExpDescTranslationDisplayed(): boolean {
- return (
- this.i18nLanguageCodeService.isHackyTranslationAvailable(
- this.expDescTranslationKey
- ) && !this.i18nLanguageCodeService.isCurrentLanguageEnglish()
+ return this.i18nLanguageCodeService.isHackyTranslationDisplayed(
+ this.expDescTranslationKey,
+ this.isMetadataFieldTranslatedByBackend(
+ TranslatableExplorationMetadataField.OBJECTIVE
+ )
);
}
diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/templates/progress-reminder-modal.component.css b/core/templates/pages/exploration-player-page/current-lesson-player/templates/progress-reminder-modal.component.css
index ea47466e80b24..2a05f42192192 100644
--- a/core/templates/pages/exploration-player-page/current-lesson-player/templates/progress-reminder-modal.component.css
+++ b/core/templates/pages/exploration-player-page/current-lesson-player/templates/progress-reminder-modal.component.css
@@ -130,7 +130,7 @@
width: 240px;
}
-oppia-progress-reminder-modal .restart-button:focus,
-oppia-progress-reminder-modal .resume-button:focus {
+.restart-button:focus,
+.resume-button:focus {
box-shadow: 0 0 2px 2px rgba(0, 0, 0, 0.5);
}
diff --git a/core/templates/pages/exploration-player-page/current-lesson-player/templates/progress-reminder-modal.component.ts b/core/templates/pages/exploration-player-page/current-lesson-player/templates/progress-reminder-modal.component.ts
index ad1a925ef9c3f..8b53764743af3 100644
--- a/core/templates/pages/exploration-player-page/current-lesson-player/templates/progress-reminder-modal.component.ts
+++ b/core/templates/pages/exploration-player-page/current-lesson-player/templates/progress-reminder-modal.component.ts
@@ -16,7 +16,7 @@
* @fileoverview Component for the progress reminder modal.
*/
-import {Component, ViewEncapsulation} from '@angular/core';
+import {Component} from '@angular/core';
import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-elements/confirm-or-cancel-modal.component';
import {I18nLanguageCodeService} from 'services/i18n-language-code.service';
@@ -29,7 +29,6 @@ const CHECKPOINT_STATUS_IN_PROGRESS = 'in-progress';
selector: 'oppia-progress-reminder-modal',
templateUrl: './progress-reminder-modal.component.html',
styleUrls: ['./progress-reminder-modal.component.css'],
- encapsulation: ViewEncapsulation.None,
})
export class ProgressReminderModalComponent extends ConfirmOrCancelModal {
// These properties below are initialized using Angular lifecycle hooks,
diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/card-navigation-control.component.css b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/card-navigation-control.component.css
index 3e46b6b1ce4fb..4ef8af6cdf483 100644
--- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/card-navigation-control.component.css
+++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/card-navigation-control.component.css
@@ -20,6 +20,11 @@ oppia-progress-tracker {
text-transform: uppercase;
}
+.oppia-learner-view-card-content .oppia-learner-confirm-button {
+ margin-bottom: 10px;
+ margin-left: 10px;
+}
+
.card-navigation-control .continue-button {
margin-bottom: 8px;
margin-left: 8px;
diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/conversation-display.component.css b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/conversation-display.component.css
index 4c0c96c12f024..de0fd3f429e63 100644
--- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/conversation-display.component.css
+++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/conversation-display.component.css
@@ -4,20 +4,15 @@
https://rtlcss.com/learn/usage-guide/control-directives .
*/
-oppia-conversation-display .oppia-learner-view-card-content .oppia-learner-confirm-button {
- margin-bottom: 10px;
- margin-left: 10px;
-}
-
-oppia-conversation-display .conversation-skin-oppia-completed-state-card,
-oppia-conversation-display .oppia-learner-view-card-top-section {
+.conversation-skin-oppia-completed-state-card,
+.oppia-learner-view-card-top-section {
background-color: #fff;
border-radius: 0 16px 16px;
padding: 20px;
position: relative;
}
-oppia-conversation-display .oppia-learner-view-card {
+.oppia-learner-view-card {
border-radius: 2px;
margin-left: 40px ;
max-width: 100vw;
@@ -27,42 +22,42 @@ oppia-conversation-display .oppia-learner-view-card {
text-align: left;
}
-oppia-conversation-display .oppia-learner-view-card-top-content {
+.oppia-learner-view-card-top-content {
border-top-left-radius: 0;
padding: 12px;
width: 100%;
}
-oppia-conversation-display .oppia-content-language-selector-container {
+.oppia-content-language-selector-container {
float: right;
margin-bottom: 12px;
margin-right: 0;
}
-oppia-conversation-display .oppia-content-language-selector-icon {
+.oppia-content-language-selector-icon {
color: rgb(75, 74, 74);
font-size: 25px;
margin-right: 4px;
}
-oppia-conversation-display .oppia-content-language-selector {
+.oppia-content-language-selector {
border-radius: 9px;
font-size: 15px;
margin-left: 5px;
padding-left: 3px;
}
-oppia-conversation-display .oppia-exploration-language {
+.oppia-exploration-language {
padding: 0 5px 0 5px;
}
-oppia-conversation-display .oppia-learner-view-card-content {
+.oppia-learner-view-card-content {
word-wrap: break-word;
}
-oppia-conversation-display .conversation-skin-instruction-disabled {
+.conversation-skin-instruction-disabled {
color: gray;
}
-oppia-conversation-display .conversation-skin-oppia-completed-state-card .conversation-skin-oppia-avatar,
-oppia-conversation-display .oppia-learner-view-card-top-section .conversation-skin-oppia-avatar {
+.conversation-skin-oppia-completed-state-card .conversation-skin-oppia-avatar,
+.oppia-learner-view-card-top-section .conversation-skin-oppia-avatar {
border: 2px solid #c55f45;
border-radius: 50%;
height: 100px;
@@ -71,7 +66,7 @@ oppia-conversation-display .oppia-learner-view-card-top-section .conversation-sk
width: 100px;
}
-oppia-conversation-display .left-side-curve {
+.left-side-curve {
height: 49px;
left: -37px;
position: absolute;
@@ -79,17 +74,17 @@ oppia-conversation-display .left-side-curve {
width: 39px;
}
-oppia-conversation-display .oppia-learner-view-card-top-content p:not(:last-child) {
+.oppia-learner-view-card-top-content p:not(:last-child) {
line-height: 1.5;
margin-bottom: 18px;
}
/* This should match the value defined in AUDIO_HIGHLIGHT_CSS_CLASS. */
-oppia-conversation-display .conversation-skin-audio-highlight {
+.conversation-skin-audio-highlight {
border: 3px solid #f3d140;
}
-oppia-conversation-display .conversation-skin-future-tutor-card {
+.conversation-skin-future-tutor-card {
background: #fff;
border-radius: 2px;
margin-left: 87px;
@@ -97,61 +92,61 @@ oppia-conversation-display .conversation-skin-future-tutor-card {
text-align: left;
}
-oppia-conversation-display .conversation-skin-tutor-card-middle-section .conversation-skin-learner-answer-content {
+.conversation-skin-tutor-card-middle-section .conversation-skin-learner-answer-content {
background-color: #fff8e5;
}
-oppia-conversation-display .conversation-skin-tutor-card-middle-section .conversation-skin-oppia-feedback-content {
+.conversation-skin-tutor-card-middle-section .conversation-skin-oppia-feedback-content {
background-color: #fff;
}
-oppia-conversation-display .conversation-skin-tutor-card-middle-section .conversation-skin-oppia-feedback-content::before,
-oppia-conversation-display .conversation-skin-tutor-card-middle-section .conversation-skin-learner-answer-content::before {
+.conversation-skin-tutor-card-middle-section .conversation-skin-oppia-feedback-content::before,
+.conversation-skin-tutor-card-middle-section .conversation-skin-learner-answer-content::before {
display: none;
}
-oppia-conversation-display .check-mark-hidden {
+.check-mark-hidden {
display: none;
}
-oppia-conversation-display .check-mark-skipped {
+.check-mark-skipped {
opacity: 0;
}
-oppia-conversation-display .end-chapter-check-mark {
+.end-chapter-check-mark {
transition-duration: 0.5s;
transition-property: opacity;
}
-oppia-conversation-display .card-content-hidden {
+.card-content-hidden {
opacity: 0;
}
-oppia-conversation-display .new-conversation-skin-responses-dropdown-container {
+.new-conversation-skin-responses-dropdown-container {
cursor: pointer;
flex-shrink: 0;
margin: 20px 0;
}
-oppia-conversation-display .response-history-container {
+.response-history-container {
align-items: center;
display: flex;
}
-oppia-conversation-display .response-history-container hr {
+.response-history-container hr {
background-color: #000;
border: none;
height: 2px;
width: 23%;
}
-oppia-conversation-display .conversation-skin-responses-dropdown-text {
+.conversation-skin-responses-dropdown-text {
color: #000;
font-size: 16px;
padding: 0 10px;
}
-oppia-conversation-display .conversation-skin-responses-dropdown-icon {
+.conversation-skin-responses-dropdown-icon {
font-size: 13px;
margin-bottom: -2px;
-moz-transition: all 0.2s;
@@ -160,7 +155,7 @@ oppia-conversation-display .conversation-skin-responses-dropdown-icon {
transition: all 0.2s;
}
-oppia-conversation-display .conversation-skin-responses-dropdown-icon-rotated {
+.conversation-skin-responses-dropdown-icon-rotated {
margin-bottom: -2px;
-moz-transform: rotate(90deg);
-o-transform: rotate(90deg);
@@ -169,31 +164,31 @@ oppia-conversation-display .conversation-skin-responses-dropdown-icon-rotated {
transition: transform 0.2s ease-in-out;
}
-oppia-conversation-display .conversation-skin-inline-interaction {
+.conversation-skin-inline-interaction {
border-radius: 8px;
margin-left: 40px;
padding: 10px;
position: relative;
}
-oppia-conversation-display .conversation-skin-inline-interaction .form-control {
+.conversation-skin-inline-interaction .form-control {
border: none;
outline: none;
resize: none;
}
-oppia-conversation-display input.form-control:focus {
+input.form-control:focus {
border: none;
box-shadow: none;
outline: none;
}
-oppia-conversation-display .conversation-skin-inline-interaction .conversation-skin-user-avatar {
+.conversation-skin-inline-interaction .conversation-skin-user-avatar {
left: -20px;
top: 14px;
}
-oppia-conversation-display .conversation-skin-milestone-message {
+.conversation-skin-milestone-message {
align-items: center;
display: flex;
justify-content: center;
@@ -201,11 +196,11 @@ oppia-conversation-display .conversation-skin-milestone-message {
padding: 0 20px;
}
-oppia-conversation-display .conversation-skin-milestone-message.milestone-progress-message {
+.conversation-skin-milestone-message.milestone-progress-message {
flex-direction: column;
}
-oppia-conversation-display .conversation-skin-milestone-message .milestone-message-star-container {
+.conversation-skin-milestone-message .milestone-message-star-container {
align-items: center;
display: flex;
flex-direction: row;
@@ -215,31 +210,31 @@ oppia-conversation-display .conversation-skin-milestone-message .milestone-messa
width: 72px;
}
-oppia-conversation-display .conversation-skin-milestone-message .milestone-message-star-container .star-icon {
+.conversation-skin-milestone-message .milestone-message-star-container .star-icon {
position: absolute;
transform: scale(1.5);
}
-oppia-conversation-display .conversation-skin-milestone-message .completed-chapter-count {
+.conversation-skin-milestone-message .completed-chapter-count {
font-weight: 500;
margin-top: 9px;
position: relative;
}
-oppia-conversation-display .conversation-skin-milestone-message .chapters-left-count {
+.conversation-skin-milestone-message .chapters-left-count {
font-weight: 500;
margin-top: 9px;
position: relative;
}
-oppia-conversation-display .conversation-skin-milestone-message .milestone-message-text {
+.conversation-skin-milestone-message .milestone-message-text {
font-size: 1.1em;
font-weight: 800;
margin-top: 9px;
text-align: center;
}
-oppia-conversation-display .conversation-skin-milestone-message .milestone-message-progress-bar-container {
+.conversation-skin-milestone-message .milestone-message-progress-bar-container {
align-items: center;
display: flex;
justify-content: center;
@@ -247,75 +242,75 @@ oppia-conversation-display .conversation-skin-milestone-message .milestone-messa
width: 100%;
}
-oppia-conversation-display .milestone-message-progress-bar-container .milestone-progress-bar-outer {
+.milestone-message-progress-bar-container .milestone-progress-bar-outer {
border: 2px solid #00645c;
height: 20px;
margin-right: 30px;
width: 300px;
}
-oppia-conversation-display .milestone-message-progress-bar-container .milestone-progress-bar-inner {
+.milestone-message-progress-bar-container .milestone-progress-bar-inner {
background: #a6dacf;
height: 100%;
}
-oppia-conversation-display .milestone-message-progress-bar-container .milestone-message-star-container {
+.milestone-message-progress-bar-container .milestone-message-star-container {
margin-bottom: 9px;
margin-left: -57px;
}
-oppia-conversation-display .oppia-conversation-skin-inline-interaction {
+.oppia-conversation-skin-inline-interaction {
opacity: 0.8;
}
-oppia-conversation-display .oppia-conversation-skin-instruction-container {
+.oppia-conversation-skin-instruction-container {
background-color: #fff;
border-radius: 8px;
padding: 6px 12px;
}
-oppia-conversation-display .oppia-no-interaction-text {
+.oppia-no-interaction-text {
color: #f00;
}
-oppia-conversation-display .conversation-skin-oppia-completed-state-card .conversation-skin-oppia-feedback-content {
+.conversation-skin-oppia-completed-state-card .conversation-skin-oppia-feedback-content {
color: #888;
font-style: italic;
}
-oppia-conversation-display .checkpoint-celebration-modal-container {
+.checkpoint-celebration-modal-container {
position: absolute;
}
-oppia-conversation-display .oppia-mcq-question-description-text {
+.oppia-mcq-question-description-text {
display: block;
padding: 0 20px;
}
@media screen and (max-width: 959px) {
- oppia-conversation-display .oppia-learner-view-card .instructions-button {
+ .oppia-learner-view-card .instructions-button {
background-color: #0d48a1;
color: #fff;
padding: 6px 12px;
white-space: normal;
}
- oppia-conversation-display .oppia-learner-view-card button.mat-button.mat-default-theme.instructions-button:focus,
- oppia-conversation-display .oppia-learner-view-card button.mat-button.mat-default-theme.instructions-button:hover {
+ .oppia-learner-view-card button.mat-button.mat-default-theme.instructions-button:focus,
+ .oppia-learner-view-card button.mat-button.mat-default-theme.instructions-button:hover {
background-color: #115fd4;
}
}
@media (max-width: 480px) {
- oppia-conversation-display .conversation-skin-oppia-completed-state-card .conversation-skin-oppia-avatar,
- oppia-conversation-display .oppia-learner-view-card-top-section .conversation-skin-oppia-avatar {
+ .conversation-skin-oppia-completed-state-card .conversation-skin-oppia-avatar,
+ .oppia-learner-view-card-top-section .conversation-skin-oppia-avatar {
height: 34px;
left: -38px;
top: 7px;
width: 34px;
}
- oppia-conversation-display .left-side-curve {
+ .left-side-curve {
height: 29px;
left: -18px;
position: absolute;
@@ -324,26 +319,26 @@ oppia-conversation-display .oppia-mcq-question-description-text {
}
- oppia-conversation-display .oppia-learner-view-card {
+ .oppia-learner-view-card {
margin-left: 40px;
margin-top: 30px;
padding-bottom: 30px;
}
- oppia-conversation-display .conversation-skin-responses-dropdown-text {
+ .conversation-skin-responses-dropdown-text {
font-size: 14px;
}
}
@media (min-width: 481px) and (max-width: 768px) {
- oppia-conversation-display .conversation-skin-oppia-completed-state-card .conversation-skin-oppia-avatar,
- oppia-conversation-display .oppia-learner-view-card-top-section .conversation-skin-oppia-avatar {
+ .conversation-skin-oppia-completed-state-card .conversation-skin-oppia-avatar,
+ .oppia-learner-view-card-top-section .conversation-skin-oppia-avatar {
height: 64px;
left: -68px;
width: 64px;
}
- oppia-conversation-display .left-side-curve {
+ .left-side-curve {
height: 39px;
left: -28px;
position: absolute;
@@ -351,26 +346,26 @@ oppia-conversation-display .oppia-mcq-question-description-text {
width: 29px;
}
- oppia-conversation-display .oppia-learner-view-card {
+ .oppia-learner-view-card {
margin-left: 55px;
margin-top: 30px;
padding-bottom: 30px;
}
- oppia-conversation-display .oppia-conversation-display .oppia-learner-view-card-top-section {
+ .oppia-learner-view-card-top-section {
width: 100%;
}
}
@media (min-width: 769px) and (max-width: 1023px) {
- oppia-conversation-display .conversation-skin-oppia-completed-state-card .conversation-skin-oppia-avatar,
- oppia-conversation-display .oppia-learner-view-card-top-section .conversation-skin-oppia-avatar {
+ .conversation-skin-oppia-completed-state-card .conversation-skin-oppia-avatar,
+ .oppia-learner-view-card-top-section .conversation-skin-oppia-avatar {
height: 64px;
left: -68px;
width: 64px;
}
- oppia-conversation-display .left-side-curve {
+ .left-side-curve {
height: 39px;
left: -28px;
position: absolute;
@@ -379,13 +374,13 @@ oppia-conversation-display .oppia-mcq-question-description-text {
}
}
-oppia-conversation-display .conversation-skin-oppia-completed-state-card {
+.conversation-skin-oppia-completed-state-card {
margin-top: 35px;
padding: 32px;
}
@media (min-width: 1024px) {
- oppia-conversation-display .supplemental-history-response {
+ .supplemental-history-response {
width: 75%;
}
}
diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/display-new-hint-modal.component.css b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/display-new-hint-modal.component.css
index 66ce70f143c08..eb8ee1c488bec 100644
--- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/display-new-hint-modal.component.css
+++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/display-new-hint-modal.component.css
@@ -4,7 +4,7 @@
https://rtlcss.com/learn/usage-guide/control-directives .
*/
-oppia-new-display-hint-modal .oppia-learner-hint-and-solution-modal-title {
+.oppia-learner-hint-and-solution-modal-title {
color: #c55f45;
font-family: 'Capriola', 'Roboto', 'Arial', sans-serif;
font-size: 1.25em;
@@ -13,24 +13,24 @@ oppia-new-display-hint-modal .oppia-learner-hint-and-solution-modal-title {
text-align: center;
}
-oppia-new-display-hint-modal .oppia-learner-hint-and-solution-modal-body {
+.oppia-learner-hint-and-solution-modal-body {
margin: 20px 0;
word-wrap: break-word;
}
-oppia-new-display-hint-modal .modal-body {
+.modal-body {
max-height: calc(85vh - 125px);
overflow-y: auto;
}
-oppia-new-display-hint-modal .oppia-learner-solution-container {
+.oppia-learner-solution-container {
align-items: center;
display: flex;
justify-content: space-between;
padding: 16px 20px;
}
-oppia-new-display-hint-modal .oppia-learner-solution-container > p {
+.oppia-learner-solution-container > p {
color: #c55f45;
left: 50%;
margin-top: 10px;
@@ -38,17 +38,17 @@ oppia-new-display-hint-modal .oppia-learner-solution-container > p {
transform: translateX(-50%);
}
-oppia-new-display-hint-modal .mat-button.hint-display-modal-got-it-button {
+.mat-button.hint-display-modal-got-it-button {
background-color: #00645c !important;
color: white;
font-weight: 700;
}
-oppia-new-display-hint-modal .mat-button.hint-display-modal-got-it-button:focus {
+.mat-button.hint-display-modal-got-it-button:focus {
outline: none !important;
}
-oppia-new-display-hint-modal .hint-modal-control-btn {
+.hint-modal-control-btn {
border: 1px solid #00645c !important;
color: #00645c;
font-weight: 700;
@@ -56,7 +56,7 @@ oppia-new-display-hint-modal .hint-modal-control-btn {
}
@media (max-width: 480px) {
- oppia-new-display-hint-modal .oppia-learner-solution-container > p {
+ .oppia-learner-solution-container > p {
margin-left: 10px;
}
}
diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/new-input-response-pair.component.css b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/new-input-response-pair.component.css
index c432cbfa78450..004fcaac4d260 100644
--- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/new-input-response-pair.component.css
+++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/new-input-response-pair.component.css
@@ -4,55 +4,52 @@
https://rtlcss.com/learn/usage-guide/control-directives .
*/
-oppia-new-input-response-pair .conversation-skin-learner-answer-container,
-oppia-new-input-response-pair .conversation-skin-oppia-feedback-container {
+.conversation-skin-learner-answer-container,
+.conversation-skin-oppia-feedback-container {
padding: 0 20px;
position: relative;
}
-oppia-new-input-response-pair .conversation-skin-oppia-feedback-container .conversation-skin-oppia-avatar {
+.conversation-skin-oppia-feedback-container .conversation-skin-oppia-avatar {
height: 100px;
left: -104px;
top: 2px;
width: 100px;
}
-oppia-new-input-response-pair .conversation-skin-oppia-feedback-container {
+.conversation-skin-oppia-feedback-container {
background-color: #fff;
border-radius: 0 16px 16px;
margin-top: 10px;
width: 70%;
}
-oppia-new-input-response-pair .conversation-skin-learner-answer-content {
+.conversation-skin-learner-answer-content {
border-bottom-right-radius: 0;
}
-oppia-new-input-response-pair .conversation-skin-oppia-feedback-content,
-oppia-new-input-response-pair .conversation-skin-learner-answer-content {
+.conversation-skin-oppia-feedback-content {
padding: 12px;
word-wrap: break-word;
}
-oppia-new-input-response-pair .conversation-skin-oppia-feedback-content,
-oppia-new-input-response-pair .conversation-skin-help-card-content {
+.conversation-skin-oppia-feedback-content,
+.conversation-skin-help-card-content {
border-top-left-radius: 0;
}
-oppia-new-input-response-pair .conversation-skin-learner-answer-container {
+.conversation-skin-learner-answer-container {
background-color: #fff8e5;
border-radius: 16px 0 16px 16px;
margin-top: 10px;
text-align: left;
width: 70%;
}
-
-oppia-new-input-response-pair .answer-wrapper {
+.answer-wrapper {
display: flex;
justify-content: flex-end;
}
-
-oppia-new-input-response-pair .answer-wrapper .right-side-curve {
+.answer-wrapper .right-side-curve {
height: 49px;
position: absolute;
right: -36px;
@@ -60,51 +57,48 @@ oppia-new-input-response-pair .answer-wrapper .right-side-curve {
transform: scaleX(-1);
width: 39px;
}
-
-oppia-new-input-response-pair .feedback-wrapper .left-side-curve {
+.feedback-wrapper .left-side-curve {
height: 49px;
left: -37px;
position: absolute;
top: -9px;
width: 39px;
}
-
-oppia-new-input-response-pair .conversation-skin-learner-answer-container .conversation-skin-user-avatar {
+.conversation-skin-learner-answer-container .conversation-skin-user-avatar {
border: 2px solid #094142;
height: 100px;
right: -104px;
top: 3px;
width: 100px;
}
-
-oppia-new-input-response-pair .oppia-popover {
+.oppia-popover {
cursor: pointer;
display: inline-block;
}
-oppia-new-input-response-pair .oppia-answer-popup-container .oppia-answer-popup {
+
+.oppia-answer-popup-container .oppia-answer-popup {
max-height: 300px;
max-width: 300px;
}
-oppia-new-input-response-pair .conversation-skin-oppia-feedback.ng-enter {
+.conversation-skin-oppia-feedback.ng-enter {
opacity: 0;
-webkit-transition: all .5s;
transition: all .5s;
}
-
-oppia-new-input-response-pair .conversation-skin-oppia-feedback.ng-enter.ng-enter-active {
+.conversation-skin-oppia-feedback.ng-enter.ng-enter-active {
opacity: 1;
}
@media (max-width: 480px) {
- oppia-new-input-response-pair .conversation-skin-oppia-feedback-container .conversation-skin-oppia-avatar {
+ .conversation-skin-oppia-feedback-container .conversation-skin-oppia-avatar {
height: 34px;
left: -37px;
top: 5px;
width: 34px;
}
- oppia-new-input-response-pair .answer-wrapper .right-side-curve {
+ .answer-wrapper .right-side-curve {
height: 29px;
position: absolute;
right: -17px;
@@ -113,7 +107,7 @@ oppia-new-input-response-pair .conversation-skin-oppia-feedback.ng-enter.ng-ente
width: 19px;
}
- oppia-new-input-response-pair .conversation-skin-learner-answer-container .conversation-skin-user-avatar {
+ .conversation-skin-learner-answer-container .conversation-skin-user-avatar {
border: 2px solid #094142;
height: 34px;
right: -39px;
@@ -121,7 +115,7 @@ oppia-new-input-response-pair .conversation-skin-oppia-feedback.ng-enter.ng-ente
width: 34px;
}
- oppia-new-input-response-pair .feedback-wrapper .left-side-curve {
+ .feedback-wrapper .left-side-curve {
height: 29px;
left: -18px;
position: absolute;
@@ -131,14 +125,14 @@ oppia-new-input-response-pair .conversation-skin-oppia-feedback.ng-enter.ng-ente
}
@media (min-width: 481px) and (max-width: 768px) {
- oppia-new-input-response-pair .conversation-skin-oppia-feedback-container .conversation-skin-oppia-avatar {
+ .conversation-skin-oppia-feedback-container .conversation-skin-oppia-avatar {
height: 64px;
left: -68px;
top: 5px;
width: 64px;
}
- oppia-new-input-response-pair .feedback-wrapper .left-side-curve {
+ .feedback-wrapper .left-side-curve {
height: 39px;
left: -27px;
position: absolute;
@@ -146,7 +140,7 @@ oppia-new-input-response-pair .conversation-skin-oppia-feedback.ng-enter.ng-ente
width: 29px;
}
- oppia-new-input-response-pair .answer-wrapper .right-side-curve {
+ .answer-wrapper .right-side-curve {
height: 39px;
position: absolute;
right: -28px;
@@ -155,7 +149,7 @@ oppia-new-input-response-pair .conversation-skin-oppia-feedback.ng-enter.ng-ente
width: 29px;
}
- oppia-new-input-response-pair .conversation-skin-learner-answer-container .conversation-skin-user-avatar {
+ .conversation-skin-learner-answer-container .conversation-skin-user-avatar {
height: 64px;
right: -69px;
top: 3px;
@@ -164,14 +158,14 @@ oppia-new-input-response-pair .conversation-skin-oppia-feedback.ng-enter.ng-ente
}
@media (min-width: 769px) and (max-width: 1023px) {
- oppia-new-input-response-pair .conversation-skin-oppia-feedback-container .conversation-skin-oppia-avatar {
+ .conversation-skin-oppia-feedback-container .conversation-skin-oppia-avatar {
height: 64px;
left: -68px;
top: 5px;
width: 64px;
}
- oppia-new-input-response-pair .feedback-wrapper .left-side-curve {
+ .feedback-wrapper .left-side-curve {
height: 39px;
left: -27px;
position: absolute;
@@ -179,7 +173,7 @@ oppia-new-input-response-pair .conversation-skin-oppia-feedback.ng-enter.ng-ente
width: 29px;
}
- oppia-new-input-response-pair .answer-wrapper .right-side-curve {
+ .answer-wrapper .right-side-curve {
height: 39px;
position: absolute;
right: -28px;
@@ -188,7 +182,7 @@ oppia-new-input-response-pair .conversation-skin-oppia-feedback.ng-enter.ng-ente
width: 29px;
}
- oppia-new-input-response-pair .conversation-skin-learner-answer-container .conversation-skin-user-avatar {
+ .conversation-skin-learner-answer-container .conversation-skin-user-avatar {
height: 64px;
right: -69px;
top: 3px;
diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/new-ratings-and-recommendations.component.css b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/new-ratings-and-recommendations.component.css
index 58fe3f252493c..125c213803299 100644
--- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/new-ratings-and-recommendations.component.css
+++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/new-ratings-and-recommendations.component.css
@@ -1,4 +1,4 @@
-oppia-new-ratings-and-recommendations .oppia-sign-up-button {
+.oppia-sign-up-button {
background-color: #00645c;
border-radius: 15px;
font-size: 1em;
@@ -7,12 +7,12 @@ oppia-new-ratings-and-recommendations .oppia-sign-up-button {
width: 154px;
}
-oppia-new-ratings-and-recommendations .conversation-skin-final-summary {
+.conversation-skin-final-summary {
margin-bottom: 25px;
margin-top: 20px;
}
-oppia-new-ratings-and-recommendations .conversation-skin-final-ratings-header {
+.conversation-skin-final-ratings-header {
color: #333;
font-family: 'Capriola', Arial, sans-serif;
font-size: 20px;
@@ -21,32 +21,32 @@ oppia-new-ratings-and-recommendations .conversation-skin-final-ratings-header {
text-align: center;
}
-oppia-new-ratings-and-recommendations .conversation-skin-final-ratings {
+.conversation-skin-final-ratings {
margin: 0 auto 60px;
text-align: center;
}
-oppia-new-ratings-and-recommendations .conversation-skin-final-ratings-display {
+.conversation-skin-final-ratings-display {
color: #064b46;
letter-spacing: 10px;
}
-oppia-new-ratings-and-recommendations .conversation-skin-final-recommendations-header {
+.conversation-skin-final-recommendations-header {
color: #333;
font-family: "Capriola", "Roboto", Arial, sans-serif;
font-size: 20px;
font-weight: 400;
}
-oppia-new-ratings-and-recommendations .conversation-skin-final-recommendations-header-container {
+.conversation-skin-final-recommendations-header-container {
text-align: center;
}
-oppia-new-ratings-and-recommendations .conversation-skin-recommended-activities-container {
+.conversation-skin-recommended-activities-container {
padding-top: 10px;
}
-oppia-new-ratings-and-recommendations .conversation-skin-back-to-collection-container {
+.conversation-skin-back-to-collection-container {
border: 1px solid #00645c;
border-radius: 40px;
margin: auto;
@@ -55,11 +55,11 @@ oppia-new-ratings-and-recommendations .conversation-skin-back-to-collection-cont
width: 35%;
}
-oppia-new-ratings-and-recommendations .conversation-skin-back-to-collection {
+.conversation-skin-back-to-collection {
color: #064b46;
}
-oppia-new-ratings-and-recommendations .conversation-skin-login-container {
+.conversation-skin-login-container {
background-color: #fff;
border-bottom: 2px solid #bbb;
border-top: 2px solid #bbb;
@@ -71,40 +71,40 @@ oppia-new-ratings-and-recommendations .conversation-skin-login-container {
width: 100%;
}
-oppia-new-ratings-and-recommendations .conversation-skin-login-container .conversation-skin-login-text {
+.conversation-skin-login-container .conversation-skin-login-text {
font-size: 0.8em;
}
-oppia-new-ratings-and-recommendations .conversation-skin-login-container .conversation-skin-congratulations-text {
+.conversation-skin-login-container .conversation-skin-congratulations-text {
font-size: 1.2em;
font-weight: 500;
}
-oppia-new-ratings-and-recommendations .conversation-skin-login-container .conversation-skin-hide-sign-up-section-text {
+.conversation-skin-login-container .conversation-skin-hide-sign-up-section-text {
color: #00645c;
cursor: pointer;
text-decoration: underline;
}
-oppia-new-ratings-and-recommendations .conversation-skin-login-container .conversation-skin-login-button-text {
+.conversation-skin-login-container .conversation-skin-login-button-text {
color: #00645c;
cursor: pointer;
text-decoration: underline;
}
-oppia-new-ratings-and-recommendations .conversation-skin-login-container .conversation-skin-login-secondary-options {
+.conversation-skin-login-container .conversation-skin-login-secondary-options {
display: flex;
justify-content: space-between;
}
-oppia-new-ratings-and-recommendations .conversation-skin-login-secondary-options button {
+.conversation-skin-login-secondary-options button {
background: none;
border: none;
cursor: default;
font-size: 0.8em;
}
-oppia-new-ratings-and-recommendations .ratings-and-recommendations-exp-summary-tiles-container {
+.ratings-and-recommendations-exp-summary-tiles-container {
align-items: center;
display: flex;
flex-direction: column;
@@ -114,7 +114,7 @@ oppia-new-ratings-and-recommendations .ratings-and-recommendations-exp-summary-t
padding-top: 24px;
}
-oppia-new-ratings-and-recommendations .community-library-btn {
+.community-library-btn {
align-items: center;
background-color: #e8f1f0;
border: 1px solid #00645c;
@@ -132,17 +132,17 @@ oppia-new-ratings-and-recommendations .community-library-btn {
width: 325px;
}
-oppia-new-ratings-and-recommendations a:hover {
+a:hover {
text-decoration: none;
}
-oppia-new-ratings-and-recommendations .community-library-btn i {
+.community-library-btn i {
font-size: 24px;
margin-right: 10px;
}
@media screen and (max-width: 959px) {
- oppia-new-ratings-and-recommendations .conversation-skin-final-summary {
+ .conversation-skin-final-summary {
margin: 0 auto;
max-width: 650px;
width: 100%;
@@ -150,12 +150,12 @@ oppia-new-ratings-and-recommendations .community-library-btn i {
}
@media (max-width: 480px) {
- oppia-new-ratings-and-recommendations .community-library-btn {
+ .community-library-btn {
font-size: 13px;
width: 240px;
}
- oppia-new-ratings-and-recommendations .community-library-btn i {
+ .community-library-btn i {
font-size: 18px;
}
}
diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/take-break-modal.component.css b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/take-break-modal.component.css
new file mode 100644
index 0000000000000..ed99413f703d3
--- /dev/null
+++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/take-break-modal.component.css
@@ -0,0 +1,3 @@
+.modal-body p {
+ line-height: 1.846;
+}
diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/take-break-modal.component.html b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/take-break-modal.component.html
index 6aa5489b5d636..2164f930377ff 100644
--- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/take-break-modal.component.html
+++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/take-break-modal.component.html
@@ -17,9 +17,3 @@
-
-
diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/take-break-modal.component.ts b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/take-break-modal.component.ts
index a4cc9894e4054..e68f2627c8f56 100644
--- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/take-break-modal.component.ts
+++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/conversation-display-components/take-break-modal.component.ts
@@ -18,11 +18,12 @@
import {Component} from '@angular/core';
import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
+import './take-break-modal.component.css';
@Component({
selector: 'take-break-modal',
templateUrl: './take-break-modal.component.html',
- styleUrls: [],
+ styleUrls: ['./take-break-modal.component.css'],
})
export class TakeBreakModalComponent {
constructor(private activeModal: NgbActiveModal) {}
diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/lesson-player-footer/checkpoint-celebration-footer.component.css b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/lesson-player-footer/checkpoint-celebration-footer.component.css
index 7bf539550e760..271e0aa6a937e 100644
--- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/lesson-player-footer/checkpoint-celebration-footer.component.css
+++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/lesson-player-footer/checkpoint-celebration-footer.component.css
@@ -1,4 +1,4 @@
-oppia-checkpoint-celebration-footer .inner-container {
+.inner-container {
-webkit-animation-delay: 0.5s;
-ms-animation-delay: 0.5s;
-webkit-animation-direction: normal;
@@ -37,7 +37,7 @@ oppia-checkpoint-celebration-footer .inner-container {
text-align: center;
}
-oppia-checkpoint-celebration-footer .inner-container-alignment {
+.inner-container-alignment {
bottom: 60px;
left: 50%;
padding-left: 30px;
@@ -45,16 +45,16 @@ oppia-checkpoint-celebration-footer .inner-container-alignment {
position: fixed;
transform: translateX(-50%);
}
-oppia-checkpoint-celebration-footer .tick-symbol {
+.tick-symbol {
font-size: 42px;
vertical-align: middle;
}
-oppia-checkpoint-celebration-footer .inner-content {
+.inner-content {
padding-left: 8px;
}
-oppia-checkpoint-celebration-footer .inner-container-fade-out {
+.inner-container-fade-out {
animation: fadeout 5s 1;
}
@@ -398,17 +398,17 @@ oppia-checkpoint-celebration-footer .dot18 {
}
@media(max-width: 959px) {
- oppia-checkpoint-celebration-footer .inner-container {
+ .inner-container {
max-width: 650px;
}
}
@media(max-width: 480px) {
- oppia-checkpoint-celebration-footer .inner-container {
+ .inner-container {
min-width: 250px;
}
- oppia-checkpoint-celebration-footer .inner-content {
+ .inner-content {
font-size: 16px;
}
diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/lesson-player-footer/save-progress-modal.component.css b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/lesson-player-footer/save-progress-modal.component.css
index ae9cae664c513..7b80c6b06c65a 100644
--- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/lesson-player-footer/save-progress-modal.component.css
+++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/lesson-player-footer/save-progress-modal.component.css
@@ -4,11 +4,11 @@
https://rtlcss.com/learn/usage-guide/control-directives .
*/
-oppia-save-progress-modal .new-save-progress-menu {
+.new-save-progress-menu {
padding: 16px;
}
-oppia-save-progress-modal .new-save-progress-menu .checkpoint-status {
+.new-save-progress-menu .checkpoint-status {
align-items: center;
display: flex;
flex-direction: column;
@@ -17,7 +17,7 @@ oppia-save-progress-modal .new-save-progress-menu .checkpoint-status {
width: 100%;
}
-oppia-save-progress-modal .new-save-progress-menu h3 {
+.new-save-progress-menu h3 {
color: #00645c;
font-family: capriola, sans-serif;
font-size: 20px;
@@ -25,7 +25,7 @@ oppia-save-progress-modal .new-save-progress-menu h3 {
text-align: center;
}
-oppia-save-progress-modal .new-save-progress-menu .sign-in-box {
+.new-save-progress-menu .sign-in-box {
align-items: center;
display: flex;
flex-direction: column;
@@ -34,16 +34,16 @@ oppia-save-progress-modal .new-save-progress-menu .sign-in-box {
width: 100%;
}
-oppia-save-progress-modal.new-save-progress-menu .sign-in-box p {
+.new-save-progress-menu .sign-in-box p {
margin: 10px 20px;
}
-oppia-save-progress-modal .new-save-progress-menu .sign-in-box .sign-in-text a {
+.new-save-progress-menu .sign-in-box .sign-in-text a {
color: #00645c;
text-decoration: underline;
}
-oppia-save-progress-modal .new-save-progress-menu .sign-in-box .create-account-btn {
+.new-save-progress-menu .sign-in-box .create-account-btn {
background-color: #00645c;
color: #fff;
cursor: pointer;
@@ -55,7 +55,7 @@ oppia-save-progress-modal .new-save-progress-menu .sign-in-box .create-account-b
width: 169px;
}
-oppia-save-progress-modal .new-save-progress-menu .uid-box {
+.new-save-progress-menu .uid-box {
align-items: center;
display: flex;
flex-direction: column;
@@ -63,16 +63,16 @@ oppia-save-progress-modal .new-save-progress-menu .uid-box {
width: 100%;
}
-oppia-save-progress-modal .new-save-progress-menu .uid-box .oppia-tooltip-container {
+.new-save-progress-menu .uid-box .oppia-tooltip-container {
width: 100%;
}
-oppia-save-progress-modal .new-save-progress-menu .uid-box .guide-text {
+.new-save-progress-menu .uid-box .guide-text {
margin-top: 32px;
text-align: center;
}
-oppia-save-progress-modal .new-save-progress-menu .uid-box .copy-text {
+.new-save-progress-menu .uid-box .copy-text {
font-size: 14px;
margin-bottom: 10px;
padding-left: 20px;
@@ -80,7 +80,7 @@ oppia-save-progress-modal .new-save-progress-menu .uid-box .copy-text {
width: 100%;
}
-oppia-save-progress-modal .new-save-progress-menu .save-progress-close-button {
+.new-save-progress-menu .save-progress-close-button {
background: transparent;
border: none;
outline: none;
diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-audio-bar.component.css b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-audio-bar.component.css
index 62c546f7ac840..7c5e26e80eb20 100644
--- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-audio-bar.component.css
+++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-audio-bar.component.css
@@ -64,20 +64,7 @@
width: 100%;
}
-oppia-new-audio-bar .mat-slider-horizontal {
- height: 0;
- min-width: 120px;
-}
-
-oppia-new-audio-bar .mat-slider {
- padding: 0;
-}
-
-oppia-new-audio-bar .mat-slider-wrapper {
- position: static;
-}
-
-oppia-new-audio-bar .mobile-mode {
+.mobile-mode {
display: none;
}
@@ -97,10 +84,10 @@ oppia-new-audio-bar .mobile-mode {
width: 120px;
}
- oppia-new-audio-bar .desktop-mode {
+ .desktop-mode {
display: none;
}
- oppia-new-audio-bar .mobile-mode {
+ .mobile-mode {
display: block;
margin-left: 30px;
}
diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-audio-bar.component.ts b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-audio-bar.component.ts
index f9acb50a18cb1..f9adf35781414 100644
--- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-audio-bar.component.ts
+++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-audio-bar.component.ts
@@ -21,7 +21,6 @@ import {
Component,
ElementRef,
ViewChild,
- ViewEncapsulation,
} from '@angular/core';
import {StateEditorService} from 'components/state-editor/state-editor-properties-services/state-editor.service';
import {Voiceover} from 'domain/exploration/voiceover.model';
@@ -42,7 +41,6 @@ import {MobileMenuService} from 'pages/exploration-player-page/services/mobile-m
selector: 'oppia-new-audio-bar',
templateUrl: './new-audio-bar.component.html',
styleUrls: ['./new-audio-bar.component.css'],
- encapsulation: ViewEncapsulation.None,
})
export class NewAudioBarComponent {
@ViewChild('audioControls', {static: false}) audioControlsRef!: ElementRef;
diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-conversation-skin.component.css b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-conversation-skin.component.css
index 2c9a3647baed7..55b0e5fe9885f 100644
--- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-conversation-skin.component.css
+++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-conversation-skin.component.css
@@ -18,7 +18,7 @@ otherwise they will interfere with the iframed conversation skin directive. */
z-index: 16;
}
-oppia-new-conversation-skin .new-supplemental-card-parent-container {
+.new-supplemental-card-parent-container {
min-width: 50%;
}
@@ -57,10 +57,6 @@ oppia-new-conversation-skin .new-supplemental-card-parent-container {
width: 100%;
}
-.conversation-skin-help-card-content {
- padding: 12px;
-}
-
.new-conversation-skin-tutor-card-container {
margin: auto;
max-width: 660px;
@@ -97,7 +93,7 @@ oppia-new-conversation-skin .new-supplemental-card-parent-container {
width: 40vw;
}
-oppia-new-conversation-skin .oppia-new-button-raised.mat-raised-raised {
+.oppia-new-button-raised.mat-raised-raised {
align-items: center;
background-color: #0d48a1;
display: flex;
@@ -106,7 +102,7 @@ oppia-new-conversation-skin .oppia-new-button-raised.mat-raised-raised {
width: 32px;
}
-oppia-new-conversation-skin .oppia-button-icon {
+.oppia-button-icon {
color: #fff;
}
diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-conversation-skin.component.ts b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-conversation-skin.component.ts
index 2b338a4f04c27..6087591e7198d 100644
--- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-conversation-skin.component.ts
+++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/new-conversation-skin.component.ts
@@ -18,12 +18,7 @@
import {Subscription} from 'rxjs';
import {StateCard} from 'domain/state_card/state-card.model';
-import {
- ChangeDetectorRef,
- Component,
- Input,
- ViewEncapsulation,
-} from '@angular/core';
+import {ChangeDetectorRef, Component, Input} from '@angular/core';
import {WindowRef} from 'services/contextual/window-ref.service';
import {AlertsService} from 'services/alerts.service';
import {PageContextService} from 'services/page-context.service';
@@ -71,7 +66,6 @@ import {PreventPageUnloadEventService} from 'services/prevent-page-unload-event.
selector: 'oppia-new-conversation-skin',
templateUrl: './new-conversation-skin.component.html',
styleUrls: ['./new-conversation-skin.component.css'],
- encapsulation: ViewEncapsulation.None,
})
export class NewConversationSkinComponent {
// This throws "Type 'QuestionPlayerConfig' is not assignable to type 'QuestionPlayerConfigDict'".
diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/supplemental-card.component.css b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/supplemental-card.component.css
index fe45d1439828a..52b36ce2c081d 100644
--- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/supplemental-card.component.css
+++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/supplemental-card.component.css
@@ -4,7 +4,7 @@
https://rtlcss.com/learn/usage-guide/control-directives .
*/
-oppia-supplemental-card .conversation-skin-help-card {
+.conversation-skin-help-card {
background: #fff;
border-radius: 2px;
/* We move the help card lower so that it does not block
@@ -23,23 +23,23 @@ oppia-supplemental-card .conversation-skin-help-card {
z-index: 1001;
}
-oppia-supplemental-card .conversation-skin-help-card.ng-enter,
-oppia-supplemental-card .conversation-skin-help-card.ng-leave-active {
+.conversation-skin-help-card.ng-enter,
+.conversation-skin-help-card.ng-leave-active {
opacity: 0;
transform: translateX(200px);
}
-oppia-supplemental-card .help-card-standard {
+.help-card-standard {
/* Use margin-bottom to make sure the help card doesn't get hidden behind the footer. */
margin-bottom: 80px;
position: absolute;
}
-oppia-supplemental-card .conversation-skin-help-card .conversation-skin-oppia-avatar {
+.conversation-skin-help-card .conversation-skin-oppia-avatar {
left: -25px;
}
-oppia-supplemental-card .conversation-skin-close-help-card-button {
+.conversation-skin-close-help-card-button {
background: none;
border: 0;
color: #000;
@@ -53,6 +53,11 @@ oppia-supplemental-card .conversation-skin-close-help-card-button {
z-index: 200;
}
-oppia-supplemental-card .conversation-skin-supplemental-card {
+.conversation-skin-supplemental-card {
background-color: #fff;
}
+
+.conversation-skin-help-card-content {
+ border-top-left-radius: 0;
+ padding: 12px;
+}
diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/supplemental-card.component.ts b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/supplemental-card.component.ts
index 0fc1f4538f6e0..130088b4865cf 100644
--- a/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/supplemental-card.component.ts
+++ b/core/templates/pages/exploration-player-page/new-lesson-player/conversation-skin-components/supplemental-card.component.ts
@@ -25,7 +25,6 @@ import {
ViewChild,
ChangeDetectorRef,
SimpleChanges,
- ViewEncapsulation,
} from '@angular/core';
import {AppConstants} from 'app.constants';
import {StateCard} from 'domain/state_card/state-card.model';
@@ -44,7 +43,6 @@ import {ConversationFlowService} from 'pages/exploration-player-page/services/co
selector: 'oppia-supplemental-card',
templateUrl: './supplemental-card.component.html',
styleUrls: ['./supplemental-card.component.css'],
- encapsulation: ViewEncapsulation.None,
})
export class SupplementalCardComponent implements OnInit, OnDestroy {
// These properties are initialized using Angular lifecycle hooks
diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/header-components/player-header.component.ts b/core/templates/pages/exploration-player-page/new-lesson-player/header-components/player-header.component.ts
index da1fc5afe35c8..7c4dc88cd0818 100644
--- a/core/templates/pages/exploration-player-page/new-lesson-player/header-components/player-header.component.ts
+++ b/core/templates/pages/exploration-player-page/new-lesson-player/header-components/player-header.component.ts
@@ -16,7 +16,7 @@
* @fileoverview Component for the new lesson player header
*/
-import {Component, ViewEncapsulation} from '@angular/core';
+import {Component} from '@angular/core';
import {ReadOnlyExplorationBackendApiService} from 'domain/exploration/read-only-exploration-backend-api.service';
import {ReadOnlyTopic} from 'domain/topic_viewer/read-only-topic.model';
import {TopicViewerBackendApiService} from 'domain/topic_viewer/topic-viewer-backend-api.service';
@@ -42,7 +42,6 @@ enum PageContextConstants {
selector: 'oppia-player-header',
templateUrl: './player-header.component.html',
styleUrls: ['./player-header.component.css'],
- encapsulation: ViewEncapsulation.None,
})
export class PlayerHeaderComponent {
// These properties are initialized using Angular lifecycle hooks
diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/lesson-player-page-root.component.ts b/core/templates/pages/exploration-player-page/new-lesson-player/lesson-player-page-root.component.ts
index 0aec608b6e72e..bc116e2998804 100644
--- a/core/templates/pages/exploration-player-page/new-lesson-player/lesson-player-page-root.component.ts
+++ b/core/templates/pages/exploration-player-page/new-lesson-player/lesson-player-page-root.component.ts
@@ -16,11 +16,10 @@
* @fileoverview New lesson player page root component.
*/
-import {Component, ViewEncapsulation} from '@angular/core';
+import {Component} from '@angular/core';
@Component({
selector: 'oppia-new-lesson-player-page-root',
templateUrl: './lesson-player-page-root.component.html',
- encapsulation: ViewEncapsulation.None,
})
export class NewLessonPlayerPageRootComponent {}
diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/sidebar-components/lesson-feedback-modal.component.css b/core/templates/pages/exploration-player-page/new-lesson-player/sidebar-components/lesson-feedback-modal.component.css
index 86d5926d5f0b5..2b4e39385c329 100644
--- a/core/templates/pages/exploration-player-page/new-lesson-player/sidebar-components/lesson-feedback-modal.component.css
+++ b/core/templates/pages/exploration-player-page/new-lesson-player/sidebar-components/lesson-feedback-modal.component.css
@@ -1,19 +1,19 @@
-oppia-lesson-feedback-modal textarea {
+textarea {
margin-bottom: 10px;
padding: 8px;
resize: none;
width: 100%;
}
-oppia-lesson-feedback-modal input[type="checkbox"] {
+input[type="checkbox"] {
margin-right: 5px;
}
-oppia-lesson-feedback-modal .feedback-submit-btn {
+.feedback-submit-btn {
background-color: #28a745;
color: white;
}
-oppia-lesson-feedback-modal .feedback-submit-btn:disabled {
+.feedback-submit-btn:disabled {
background-color: #dfdee2;
}
diff --git a/core/templates/pages/exploration-player-page/new-lesson-player/sidebar-components/share-lesson-modal.component.css b/core/templates/pages/exploration-player-page/new-lesson-player/sidebar-components/share-lesson-modal.component.css
index caca04e24b957..4bff550dee1bf 100644
--- a/core/templates/pages/exploration-player-page/new-lesson-player/sidebar-components/share-lesson-modal.component.css
+++ b/core/templates/pages/exploration-player-page/new-lesson-player/sidebar-components/share-lesson-modal.component.css
@@ -4,13 +4,13 @@
https://rtlcss.com/learn/usage-guide/control-directives .
*/
-oppia-share-lesson-modal .oppia-share-lesson-modal-header {
+.oppia-share-lesson-modal-header {
align-items: center;
display: flex;
justify-content: space-between;
}
-oppia-share-lesson-modal .oppia-share-lesson-modal-title {
+.oppia-share-lesson-modal-title {
color: #00645c;
font-family: 'Capriola', 'Roboto', 'Arial', sans-serif;
font-size: 1.25em;
@@ -22,26 +22,26 @@ textarea::-webkit-scrollbar {
display: none;
}
-oppia-share-lesson-modal .modal-body {
+.modal-body {
display: flex;
flex-direction: column;
padding: 24px;
}
-oppia-share-lesson-modal .oppia-share-lesson-modal-footer {
+.oppia-share-lesson-modal-footer {
align-items: center;
display: flex;
justify-content: space-between;
padding: 16px 20px;
}
-oppia-share-lesson-modal .cancel-button {
+.cancel-button {
border: 1px solid #00645c;
color: #00645c;
font-weight: 700;
}
-oppia-share-lesson-modal .lesson-attribution-text {
+.lesson-attribution-text {
color: #00645c;
cursor: pointer;
font-size: 20px;
@@ -49,13 +49,13 @@ oppia-share-lesson-modal .lesson-attribution-text {
text-decoration: underline;
}
-oppia-share-lesson-modal .share-icon-container svg {
+.share-icon-container svg {
cursor: pointer;
margin-bottom: 40px;
margin-right: 12px;
}
-oppia-share-lesson-modal .share-modal-btn {
+.share-modal-btn {
border: 1px solid #205d86;
border-radius: 4px;
color: #205d86;
@@ -66,13 +66,13 @@ oppia-share-lesson-modal .share-modal-btn {
width: 210px;
}
-oppia-share-lesson-modal .share-modal-btn .button-content {
+.share-modal-btn .button-content {
align-items: center;
display: flex;
justify-content: space-between;
}
-oppia-share-lesson-modal .success-message {
+.success-message {
border-radius: 4px;
color: white;
height: 40px;
@@ -80,7 +80,7 @@ oppia-share-lesson-modal .success-message {
width: 200px;
}
-oppia-share-lesson-modal .success-message p {
+.success-message p {
align-items: center;
background-color: #323232;
display: flex;
@@ -88,13 +88,13 @@ oppia-share-lesson-modal .success-message p {
padding: 12px 16px;
}
-oppia-share-lesson-modal .copy-modal-body {
+.copy-modal-body {
align-items: center;
display: flex;
justify-content: center;
}
-oppia-share-lesson-modal .cc-attribution-header {
+.cc-attribution-header {
color: #0d48a1;
font-family: "Roboto", sans-serif;
font-size: 20px;
@@ -103,11 +103,11 @@ oppia-share-lesson-modal .cc-attribution-header {
text-align: center;
}
-oppia-share-lesson-modal .cc-attribution a:hover {
+.cc-attribution a:hover {
text-decoration: none;
}
-oppia-share-lesson-modal .cc-attribution-input {
+.cc-attribution-input {
background-color: #ddd;
border: 1px solid #0d48a1;
border-radius: 4px;
@@ -115,7 +115,7 @@ oppia-share-lesson-modal .cc-attribution-input {
padding: 10px;
}
-oppia-share-lesson-modal .copy-cc-btn {
+.copy-cc-btn {
border: 1px solid #205d86;
border-radius: 4px;
font-size: 17px;
@@ -124,7 +124,7 @@ oppia-share-lesson-modal .copy-cc-btn {
width: 49%;
}
-oppia-share-lesson-modal .copy-cc-btn .button-content {
+.copy-cc-btn .button-content {
align-items: center;
color: #205d86;
display: flex;
@@ -132,34 +132,34 @@ oppia-share-lesson-modal .copy-cc-btn .button-content {
justify-content: space-between;
}
-oppia-share-lesson-modal .cc-attribution-text {
+.cc-attribution-text {
display: flex;
flex-direction: column;
}
@media (max-width: 480px) {
- oppia-share-lesson-modal .oppia-share-lesson-modal-footer {
+ .oppia-share-lesson-modal-footer {
justify-content: center;
}
- oppia-share-lesson-modal .lesson-attribution-text {
+ .lesson-attribution-text {
font-size: 15px;
}
- oppia-share-lesson-modal .share-modal-btn {
+ .share-modal-btn {
margin-bottom: 10px;
margin-right: 0;
}
- oppia-share-lesson-modal .copy-modal-body-btn {
+ .copy-modal-body-btn {
flex-direction: column;
}
- oppia-share-lesson-modal .cc-attribution-input {
+ .cc-attribution-input {
width: 100%;
}
- oppia-share-lesson-modal .copy-cc-btn {
+ .copy-cc-btn {
width: 100%;
}
}
diff --git a/core/templates/pages/exploration-player-page/services/current-interaction.service.spec.ts b/core/templates/pages/exploration-player-page/services/current-interaction.service.spec.ts
index 1900419585aab..ea972ac552cf6 100644
--- a/core/templates/pages/exploration-player-page/services/current-interaction.service.spec.ts
+++ b/core/templates/pages/exploration-player-page/services/current-interaction.service.spec.ts
@@ -264,6 +264,13 @@ describe('Current Interaction Service', () => {
expect(currentInteractionService.showNoResponseError()).toBe(true);
});
+ it('should return false for no response error when no card is displayed', () => {
+ spyOn(currentInteractionService, 'getDisplayedCard').and.returnValue(
+ undefined
+ );
+
+ expect(currentInteractionService.showNoResponseError()).toBe(false);
+ });
it('should update answer validity using updateAnswerIsValid', () => {
spyOn(currentInteractionService, 'getDisplayedCard').and.returnValue(
displayedCard
@@ -290,4 +297,12 @@ describe('Current Interaction Service', () => {
expect(currentInteractionService.showInvalidResponseError()).toBe(false);
expect(displayedCard.showInvalidResponseError).toHaveBeenCalledTimes(2);
});
+
+ it('should return false for invalid response error when no card is displayed', () => {
+ spyOn(currentInteractionService, 'getDisplayedCard').and.returnValue(
+ undefined
+ );
+
+ expect(currentInteractionService.showInvalidResponseError()).toBe(false);
+ });
});
diff --git a/core/templates/pages/exploration-player-page/services/current-interaction.service.ts b/core/templates/pages/exploration-player-page/services/current-interaction.service.ts
index 28ac4cf2dbbef..6058bc93cbba0 100644
--- a/core/templates/pages/exploration-player-page/services/current-interaction.service.ts
+++ b/core/templates/pages/exploration-player-page/services/current-interaction.service.ts
@@ -161,7 +161,7 @@ export class CurrentInteractionService {
CurrentInteractionService.onSubmitFn(answer, interactionRulesService);
}
- getDisplayedCard(): StateCard {
+ getDisplayedCard(): StateCard | undefined {
const index = this.playerPositionService.getDisplayedCardIndex();
return this.playerTranscriptService.getCard(index);
}
@@ -171,7 +171,7 @@ export class CurrentInteractionService {
}
showNoResponseError(): boolean {
- return this.getDisplayedCard().showNoResponseError();
+ return this.getDisplayedCard()?.showNoResponseError() ?? false;
}
submitAnswer(): void {
@@ -242,6 +242,6 @@ export class CurrentInteractionService {
this.getDisplayedCard()?.updateAnswerIsValid(isAnswerValid);
}
showInvalidResponseError(): boolean {
- return this.getDisplayedCard().showInvalidResponseError();
+ return this.getDisplayedCard()?.showInvalidResponseError() ?? false;
}
}
diff --git a/core/templates/pages/exploration-player-page/services/learner-view-info-backend-api.service.spec.ts b/core/templates/pages/exploration-player-page/services/learner-view-info-backend-api.service.spec.ts
index cf790d52b2d3b..3f80c08f96b6e 100644
--- a/core/templates/pages/exploration-player-page/services/learner-view-info-backend-api.service.spec.ts
+++ b/core/templates/pages/exploration-player-page/services/learner-view-info-backend-api.service.spec.ts
@@ -52,7 +52,7 @@ describe('Learner View Info Backend Api Service', () => {
let req = httpTestingController.expectOne(
'/explorationsummarieshandler/' +
- 'data?stringified_exp_ids=&include_private_explorations='
+ 'data?stringified_exp_ids=&include_private_explorations=&display_in_language_code=en'
);
expect(req.request.method).toEqual('GET');
req.flush(jobOutput);
diff --git a/core/templates/pages/exploration-player-page/services/learner-view-info-backend-api.service.ts b/core/templates/pages/exploration-player-page/services/learner-view-info-backend-api.service.ts
index f48d3eb3d3cd8..60f55db535dcb 100644
--- a/core/templates/pages/exploration-player-page/services/learner-view-info-backend-api.service.ts
+++ b/core/templates/pages/exploration-player-page/services/learner-view-info-backend-api.service.ts
@@ -19,6 +19,7 @@ import {HttpClient} from '@angular/common/http';
import {Injectable} from '@angular/core';
import {AppConstants} from 'app.constants';
import {LearnerExplorationSummaryBackendDict} from 'domain/summary/learner-exploration-summary.model';
+import {I18nLanguageCodeService} from 'services/i18n-language-code.service';
interface LearnerViewBackendDict {
summaries: LearnerExplorationSummaryBackendDict[];
@@ -28,7 +29,10 @@ interface LearnerViewBackendDict {
providedIn: 'root',
})
export class LearnerViewInfoBackendApiService {
- constructor(private http: HttpClient) {}
+ constructor(
+ private http: HttpClient,
+ private i18nLanguageCodeService: I18nLanguageCodeService
+ ) {}
async fetchLearnerInfoAsync(
stringifiedExpIds: string,
@@ -41,6 +45,11 @@ export class LearnerViewInfoBackendApiService {
params: {
stringified_exp_ids: stringifiedExpIds,
include_private_explorations: includePrivateExplorations,
+ // The lesson information card shows this metadata alongside the
+ // lesson itself, so it is displayed in the learner's site
+ // language.
+ display_in_language_code:
+ this.i18nLanguageCodeService.getCurrentI18nLanguageCode(),
},
}
)
diff --git a/core/templates/pages/feedback-updates-page/feedback-updates-page.component.css b/core/templates/pages/feedback-updates-page/feedback-updates-page.component.css
index 8464b436dab9e..0a439b0913d77 100644
--- a/core/templates/pages/feedback-updates-page/feedback-updates-page.component.css
+++ b/core/templates/pages/feedback-updates-page/feedback-updates-page.component.css
@@ -256,3 +256,16 @@ oppia-feedback-updates-page .oppia-pagination-button {
.oppia-diagram-spinning-button {
margin: 0 auto;
}
+
+.oppia-profile-alignment {
+ vertical-align: middle;
+}
+
+.oppia-box-size {
+ padding: 2px 10px;
+}
+
+.feedback-updates-content-row {
+ display: flex;
+ justify-content: center;
+}
diff --git a/core/templates/pages/feedback-updates-page/feedback-updates-page.component.html b/core/templates/pages/feedback-updates-page/feedback-updates-page.component.html
index 9019d8ecd2895..a5dae3b24740f 100644
--- a/core/templates/pages/feedback-updates-page/feedback-updates-page.component.html
+++ b/core/templates/pages/feedback-updates-page/feedback-updates-page.component.html
@@ -250,16 +250,3 @@
-
-
diff --git a/core/templates/pages/landing-pages/topic-landing-page/topic-landing-page.component.css b/core/templates/pages/landing-pages/topic-landing-page/topic-landing-page.component.css
new file mode 100644
index 0000000000000..31e502cc3c41c
--- /dev/null
+++ b/core/templates/pages/landing-pages/topic-landing-page/topic-landing-page.component.css
@@ -0,0 +1,135 @@
+.oppia-landing-page h2, button {
+ border-radius: 4px;
+ font-family: "Roboto", Arial, sans-serif;
+}
+.oppia-landing-page h2, h3, p {
+ line-height: 1.5;
+}
+.oppia-white-container {
+ background-color: #fff
+}
+.oppia-grey-container {
+ background-color: #e8e7e3
+}
+.oppia-landing-section-inner {
+ margin-left: auto;
+ margin-right: auto;
+ max-width: 1200px;
+ padding: 4% 0;
+}
+.oppia-langing-page-qualities {
+ width: 30%;
+}
+.oppia-flex-box {
+ display: flex;
+ justify-content: center;
+}
+.oppia-flex-row {
+ flex-flow: row;
+}
+.oppia-flex-column {
+ flex-flow: column;
+}
+.oppia-flex-item {
+ padding: 5px;
+}
+.oppia-text-align-left {
+ text-align: left;
+}
+.oppia-flex-align-center {
+ align-items: center;
+}
+.oppia-landing-page-button {
+ background-color: #009688;
+ border: 4px solid #009688;
+ border-radius: 5px;
+ color: #fff;
+ margin: 14px 0;
+ min-width: 250px;
+ text-transform: uppercase;
+}
+.oppia-make-button-transparent {
+ background-color: transparent;
+ color: #009688;
+}
+.oppia-landing-page-image-container {
+ display: block;
+ margin: auto;
+ min-height: 150px;
+ min-width: 200px;
+ width: 80%;
+}
+.oppia-devices-image {
+ width: 80%;
+}
+.oppia-container-low-width {
+ width: 40%;
+}
+.oppia-only-display-in-mobile {
+ display: none;
+}
+.oppia-container-margin {
+ margin: 10px;
+}
+.oppia-landing-page-button:hover,
+.oppia-landing-page-button:focus,
+.oppia-landing-page-button:active {
+ background-color: #015c53;
+ border-color: #015c53;
+ color: #fff;
+}
+.oppia-make-button-transparent:hover,
+.oppia-make-button-transparent:focus,
+.oppia-make-button-transparent:active {
+ background-color: transparent;
+ border-color: #004d45;
+ color: #004d45;
+}
+
+@media screen and (max-width: 780px) {
+ .oppia-landing-section-inner,
+ .oppia-landing-section {
+ height: auto;
+ }
+ .oppia-landing-page-button {
+ margin: 0 0 14px;
+ }
+ .oppia-flex-mobile-column {
+ flex-flow: column;
+ }
+ .oppia-flex-mobile-order-2 {
+ order: 2;
+ }
+ .oppia-flex-mobile-order-3 {
+ order: 3;
+ }
+ .oppia-flex-mobile-row {
+ flex-flow: row;
+ }
+ .oppia-flex-mobile-align-center {
+ align-items: center;
+ }
+ .oppia-mobile-text-align-center {
+ text-align: center;
+ }
+ .oppia-landing-page-image-container {
+ min-height: unset;
+ min-width: unset;
+ width: 35%;
+ }
+ .oppia-langing-page-qualities {
+ width: 100%;
+ }
+ .oppia-landing-qualities-description {
+ width: 65%;
+ }
+ .oppia-container-low-width {
+ width: 75%;
+ }
+ .oppia-only-display-in-mobile {
+ display: block;
+ }
+ .oppia-container-margin {
+ margin: 0;
+ }
+}
diff --git a/core/templates/pages/landing-pages/topic-landing-page/topic-landing-page.component.html b/core/templates/pages/landing-pages/topic-landing-page/topic-landing-page.component.html
index e4e8dbf54eeae..e248805e0b045 100644
--- a/core/templates/pages/landing-pages/topic-landing-page/topic-landing-page.component.html
+++ b/core/templates/pages/landing-pages/topic-landing-page/topic-landing-page.component.html
@@ -74,141 +74,3 @@ Get started today! Looking for something else? Check out all of our other le
-
-
diff --git a/core/templates/pages/landing-pages/topic-landing-page/topic-landing-page.component.ts b/core/templates/pages/landing-pages/topic-landing-page/topic-landing-page.component.ts
index 73519a678fb4c..be469bda0e2a4 100644
--- a/core/templates/pages/landing-pages/topic-landing-page/topic-landing-page.component.ts
+++ b/core/templates/pages/landing-pages/topic-landing-page/topic-landing-page.component.ts
@@ -28,6 +28,7 @@ import {UrlService} from 'services/contextual/url.service';
import {WindowRef} from 'services/contextual/window-ref.service';
import {SiteAnalyticsService} from 'services/site-analytics.service';
import {PageTitleService} from 'services/page-title.service';
+import './topic-landing-page.component.css';
type TopicLandingPageDataKey =
keyof typeof TopicLandingPageConstants.TOPIC_LANDING_PAGE_DATA;
@@ -53,7 +54,7 @@ interface TopicData {
@Component({
selector: 'topic-landing-page',
templateUrl: './topic-landing-page.component.html',
- styleUrls: [],
+ styleUrls: ['./topic-landing-page.component.css'],
})
export class TopicLandingPageComponent implements OnInit, OnDestroy {
// These properties are initialized using Angular lifecycle hooks
diff --git a/core/templates/pages/learner-dashboard-page/add-goals-modal/add-goals-modal.component.css b/core/templates/pages/learner-dashboard-page/add-goals-modal/add-goals-modal.component.css
new file mode 100644
index 0000000000000..7c6f6e655ebe0
--- /dev/null
+++ b/core/templates/pages/learner-dashboard-page/add-goals-modal/add-goals-modal.component.css
@@ -0,0 +1,107 @@
+.oppia-learner-dash-button--default[disabled],
+.oppia-learner-dash-button--default:disabled {
+ background-color: #f2f2f2 !important;
+ border: 1px solid #ccc !important;
+ color: #888 !important;
+ cursor: not-allowed;
+ opacity: 0.6;
+ pointer-events: none;
+}
+
+.oppia-learner-dash-goals-modal-content {
+ box-sizing: border-box;
+ display: grid;
+ gap: 12px 24px;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ height: auto;
+ max-height: 165px;
+ overflow-y: auto;
+ padding: 12px 20px 0;
+ width: 100%;
+}
+
+.oppia-learner-dash-goals-checkbox {
+ align-items: flex-start;
+ display: flex;
+ font-size: 16px;
+ gap: 0 12px;
+ min-height: 40px;
+ overflow: hidden;
+}
+
+.oppia-learner-dash-goals-checkbox-text {
+ -webkit-box-orient: vertical;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: normal;
+ word-break: break-word;
+}
+
+.oppia-learner-dash-goals-modal-actions {
+ bottom: 0;
+ display: flex;
+ gap: 12px;
+ justify-content: end;
+}
+
+.oppia-learner-dash-button--modal {
+ flex: 1 1 auto;
+ height: 44px;
+ max-width: 178px;
+ min-width: 95px;
+}
+
+.oppia-learner-dash-button--modal.outline {
+ border: 1px solid #00645c;
+}
+
+.oppia-learner-dash-goals-modal-title {
+ font-size: 20px;
+ font-weight: 500;
+ text-align: center;
+}
+
+.oppia-learner-dash-goals-modal-info {
+ font-size: 14px;
+ text-align: center;
+}
+
+@media screen and (max-width: 1024px) {
+ .oppia-learner-dash-button--modal {
+ max-width: 85%;
+ }
+
+ .oppia-learner-dash-goals-modal-content {
+ grid-template-columns: 1fr;
+ padding: 20px 16px;
+ }
+
+ .oppia-learner-dash-goals-modal-actions {
+ flex-grow: 1;
+ justify-content: center;
+ }
+
+ .oppia-learner-dash-goals-checkbox {
+ align-items: center;
+ display: flex;
+ min-height: 48px;
+ }
+}
+
+@media screen and (max-width: 768px) {
+
+ .oppia-learner-dash-goals-modal-content {
+ min-height: 256px;
+ }
+}
+
+@media screen and (max-width: 576px) {
+ .oppia-learner-dash-goals-modal-actions {
+ gap: 0 0;
+ }
+ .oppia-learner-dash-button--modal {
+ flex: 1 1 45%;
+ }
+}
diff --git a/core/templates/pages/learner-dashboard-page/add-goals-modal/add-goals-modal.component.html b/core/templates/pages/learner-dashboard-page/add-goals-modal/add-goals-modal.component.html
index 486425883b179..80f41ef6a1fc7 100644
--- a/core/templates/pages/learner-dashboard-page/add-goals-modal/add-goals-modal.component.html
+++ b/core/templates/pages/learner-dashboard-page/add-goals-modal/add-goals-modal.component.html
@@ -19,114 +19,3 @@ {{ 'I18N_LEAR
{{ 'I18N_LEARNER_DASHBOARD_GOALS_SECTION_MODAL_SAVE' | translate }}
-
-
diff --git a/core/templates/pages/learner-dashboard-page/add-goals-modal/add-goals-modal.component.ts b/core/templates/pages/learner-dashboard-page/add-goals-modal/add-goals-modal.component.ts
index 084a09378c19f..da7ae44a8fd04 100644
--- a/core/templates/pages/learner-dashboard-page/add-goals-modal/add-goals-modal.component.ts
+++ b/core/templates/pages/learner-dashboard-page/add-goals-modal/add-goals-modal.component.ts
@@ -17,10 +17,12 @@
*/
import {Component, Inject} from '@angular/core';
import {MatDialogRef, MAT_DIALOG_DATA} from '@angular/material/dialog';
+import './add-goals-modal.component.css';
@Component({
selector: 'oppia-add-goals-modal',
templateUrl: './add-goals-modal.component.html',
+ styleUrls: ['./add-goals-modal.component.css'],
})
export class AddGoalsModalComponent {
checkedTopics: Set;
diff --git a/core/templates/pages/learner-dashboard-page/card-display/card-display.component.css b/core/templates/pages/learner-dashboard-page/card-display/card-display.component.css
new file mode 100644
index 0000000000000..a96cf0a0ed9c9
--- /dev/null
+++ b/core/templates/pages/learner-dashboard-page/card-display/card-display.component.css
@@ -0,0 +1,58 @@
+:host {
+ max-width: fit-content;
+ overflow: hidden;
+}
+
+.card-display-arrow-button {
+ align-items: center;
+ background-color: #00645c;
+ border: none;
+ border-radius: .25em;
+ color: #fff;
+ display: flex;
+ height: 24px;
+ justify-content: center;
+ transition: all 300ms ease-out;
+ width: 24px;
+}
+
+.card-display-arrow-button:disabled {
+ background-color: #667085;
+}
+
+.card-display-arrow-button:not(:disabled):hover {
+ background-color: #429488;
+}
+
+.card-display-content-container {
+ display: flex;
+ gap: 0 16px;
+ max-width: 100%;
+ overflow-x: hidden;
+ padding: .5px;
+}
+
+.card-display-content-hidden {
+ flex-wrap: wrap;
+ gap: 16px 16px;
+ height: 289px;
+ overflow-y: hidden;
+}
+
+.card-display-content-shown {
+ flex-wrap: wrap;
+ gap: 16px 16px;
+ height: 100%;
+}
+
+.card-display-heading {
+ color: #333;
+ font-size: 20px;
+ font-weight: 500;
+ margin-bottom: 0;
+}
+
+.card-display-arrow-button-container {
+ display: flex;
+ gap: 0 5px;
+}
diff --git a/core/templates/pages/learner-dashboard-page/card-display/card-display.component.html b/core/templates/pages/learner-dashboard-page/card-display/card-display.component.html
index 8fc9b4367b4d3..e8459821f4bdd 100644
--- a/core/templates/pages/learner-dashboard-page/card-display/card-display.component.html
+++ b/core/templates/pages/learner-dashboard-page/card-display/card-display.component.html
@@ -17,64 +17,3 @@
-
-
diff --git a/core/templates/pages/learner-dashboard-page/card-display/card-display.component.ts b/core/templates/pages/learner-dashboard-page/card-display/card-display.component.ts
index d5e7f29fb7e69..f4138cd597e47 100644
--- a/core/templates/pages/learner-dashboard-page/card-display/card-display.component.ts
+++ b/core/templates/pages/learner-dashboard-page/card-display/card-display.component.ts
@@ -25,9 +25,11 @@ import {
NgZone,
} from '@angular/core';
import {I18nLanguageCodeService} from 'services/i18n-language-code.service';
+import './card-display.component.css';
@Component({
selector: 'oppia-card-display',
templateUrl: './card-display.component.html',
+ styleUrls: ['./card-display.component.css'],
})
export class CardDisplayComponent implements AfterContentInit {
@Input() headingI18n!: string;
diff --git a/core/templates/pages/learner-dashboard-page/classroom-button/classroom-button.component.css b/core/templates/pages/learner-dashboard-page/classroom-button/classroom-button.component.css
new file mode 100644
index 0000000000000..5637c791b25ed
--- /dev/null
+++ b/core/templates/pages/learner-dashboard-page/classroom-button/classroom-button.component.css
@@ -0,0 +1,47 @@
+.oppia-learner-dash-button-lg {
+ align-items: center;
+ border: 1px solid;
+ border-radius: 4px;
+ display: inline-flex;
+ font-weight: 700;
+ gap: 0 4px;
+ justify-content: center;
+ max-width: 100%;
+ padding: 10px 0;
+ text-align: center;
+ text-decoration: none;
+ transition: all 300ms ease-out;
+ width: 344px;
+}
+
+.oppia-learner-dash-button-lg:hover {
+ text-decoration: none;
+}
+
+.oppia-learner-dash-button-lg:focus {
+ text-decoration: none;
+}
+
+@media screen and (max-width: 500px) {
+ .oppia-learner-dash-button-lg {
+ padding: 10px 8px;
+ }
+}
+
+.oppia-learner-dash-button--blue {
+ background-color: #fff;
+ border-color: #205d86;
+ color: #205d86;
+}
+
+.oppia-learner-dash-button--blue:hover {
+ background-color: #cadbda;
+ border-color: #205d86;
+ color: #205d86;
+}
+
+.oppia-learner-dash-button--blue:focus {
+ background-color: #429488;
+ border-color: #205d86;
+ color: #fff;
+}
diff --git a/core/templates/pages/learner-dashboard-page/classroom-button/classroom-button.component.html b/core/templates/pages/learner-dashboard-page/classroom-button/classroom-button.component.html
index fa8941b17dad0..42dc7811111d3 100644
--- a/core/templates/pages/learner-dashboard-page/classroom-button/classroom-button.component.html
+++ b/core/templates/pages/learner-dashboard-page/classroom-button/classroom-button.component.html
@@ -3,53 +3,3 @@
{{ 'I18N_LEARNER_DASHBOARD_HOME_CLASSROOM_SECTION_LINK' | translate }}
-
-
diff --git a/core/templates/pages/learner-dashboard-page/classroom-button/classroom-button.component.ts b/core/templates/pages/learner-dashboard-page/classroom-button/classroom-button.component.ts
index 7326f764f326a..f4ac1a6277512 100644
--- a/core/templates/pages/learner-dashboard-page/classroom-button/classroom-button.component.ts
+++ b/core/templates/pages/learner-dashboard-page/classroom-button/classroom-button.component.ts
@@ -17,9 +17,11 @@
*/
import {Component, Input} from '@angular/core';
import {UrlInterpolationService} from 'domain/utilities/url-interpolation.service';
+import './classroom-button.component.css';
@Component({
selector: 'oppia-classroom-button',
templateUrl: './classroom-button.component.html',
+ styleUrls: ['./classroom-button.component.css'],
})
export class ClassroomButtonComponent {
@Input() classroom!: string;
diff --git a/core/templates/pages/learner-dashboard-page/content-toggle-button/content-toggle-button.component.css b/core/templates/pages/learner-dashboard-page/content-toggle-button/content-toggle-button.component.css
new file mode 100644
index 0000000000000..2d3968a8b01f0
--- /dev/null
+++ b/core/templates/pages/learner-dashboard-page/content-toggle-button/content-toggle-button.component.css
@@ -0,0 +1,21 @@
+.content-toggle-button {
+ align-items: center;
+ background-color: #fff;
+ border: none;
+ border-radius: .25em;
+ color: #00645c;
+ display: flex;
+ float: inline-end;
+ font-size: 14px;
+ font-weight: 500;
+ gap: 0 4px;
+ justify-content: center;
+ padding: 8px;
+ transition: all 300ms ease-out;
+ width: fit-content;
+}
+
+.content-toggle-button:not(:disabled):hover .content-toggle-button-text {
+ text-decoration: underline;
+ text-underline-offset: 4px;
+}
diff --git a/core/templates/pages/learner-dashboard-page/content-toggle-button/content-toggle-button.component.html b/core/templates/pages/learner-dashboard-page/content-toggle-button/content-toggle-button.component.html
index 454cd769768ef..7abd813a8b3ef 100644
--- a/core/templates/pages/learner-dashboard-page/content-toggle-button/content-toggle-button.component.html
+++ b/core/templates/pages/learner-dashboard-page/content-toggle-button/content-toggle-button.component.html
@@ -2,27 +2,3 @@
{{ buttonText }}
-
-
diff --git a/core/templates/pages/learner-dashboard-page/content-toggle-button/content-toggle-button.component.ts b/core/templates/pages/learner-dashboard-page/content-toggle-button/content-toggle-button.component.ts
index 3d2f6eb84b895..2004e8dc326cc 100644
--- a/core/templates/pages/learner-dashboard-page/content-toggle-button/content-toggle-button.component.ts
+++ b/core/templates/pages/learner-dashboard-page/content-toggle-button/content-toggle-button.component.ts
@@ -17,9 +17,11 @@
*/
import {Component, EventEmitter, Output} from '@angular/core';
import {TranslateService} from '@ngx-translate/core';
+import './content-toggle-button.component.css';
@Component({
selector: 'oppia-content-toggle-button',
templateUrl: './content-toggle-button.component.html',
+ styleUrls: ['./content-toggle-button.component.css'],
})
export class ContentToggleButtonComponent {
isExpanded: boolean = false;
diff --git a/core/templates/pages/learner-dashboard-page/goal-list/goal-list.component.css b/core/templates/pages/learner-dashboard-page/goal-list/goal-list.component.css
new file mode 100644
index 0000000000000..0edcc1643be2a
--- /dev/null
+++ b/core/templates/pages/learner-dashboard-page/goal-list/goal-list.component.css
@@ -0,0 +1,139 @@
+.goal-list-center {
+ align-items: center;
+ display: flex;
+ gap: 0 12px;
+ position: relative;
+}
+
+.goal-list-container {
+ box-shadow: 0 2px 2px 0 #00000040;
+ width: 100%;
+}
+
+.goal-list-details {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ justify-content: space-between;
+ padding-bottom: 8px;
+ padding-top: 8px;
+}
+
+.goal-list-details p {
+ font-family: 'Roboto', Arial, sans-serif;
+ font-weight: 500;
+}
+
+.goal-list-img {
+ height: 96px;
+ width: 152px;
+}
+
+.goal-list-options {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ justify-content: space-between;
+ position: relative;
+}
+
+.goal-list-progress-bar {
+ background-color: rgb(0, 100, 92, .24);
+ height: 4px;
+ max-width: 185px;
+ width: 100%;
+}
+
+.goal-list-progress-bar>div {
+ background-color: rgb(0, 100, 92, 1);
+ height: 4px;
+}
+
+.goal-list-progress-text {
+ font-size: 10px;
+ margin-bottom: 0;
+}
+
+.goal-list-story-nodes {
+ display: flex;
+ flex-direction: column;
+ max-height: 276px;
+ overflow-y: auto;
+ padding-bottom: 12px;
+ padding-top: 12px;
+ scrollbar-color: #205d86;
+}
+
+.goal-list-story-node-line {
+ border-left: 1px solid;
+ height: 12px;
+}
+
+.goal-list-story-node-line-container {
+ display: flex;
+ justify-content: center;
+ width: 36px;
+}
+
+.goal-list-stretch {
+ align-items: stretch;
+ display: flex;
+ gap: 0 12px;
+}
+
+.goal-list-title {
+ font-size: 20px;
+}
+
+.oppia-learner-dash-button--goals-list {
+ text-decoration: none;
+ width: 96px;
+}
+
+.oppia-learner-dash-button--incomplete-goal {
+ opacity: 0;
+}
+
+.oppia-learner-dash-button--incomplete-goal:hover {
+ opacity: 1;
+}
+
+@media screen and (max-width: 500px) {
+ .goal-list-details {
+ width: 100%;
+ }
+
+ .goal-list-img {
+ height: 187px;
+ width: 100%;
+ }
+
+ .goal-list-options {
+ align-items: center;
+ flex-direction: row-reverse;
+ padding-top: 8px;
+ width: 100%;
+ }
+
+ .goal-list-stretch {
+ flex-direction: column;
+ width: 100%;
+ }
+
+ .goal-list-title {
+ font-size: 18px;
+ margin-bottom: 8px;
+ }
+}
+.oppia-learner-dash-button--disabled {
+ background-color: #f5f5f5;
+ border: 1px dashed #aaa;
+ border-radius: 4px;
+ color: #888;
+ cursor: default;
+ font-size: 14px;
+ font-weight: 500;
+ height: 36px;
+ padding: 10px 8px;
+ width: 122px;
+}
diff --git a/core/templates/pages/learner-dashboard-page/goal-list/goal-list.component.html b/core/templates/pages/learner-dashboard-page/goal-list/goal-list.component.html
index 3ec430cd1ac63..231df016559a6 100644
--- a/core/templates/pages/learner-dashboard-page/goal-list/goal-list.component.html
+++ b/core/templates/pages/learner-dashboard-page/goal-list/goal-list.component.html
@@ -61,145 +61,3 @@
-
-
diff --git a/core/templates/pages/learner-dashboard-page/goal-list/goal-list.component.ts b/core/templates/pages/learner-dashboard-page/goal-list/goal-list.component.ts
index 30de89dc05f15..0bd116ae750c1 100644
--- a/core/templates/pages/learner-dashboard-page/goal-list/goal-list.component.ts
+++ b/core/templates/pages/learner-dashboard-page/goal-list/goal-list.component.ts
@@ -26,10 +26,12 @@ import {LearnerTopicSummary} from 'domain/topic/learner-topic-summary.model';
import {StorySummary} from 'domain/story/story-summary.model';
import {StoryNode} from 'domain/story/story-node.model';
import {PlatformFeatureService} from 'services/platform-feature.service';
+import './goal-list.component.css';
@Component({
selector: 'oppia-goal-list',
templateUrl: './goal-list.component.html',
+ styleUrls: ['./goal-list.component.css'],
})
export class GoalListComponent implements OnInit {
@Input() goalTopic!: LearnerTopicSummary;
diff --git a/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.css b/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.css
index 0726d368f6a82..ceaa7b8671462 100644
--- a/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.css
+++ b/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.css
@@ -688,3 +688,15 @@ oppia-learner-dashboard-page .oppia-learner-groups-icon {
font-size: 24px;
word-wrap: break-word;
}
+
+.oppia-profile-alignment {
+ vertical-align: middle;
+}
+.oppia-box-size {
+ padding: 2px 10px;
+}
+.col-md-3 {
+ flex: 0 0 25%;
+ padding-left: 15px;
+ padding-right: 15px;
+}
diff --git a/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.html b/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.html
index 2380405ecb831..5fb20573f1ab2 100644
--- a/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.html
+++ b/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.html
@@ -203,17 +203,3 @@
-
-
diff --git a/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.ts b/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.ts
index 47a8eb0dbd7c7..9096fa90a8435 100644
--- a/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.ts
+++ b/core/templates/pages/learner-dashboard-page/learner-dashboard-page.component.ts
@@ -53,6 +53,7 @@ import {PageTitleService} from 'services/page-title.service';
import {LearnerGroupBackendApiService} from 'domain/learner_group/learner-group-backend-api.service';
import {UrlService} from 'services/contextual/url.service';
import {PlatformFeatureService} from 'services/platform-feature.service';
+import './learner-dashboard-page.component.css';
interface LearnerDashboardExplorationsData {
completedExplorationsList: LearnerExplorationSummary[];
diff --git a/core/templates/pages/learner-dashboard-page/modal-templates/decline-invitaiton-modal.component.ts b/core/templates/pages/learner-dashboard-page/modal-templates/decline-invitaiton-modal.component.ts
index 253a0fe9d8eb4..7a8e3aeba8550 100644
--- a/core/templates/pages/learner-dashboard-page/modal-templates/decline-invitaiton-modal.component.ts
+++ b/core/templates/pages/learner-dashboard-page/modal-templates/decline-invitaiton-modal.component.ts
@@ -19,10 +19,12 @@
import {Component} from '@angular/core';
import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-elements/confirm-or-cancel-modal.component';
+import './decline-invitation-modal.component.css';
@Component({
selector: 'oppia-decline-invitation-modal',
templateUrl: './decline-invitation-modal.component.html',
+ styleUrls: ['./decline-invitation-modal.component.css'],
})
export class DeclineInvitationModalComponent extends ConfirmOrCancelModal {
learnerGroupTitle!: string;
diff --git a/core/templates/pages/learner-dashboard-page/modal-templates/decline-invitation-modal.component.css b/core/templates/pages/learner-dashboard-page/modal-templates/decline-invitation-modal.component.css
new file mode 100644
index 0000000000000..dc65574dd7989
--- /dev/null
+++ b/core/templates/pages/learner-dashboard-page/modal-templates/decline-invitation-modal.component.css
@@ -0,0 +1,13 @@
+.btn-primary {
+ background-color: #00645c;
+ border: solid 1px #00645c;
+ margin-right: 20px;
+ padding: 6px 16px;
+}
+
+.btn-danger {
+ background: #fff;
+ border: solid 1px #a53417;
+ color: #a53417;
+ padding: 6px 16px;
+}
diff --git a/core/templates/pages/learner-dashboard-page/modal-templates/decline-invitation-modal.component.html b/core/templates/pages/learner-dashboard-page/modal-templates/decline-invitation-modal.component.html
index ced46303220b5..3d532c806d1cb 100644
--- a/core/templates/pages/learner-dashboard-page/modal-templates/decline-invitation-modal.component.html
+++ b/core/templates/pages/learner-dashboard-page/modal-templates/decline-invitation-modal.component.html
@@ -8,19 +8,3 @@
-
-
diff --git a/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-details-modal.component.css b/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-details-modal.component.css
new file mode 100644
index 0000000000000..9d701c0397737
--- /dev/null
+++ b/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-details-modal.component.css
@@ -0,0 +1,26 @@
+.modal-header-title {
+ border-bottom: solid 1px #e0e0e0;
+}
+
+.modal-header-title p {
+ margin: 0;
+}
+
+.primary-color {
+ color: #00645c;
+}
+
+.modal-header .btn-close {
+ cursor: pointer;
+ margin: 0.75rem 0 0.75rem auto;
+ opacity: 0.5;
+}
+
+.modal-body .group-title,
+.modal-body .group-description {
+ margin-bottom: 15px;
+}
+
+.modal-footer {
+ display: none;
+}
diff --git a/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-details-modal.component.html b/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-details-modal.component.html
index 78bb3adf2301b..460c00e75ef6b 100644
--- a/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-details-modal.component.html
+++ b/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-details-modal.component.html
@@ -28,32 +28,3 @@
-
-
diff --git a/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-details-modal.component.ts b/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-details-modal.component.ts
index 1ad12012818b1..eac59fed709f4 100644
--- a/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-details-modal.component.ts
+++ b/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-details-modal.component.ts
@@ -20,10 +20,12 @@ import {Component} from '@angular/core';
import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-elements/confirm-or-cancel-modal.component';
import {ShortLearnerGroupSummary} from 'domain/learner_group/short-learner-group-summary.model';
+import './view-learner-group-details-modal.component.css';
@Component({
selector: 'oppia-view-learner-group-details-modal',
templateUrl: './view-learner-group-details-modal.component.html',
+ styleUrls: ['./view-learner-group-details-modal.component.css'],
})
export class ViewLearnerGroupDetailsModalComponent extends ConfirmOrCancelModal {
learnerGroup!: ShortLearnerGroupSummary;
diff --git a/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-invitation-modal.component.css b/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-invitation-modal.component.css
new file mode 100644
index 0000000000000..4afb61728a92d
--- /dev/null
+++ b/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-invitation-modal.component.css
@@ -0,0 +1,45 @@
+.modal-header-title {
+ border-bottom: solid 1px #e0e0e0;
+}
+
+.modal-header-title p {
+ margin: 0;
+}
+
+.primary-color {
+ color: #00645c;
+}
+
+.btn-primary {
+ background-color: #00645c;
+ border: solid 1px #00645c;
+ margin-right: 20px;
+ padding: 6px 16px;
+}
+
+.btn-secondary {
+ border: solid 1px #00645c;
+ color: #00645c;
+ margin-right: 20px;
+ padding: 6px 16px;
+}
+
+.btn-danger {
+ background: #fff;
+ border: solid 1px #a53417;
+ color: #a53417;
+ padding: 6px 16px;
+}
+
+.modal-header .btn-close {
+ cursor: pointer;
+ margin: 0.75rem 0 0.75rem auto;
+ opacity: 0.5;
+}
+
+.group-title,
+.group-description,
+.learner-groups-progress-sharing-info,
+.group-facilitator {
+ margin-bottom: 15px;
+}
diff --git a/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-invitation-modal.component.html b/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-invitation-modal.component.html
index 5d74af53f9c46..c63b7d3ed38da 100644
--- a/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-invitation-modal.component.html
+++ b/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-invitation-modal.component.html
@@ -43,51 +43,3 @@
-
-
diff --git a/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-invitation-modal.component.ts b/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-invitation-modal.component.ts
index 4cf30778836e7..1a3351b48ee4d 100644
--- a/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-invitation-modal.component.ts
+++ b/core/templates/pages/learner-dashboard-page/modal-templates/view-learner-group-invitation-modal.component.ts
@@ -20,10 +20,12 @@ import {Component} from '@angular/core';
import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-elements/confirm-or-cancel-modal.component';
import {ShortLearnerGroupSummary} from 'domain/learner_group/short-learner-group-summary.model';
+import './view-learner-group-invitation-modal.component.css';
@Component({
selector: 'oppia-view-learner-group-invitation-modal',
templateUrl: './view-learner-group-invitation-modal.component.html',
+ styleUrls: ['./view-learner-group-invitation-modal.component.css'],
})
export class ViewLearnerGroupInvitationModalComponent extends ConfirmOrCancelModal {
learnerGroup!: ShortLearnerGroupSummary;
diff --git a/core/templates/pages/learner-dashboard-page/old-progress-tab.component.css b/core/templates/pages/learner-dashboard-page/old-progress-tab.component.css
new file mode 100644
index 0000000000000..700a67f0c088b
--- /dev/null
+++ b/core/templates/pages/learner-dashboard-page/old-progress-tab.component.css
@@ -0,0 +1,313 @@
+.progress-tab .completed-stories {
+ background-color: #fff;
+ border-radius: 4px;
+ box-shadow: 0 0 4px #0000001a;
+ margin-top: 15px;
+ min-height: 342px;
+ opacity: 1;
+ padding: 0 0 30px 40px;
+ width: 736px;
+}
+.progress-tab .empty-completed-stories {
+ background-color: #fff;
+ border-radius: 4px;
+ box-shadow: 0 0 4px #0000001a;
+ margin-top: 15px;
+ min-height: 206px;
+ opacity: 1;
+ padding: 0 0 30px 40px;
+ width: 360px;
+}
+.progress-tab .skill-proficiency {
+ background-color: #fff;
+ border-radius: 4px;
+ box-shadow: 0 0 4px #0000001a;
+ min-height: 220px;
+ opacity: 1;
+ padding: 0 30px 30px 40px;
+ width: 736px;
+}
+#setting-a-goal-link {
+ color: #00645c;
+ font-weight: bold;
+}
+.progress-tab .empty-section {
+ padding: 0 113px 30px 40px;
+}
+.progress-tab .completed-stories .completed-stories-title,
+.progress-tab .empty-completed-stories .completed-stories-title,
+.progress-tab .skill-proficiency-title {
+ color: #00645c;
+ display: inline-block;
+ font-family: "Capriola", "Roboto", Arial, sans-serif;
+ font-size: 24px;
+ letter-spacing: 0;
+ opacity: 1;
+ text-align: left;
+}
+.progress-tab .empty-section .skill-proficiency-title {
+ margin-bottom: 0;
+}
+.progress-tab .empty-skill-proficiency-section {
+ font-size: 18px;
+}
+.progress-tab .empty-skill-proficiency {
+ display: inline;
+ font-size: 18px;
+ margin-bottom: 0;
+ text-align: left;
+}
+.oppia-badge-image {
+ margin-right: 12px;
+ width: 32px;
+}
+.oppia-badge-image-for-topic {
+ bottom: 6px;
+ float: left;
+ margin-right: 20px;
+ position: relative;
+ width: 31px;
+}
+.progress-tab .skill-proficiency-topic-tile {
+ border-bottom: 1px solid;
+ height: 43px;
+ margin-bottom: 16px;
+ width: 656px;
+}
+.progress-tab .skill-proficiency-topic-title {
+ color: #333;
+ font-size: 19px;
+ font-weight: 500;
+ letter-spacing: 0;
+ opacity: 1;
+ position: relative;
+ text-align: left;
+}
+.progress-tab .progress-text {
+ bottom: 20px;
+ color: #00645c;
+ float: right;
+ font-size: 14px;
+ letter-spacing: 0;
+ opacity: 1;
+ position: relative;
+ right: 20px;
+ text-align: left;
+}
+.progress-tab .circle {
+ background-color: #fff;
+ border-radius: 100%;
+ height: 16px;
+ left: 6px;
+ position: relative;
+ text-align: center;
+ top: 6px;
+ width: 16px;
+}
+.progress-tab .circle-border {
+ background-color: #00645c;
+ border-radius: 100%;
+ bottom: 24px;
+ display: inline-block;
+ height: 28px;
+ left: 230px;
+ margin-left: 30%;
+ position: relative;
+ text-align: center;
+ width: 28px;
+}
+.progress-tab .info-tooltip {
+ display: inline-block;
+ opacity: 1;
+ position: relative;
+ text-decoration: none;
+}
+.progress-tab .info-tooltip span {
+ background: white;
+ -moz-border-radius: 6px;
+ -webkit-border-radius: 6px;
+ border-radius: 6px;
+ color: black;
+ display: none;
+}
+.progress-tab .progress-translate {
+ display: inline-block;
+}
+.progress-tab .info-tooltip span img {
+ float: left;
+ margin: 0 8px 8px 0;
+}
+.progress-tab .info-tooltip:hover span {
+ border: 0.5px solid #ccc;
+ border-radius: 4px;
+ box-shadow: 0 3px 6px #00000029;
+ display: block;
+ height: 155px;
+ margin-left: 32px;
+ padding: 9px 16px;
+ position: absolute;
+ top: -37px;
+ width: 157px;
+ z-index: 2;
+}
+.progress-tab .badge-type {
+ color: #333;
+ font-size: 16px;
+ margin-bottom: 0;
+ text-align: left;
+}
+.progress-tab .badge-percent {
+ color: #555;
+ font-size: 12px;
+ font-style: italic;
+ text-align: left;
+}
+.progress-tab .bg-info {
+ background-color: #fff;
+ color: #000;
+ margin-bottom: 16px;
+}
+.progress-tab .completed-stories-content {
+ display: inline-block;
+}
+.progress-tab .story-tile {
+ display: inline-block;
+ padding: 0 30px 0 0;
+}
+.progress-tab .empty-completed-stories-content {
+ font-size: 16px;
+ padding: 0 32px 0 0;
+}
+.progress-tab .completed-stories .completed-stories-heading,
+.progress-tab .empty-completed-stories .completed-stories-heading,
+.progress-tab .skill-proficiency-heading {
+ padding: 32px 0;
+}
+.progress-tab .completed-stories-length {
+ bottom: 3px;
+ font-size: 18px;
+ margin: 0 4px 0 12px;
+ position: relative;
+}
+.progress-tab .oppia-svg-star-image {
+ bottom: 4px;
+ filter: invert(95%) sepia(49%) saturate(2374%) hue-rotate(320deg) brightness(91%) contrast(89%);
+ position: relative;
+ width: 20px;
+}
+.progress-tab .skills-text {
+ color: #333;
+ font-size: 18px;
+ margin-bottom: 0;
+ text-align: left;
+ text-transform: uppercase;
+}
+.progress-tab .oppia-svg-dropdown-image,
+.progress-tab .oppia-svg-up-image {
+ bottom: 5px;
+ float: right;
+ position: relative;
+ top: 2px;
+ width: 30px;
+}
+.progress-tab .oppia-svg-info-image {
+ bottom: 3px;
+ left: 10px;
+ position: relative;
+ width: 20px;
+}
+@media screen and (min-width: 768px) and (max-width: 950px) {
+ .progress-tab .completed-stories {
+ margin-top: 35px;
+ padding: 0 0 30px 25px;
+ position: relative;
+ right: 20px;
+ width: 555px;
+ }
+ .progress-tab .empty-completed-stories {
+ position: relative;
+ right: 25px;
+ width: 555px;
+ }
+ .progress-tab .circle-border {
+ left: 88px;
+ }
+ .progress-tab .scroll-content {
+ overflow: scroll;
+ }
+ .progress-tab .oppia-story-completion {
+ overflow: hidden;
+ }
+ .progress-tab .skill-proficiency {
+ position: relative;
+ right: 21px;
+ width: 555px;
+ }
+ .progress-tab .skill-proficiency-topic-tile {
+ width: 455px;
+ }
+ .progress-tab .skills-text {
+ font-size: 18px;
+ }
+ .progress-tab .skill-proficiency-title {
+ font-size: 20px;
+ margin-bottom: 0;
+ }
+}
+@media screen and (max-width: 767px) {
+ .progress-tab .empty-completed-stories {
+ margin: auto;
+ }
+ .progress-tab .completed-stories {
+ margin: auto;
+ margin-top: 35px;
+ padding: 0 0 30px 25px;
+ width: 360px;
+ }
+ .progress-tab .skill-proficiency {
+ margin: auto;
+ width: 360px;
+ }
+ .progress-tab .skill-proficiency-topic-tile {
+ height: 52px;
+ width: 280px;
+ }
+ .progress-tab .circle-border {
+ display: none;
+ }
+ .progress-tab .skill-proficiency-topic-title {
+ font-size: 16px;
+ }
+ .progress-tab .skills-text {
+ display: none;
+ }
+ .progress-tab .progress-text {
+ display: contents;
+ font-style: italic;
+ }
+ .progress-tab .skill-proficiency-title,
+ .progress-tab .completed-stories-title {
+ font-size: 20px;
+ margin-bottom: 0;
+ }
+ .progress-tab .story-tile {
+ padding: 0 14px 0 0;
+ }
+ .progress-tab .scroll-content {
+ overflow: scroll;
+ }
+ .progress-tab .skill-proficiency-heading {
+ padding: 23px 0;
+ }
+ .progress-tab .oppia-story-completion {
+ overflow: hidden;
+ }
+ .progress-tab .empty-section {
+ padding: 0 50px 30px 40px;
+ }
+}
+@media(max-width: 430px) {
+ .progress-tab .empty-completed-stories {
+ width: 355px;
+ }
+}
diff --git a/core/templates/pages/learner-dashboard-page/old-progress-tab.component.html b/core/templates/pages/learner-dashboard-page/old-progress-tab.component.html
index eec665a9077ae..7cc05a8ae3a8a 100644
--- a/core/templates/pages/learner-dashboard-page/old-progress-tab.component.html
+++ b/core/templates/pages/learner-dashboard-page/old-progress-tab.component.html
@@ -131,318 +131,3 @@
-
diff --git a/core/templates/pages/learner-dashboard-page/old-progress-tab.component.ts b/core/templates/pages/learner-dashboard-page/old-progress-tab.component.ts
index ba805fbafe1a3..4bfbf9e3e72fe 100644
--- a/core/templates/pages/learner-dashboard-page/old-progress-tab.component.ts
+++ b/core/templates/pages/learner-dashboard-page/old-progress-tab.component.ts
@@ -28,10 +28,12 @@ import {
} from 'domain/learner_dashboard/learner-dashboard-backend-api.service';
import {Subscription} from 'rxjs';
import {WindowDimensionsService} from 'services/contextual/window-dimensions.service';
+import './old-progress-tab.component.css';
@Component({
selector: 'oppia-old-progress-tab',
templateUrl: './old-progress-tab.component.html',
+ styleUrls: ['./old-progress-tab.component.css'],
})
export class OldProgressTabComponent implements OnInit {
@Output() setActiveSection: EventEmitter = new EventEmitter();
diff --git a/core/templates/pages/learner-dashboard-page/skill-card/skill-card.component.css b/core/templates/pages/learner-dashboard-page/skill-card/skill-card.component.css
new file mode 100644
index 0000000000000..94be3837d0ed4
--- /dev/null
+++ b/core/templates/pages/learner-dashboard-page/skill-card/skill-card.component.css
@@ -0,0 +1,50 @@
+.oppia-learner-dash-button--skill {
+ background-color: transparent;
+ border-color: #fff;
+ color: #fff;
+ width: 122px;
+}
+
+.oppia-learner-dash-button--skill:hover {
+ background-color: #fff;
+ color: #c55f45;
+}
+
+.skill-card-container {
+ background-color: #c55f45;
+ border-radius: 4px;
+ box-shadow: 0 2px 2px 0 #00000040;
+ height: 260px;
+ padding: 0;
+ width: 216px;
+}
+
+.skill-card-info {
+ border-radius: 0 0 4px 4px;
+ height: 124px;
+ padding: 8px;
+}
+
+.skill-card-img {
+ height: 136px;
+ max-height: 100%;
+ position: relative;
+}
+
+.skill-card-title {
+ color: #fff;
+ font-family: Capriola, Roboto, Arial, sans-serif;
+ font-size: 15px;
+ -webkit-line-clamp: 2;
+ line-height: 20px;
+}
+
+.skill-card-title-container {
+ height: 40px;
+}
+
+.skill-card-topic {
+ color: #fff;
+ font-size: 12px;
+ font-weight: 600;
+}
diff --git a/core/templates/pages/learner-dashboard-page/skill-card/skill-card.component.html b/core/templates/pages/learner-dashboard-page/skill-card/skill-card.component.html
index 321ae39720bf0..7b4a8c28459a1 100644
--- a/core/templates/pages/learner-dashboard-page/skill-card/skill-card.component.html
+++ b/core/templates/pages/learner-dashboard-page/skill-card/skill-card.component.html
@@ -22,56 +22,3 @@
-
-
diff --git a/core/templates/pages/learner-dashboard-page/skill-card/skill-card.component.ts b/core/templates/pages/learner-dashboard-page/skill-card/skill-card.component.ts
index 367a5f77e6a72..2fbff20b4f4fb 100644
--- a/core/templates/pages/learner-dashboard-page/skill-card/skill-card.component.ts
+++ b/core/templates/pages/learner-dashboard-page/skill-card/skill-card.component.ts
@@ -26,9 +26,11 @@ import {AssetsBackendApiService} from 'services/assets-backend-api.service';
import {LoaderService} from 'services/loader.service';
import {SiteAnalyticsService} from 'services/site-analytics.service';
import {WindowRef} from 'services/contextual/window-ref.service';
+import './skill-card.component.css';
@Component({
selector: 'oppia-skill-card',
templateUrl: './skill-card.component.html',
+ styleUrls: ['./skill-card.component.css'],
})
export class SkillCardComponent {
@Input() topic!: LearnerTopicSummary;
diff --git a/core/templates/pages/learner-dashboard-page/suggestion-modal/learner-dashboard-suggestion-modal.component.css b/core/templates/pages/learner-dashboard-page/suggestion-modal/learner-dashboard-suggestion-modal.component.css
new file mode 100644
index 0000000000000..7386e064ea906
--- /dev/null
+++ b/core/templates/pages/learner-dashboard-page/suggestion-modal/learner-dashboard-suggestion-modal.component.css
@@ -0,0 +1,8 @@
+.oppia-learner-dashboard-suggestion-body .oppia-no-current-state {
+ color: red;
+}
+.oppia-learner-dashboard-suggestion-body .oppia-dashboard-suggestin-description-text {
+ margin-left: 10px;
+ margin-right: 10px;
+ margin-top: 20px;
+}
diff --git a/core/templates/pages/learner-dashboard-page/suggestion-modal/learner-dashboard-suggestion-modal.component.html b/core/templates/pages/learner-dashboard-page/suggestion-modal/learner-dashboard-suggestion-modal.component.html
index 05fc5b1d362fe..7d4d3107012b0 100644
--- a/core/templates/pages/learner-dashboard-page/suggestion-modal/learner-dashboard-suggestion-modal.component.html
+++ b/core/templates/pages/learner-dashboard-page/suggestion-modal/learner-dashboard-suggestion-modal.component.html
@@ -34,13 +34,3 @@
-
diff --git a/core/templates/pages/learner-dashboard-page/suggestion-modal/learner-dashboard-suggestion-modal.component.ts b/core/templates/pages/learner-dashboard-page/suggestion-modal/learner-dashboard-suggestion-modal.component.ts
index bdb1a9385082a..a5f41ca6fdc98 100644
--- a/core/templates/pages/learner-dashboard-page/suggestion-modal/learner-dashboard-suggestion-modal.component.ts
+++ b/core/templates/pages/learner-dashboard-page/suggestion-modal/learner-dashboard-suggestion-modal.component.ts
@@ -20,11 +20,12 @@ import {Component, Input} from '@angular/core';
import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-elements/confirm-or-cancel-modal.component';
+import './learner-dashboard-suggestion-modal.component.css';
@Component({
selector: 'oppia-learner-dashboard-suggestion-modal',
templateUrl: './learner-dashboard-suggestion-modal.component.html',
- styleUrls: [],
+ styleUrls: ['./learner-dashboard-suggestion-modal.component.css'],
})
export class LearnerDashboardSuggestionModalComponent extends ConfirmOrCancelModal {
// These properties are initialized using Angular lifecycle hooks
diff --git a/core/templates/pages/learner-group-pages/templates/delete-learner-group-modal.component.css b/core/templates/pages/learner-group-pages/templates/delete-learner-group-modal.component.css
new file mode 100644
index 0000000000000..dc65574dd7989
--- /dev/null
+++ b/core/templates/pages/learner-group-pages/templates/delete-learner-group-modal.component.css
@@ -0,0 +1,13 @@
+.btn-primary {
+ background-color: #00645c;
+ border: solid 1px #00645c;
+ margin-right: 20px;
+ padding: 6px 16px;
+}
+
+.btn-danger {
+ background: #fff;
+ border: solid 1px #a53417;
+ color: #a53417;
+ padding: 6px 16px;
+}
diff --git a/core/templates/pages/learner-group-pages/templates/delete-learner-group-modal.component.html b/core/templates/pages/learner-group-pages/templates/delete-learner-group-modal.component.html
index 1f26029901c58..b49d18453a0f3 100644
--- a/core/templates/pages/learner-group-pages/templates/delete-learner-group-modal.component.html
+++ b/core/templates/pages/learner-group-pages/templates/delete-learner-group-modal.component.html
@@ -12,19 +12,3 @@
-
-
diff --git a/core/templates/pages/learner-group-pages/templates/delete-learner-group-modal.component.ts b/core/templates/pages/learner-group-pages/templates/delete-learner-group-modal.component.ts
index fc4c437aa0651..287e182e1e95e 100644
--- a/core/templates/pages/learner-group-pages/templates/delete-learner-group-modal.component.ts
+++ b/core/templates/pages/learner-group-pages/templates/delete-learner-group-modal.component.ts
@@ -19,10 +19,12 @@
import {Component} from '@angular/core';
import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
import {ConfirmOrCancelModal} from 'components/common-layout-directives/common-elements/confirm-or-cancel-modal.component';
+import './delete-learner-group-modal.component.css';
@Component({
selector: 'oppia-delete-learner-group-modal',
templateUrl: './delete-learner-group-modal.component.html',
+ styleUrls: ['./delete-learner-group-modal.component.css'],
})
export class DeleteLearnerGroupModalComponent extends ConfirmOrCancelModal {
learnerGroupTitle!: string;
diff --git a/core/templates/pages/library-page/library-page.component.html b/core/templates/pages/library-page/library-page.component.html
index dcb60fbb86358..1ed51925451ea 100644
--- a/core/templates/pages/library-page/library-page.component.html
+++ b/core/templates/pages/library-page/library-page.component.html
@@ -111,7 +111,8 @@