From f7f41e5e4177ca8ba6979133c69978e4e39791e4 Mon Sep 17 00:00:00 2001 From: Afzal Khan Date: Tue, 11 Jun 2024 11:35:56 -0400 Subject: [PATCH] [GSoC'24] M1.2 (a): Update classroom storage model and functions (#20389) * Update classroom storage model and backend * create util function for creating a classroom * fix classroom_test * fix merge conflict * create structure for classroom image * Add ImageDict structure * comment classroomPage and classroomPageFileUploadFeatures e2e test * add e2e test * fix test * Create a single function for banner and thumbnail validation * Change banner to banner_data * fix create classroom frontend * store image data separately * Add test for invalid classroom id * use image domain object instead of dict * add test for image object * Fix linter * change Image to ImageData and dropped image_data * add test for update or create classroom * fix classroomPage test * add function for saving a new default classroom --- assets/constants.ts | 4 + core/controllers/access_validators_test.py | 15 +- core/controllers/acl_decorators_test.py | 15 +- core/controllers/admin.py | 40 ++- core/controllers/android_test.py | 33 +-- core/controllers/classroom_test.py | 60 ++-- .../controllers/contributor_dashboard_test.py | 23 +- core/controllers/learner_dashboard_test.py | 26 +- core/controllers/topic_editor_test.py | 10 +- .../topics_and_skills_dashboard_test.py | 19 +- core/domain/android_services.py | 4 +- core/domain/android_services_test.py | 6 +- core/domain/classroom_config_domain.py | 279 ++++++++++++++++-- core/domain/classroom_config_domain_test.py | 261 +++++++++++++++- core/domain/classroom_config_services.py | 98 +++++- core/domain/classroom_config_services_test.py | 107 ++++++- core/domain/learner_group_services_test.py | 12 +- core/domain/learner_progress_services_test.py | 18 +- core/domain/skill_services_test.py | 20 +- core/feconf.py | 3 + core/storage/classroom/gae_models.py | 54 +++- core/storage/classroom/gae_models_test.py | 47 ++- .../classroom-backend-api.service.spec.ts | 24 ++ .../classroom-backend-api.service.ts | 26 +- core/tests/test_utils.py | 75 +++++ 25 files changed, 1002 insertions(+), 277 deletions(-) diff --git a/assets/constants.ts b/assets/constants.ts index 3f4578bd8fdc..9f76c64a3af6 100644 --- a/assets/constants.ts +++ b/assets/constants.ts @@ -416,6 +416,7 @@ export default { "ALLOWED_THUMBNAIL_BG_COLORS": { "chapter": ["#F8BF74", "#D68F78", "#8EBBB6", "#B3D8F1"], + "classroom": ["transparent", "#C8F5CD", "#AED2E9"], "topic": ["#C6DCDA"], "subtopic": ["#FFFFFF"], "story": ["#F8BF74", "#D68F78", "#8EBBB6", "#B3D8F1"] @@ -6196,6 +6197,9 @@ export default { // 'math' is the 'classroom URL fragment'. "MAX_CHARS_IN_CLASSROOM_URL_FRAGMENT": 20, "MAX_CHARS_IN_CLASSROOM_NAME": 39, + "MAX_CHARS_IN_CLASSROOM_TEASER_TEXT": 68, + "MAX_CHARS_IN_CLASSROOM_COURSE_DETAILS": 720, + "MAX_CHARS_IN_CLASSROOM_TOPIC_LIST_INTRO": 240, "MAX_CHARS_IN_TOPIC_NAME": 39, "MAX_CHARS_IN_ABBREV_TOPIC_NAME": 12, // This represents the maximum number of characters in the URL fragment for diff --git a/core/controllers/access_validators_test.py b/core/controllers/access_validators_test.py index 012bdf1118dd..ccb44e458f68 100644 --- a/core/controllers/access_validators_test.py +++ b/core/controllers/access_validators_test.py @@ -20,8 +20,6 @@ from core import feature_flag_list from core import feconf -from core.domain import classroom_config_domain -from core.domain import classroom_config_services from core.domain import learner_group_fetchers from core.domain import learner_group_services from core.domain import rights_manager @@ -58,18 +56,7 @@ def setUp(self) -> None: self.get_user_id_from_email(self.CURRICULUM_ADMIN_EMAIL)) self.signup(self.EDITOR_EMAIL, self.EDITOR_USERNAME) self.editor_id = self.get_user_id_from_email(self.EDITOR_EMAIL) - math_classroom_dict: classroom_config_domain.ClassroomDict = { - 'classroom_id': 'math_classroom_id', - 'name': 'math', - 'url_fragment': 'math', - 'course_details': 'Course details for classroom.', - 'topic_list_intro': 'Topics covered for classroom', - 'topic_id_to_prerequisite_topic_ids': {} - } - math_classroom = classroom_config_domain.Classroom.from_dict( - math_classroom_dict) - - classroom_config_services.create_new_classroom(math_classroom) + self.save_new_valid_classroom() def test_validation_returns_true_if_classroom_is_available(self) -> None: self.login(self.EDITOR_EMAIL) diff --git a/core/controllers/acl_decorators_test.py b/core/controllers/acl_decorators_test.py index dfbea78b87b3..c794f7756375 100644 --- a/core/controllers/acl_decorators_test.py +++ b/core/controllers/acl_decorators_test.py @@ -30,8 +30,6 @@ from core.domain import blog_services from core.domain import classifier_domain from core.domain import classifier_services -from core.domain import classroom_config_domain -from core.domain import classroom_config_services from core.domain import exp_domain from core.domain import exp_services from core.domain import feedback_services @@ -979,18 +977,7 @@ def setUp(self) -> None: self.signup(self.EDITOR_EMAIL, self.EDITOR_USERNAME) self.editor_id = self.get_user_id_from_email(self.EDITOR_EMAIL) - math_classroom_dict: classroom_config_domain.ClassroomDict = { - 'classroom_id': 'math_classroom_id', - 'name': 'math', - 'url_fragment': 'math', - 'course_details': 'Course details for classroom.', - 'topic_list_intro': 'Topics covered for classroom', - 'topic_id_to_prerequisite_topic_ids': {} - } - math_classroom = classroom_config_domain.Classroom.from_dict( - math_classroom_dict) - - classroom_config_services.create_new_classroom(math_classroom) + self.save_new_valid_classroom() self.mock_testapp = webtest.TestApp(webapp2.WSGIApplication( [webapp2.Route( diff --git a/core/controllers/admin.py b/core/controllers/admin.py index 5e68ef962c06..2aecab90ac5d 100644 --- a/core/controllers/admin.py +++ b/core/controllers/admin.py @@ -1195,11 +1195,6 @@ def _generate_dummy_classroom(self) -> None: self.user_id, question_id_15, skill_id_5, 0.5) classroom_id_1 = classroom_config_services.get_new_classroom_id() - - classroom_name_1 = 'Math' - - classroom_url_fragment_1 = 'math' - topic_dependency_for_classroom_1: Dict[str, list[str]] = { topic_id_1: [], topic_id_2: [topic_id_1], @@ -1207,22 +1202,25 @@ def _generate_dummy_classroom(self) -> None: topic_id_4: [topic_id_2], topic_id_5: [topic_id_2, topic_id_3] } - - classroom_dict_1: classroom_config_domain.ClassroomDict = { - 'classroom_id': classroom_id_1, - 'name': classroom_name_1, - 'url_fragment': classroom_url_fragment_1, - 'course_details': '', - 'topic_list_intro': '', - 'topic_id_to_prerequisite_topic_ids': ( - topic_dependency_for_classroom_1) - } - - classroom_1 = classroom_config_domain.Classroom.from_dict( - classroom_dict_1) - - classroom_config_services.update_or_create_classroom_model( - classroom_1) + classroom_1 = classroom_config_domain.Classroom( + classroom_id=classroom_id_1, + name='math', + url_fragment='math', + course_details='Math course details', + teaser_text='Math teaser text', + topic_list_intro='Start with our first topic.', + topic_id_to_prerequisite_topic_ids=( + topic_dependency_for_classroom_1), + is_published=True, + thumbnail_data=classroom_config_domain.ImageData( + 'thumbnail.svg', 'transparent', 1000 + ), + banner_data=classroom_config_domain.ImageData( + 'banner.png', 'transparent', 1000 + ) + ) + + classroom_config_services.create_new_classroom(classroom_1) else: raise Exception('Cannot generate dummy classroom in production.') diff --git a/core/controllers/android_test.py b/core/controllers/android_test.py index 499fb1f7912b..80e7850a9361 100644 --- a/core/controllers/android_test.py +++ b/core/controllers/android_test.py @@ -17,7 +17,6 @@ from __future__ import annotations from core.constants import constants -from core.domain import classroom_config_domain from core.domain import classroom_config_services from core.domain import exp_domain from core.domain import exp_fetchers @@ -350,19 +349,9 @@ def test_get_subtopic_returns_correct_json(self) -> None: def test_get_classroom_returns_correct_json(self) -> None: classroom_id = classroom_config_services.get_new_classroom_id() - classroom_dict: classroom_config_domain.ClassroomDict = { - 'classroom_id': classroom_id, - 'name': 'Math', - 'url_fragment': 'math', - 'course_details': '', - 'topic_list_intro': '', - 'topic_id_to_prerequisite_topic_ids': {} - } - - classroom = classroom_config_domain.Classroom.from_dict( - classroom_dict) - - classroom_config_services.update_or_create_classroom_model(classroom) + classroom = self.save_new_valid_classroom( + classroom_id=classroom_id, name='math' + ) with self.secrets_swap: self.assertEqual( self.get_json( @@ -380,19 +369,9 @@ def test_get_classroom_returns_correct_json(self) -> None: def test_get_classroom_with_version_returns_error(self) -> None: classroom_id = classroom_config_services.get_new_classroom_id() - classroom_dict: classroom_config_domain.ClassroomDict = { - 'classroom_id': classroom_id, - 'name': 'Math', - 'url_fragment': 'math', - 'course_details': '', - 'topic_list_intro': '', - 'topic_id_to_prerequisite_topic_ids': {} - } - - classroom = classroom_config_domain.Classroom.from_dict( - classroom_dict) - - classroom_config_services.update_or_create_classroom_model(classroom) + self.save_new_valid_classroom( + classroom_id=classroom_id, name='math' + ) with self.secrets_swap: self.assertEqual( self.get_json( diff --git a/core/controllers/classroom_test.py b/core/controllers/classroom_test.py index 6b883e42f663..6e93a6b06494 100644 --- a/core/controllers/classroom_test.py +++ b/core/controllers/classroom_test.py @@ -26,6 +26,14 @@ from core.tests import test_utils +dummy_thumbnail_data = classroom_config_domain.ImageData( + 'thumbnail.svg', 'transparent', 1000 +) +dummy_banner_data = classroom_config_domain.ImageData( + 'banner.png', 'transparent', 1000 +) + + class BaseClassroomControllerTests(test_utils.GenericTestBase): def setUp(self) -> None: @@ -82,23 +90,15 @@ def test_get(self) -> None: public_topic.skill_ids_for_diagnostic_test = ['skill_id_1'] topic_services.save_new_topic(admin_id, public_topic) topic_services.publish_topic(topic_id_2, admin_id) - - math_classroom_dict: classroom_config_domain.ClassroomDict = { - 'classroom_id': 'math_classroom_id', - 'name': 'math', - 'url_fragment': 'math', - 'course_details': 'Course details for classroom.', - 'topic_list_intro': 'Topics covered for classroom', - 'topic_id_to_prerequisite_topic_ids': { - topic_id_1: [], - topic_id_2: [], - topic_id_3: [] - } - } - math_classroom = classroom_config_domain.Classroom.from_dict( - math_classroom_dict) - - classroom_config_services.create_new_classroom(math_classroom) + self.save_new_valid_classroom( + topic_id_to_prerequisite_topic_ids={ + topic_id_1: [], + topic_id_2: [], + topic_id_3: [] + }, + course_details='Course details for classroom.', + topic_list_intro='Topics covered for classroom' + ) self.logout() json_response = self.get_json( @@ -200,16 +200,20 @@ def setUp(self) -> None: 'name': 'physics', 'url_fragment': 'physics', 'course_details': 'Curated physics foundations course.', + 'teaser_text': 'Teaser test for physics classroom', 'topic_list_intro': 'Start from the basics with our first topic.', 'topic_id_to_prerequisite_topic_ids': { 'topic_id_1': ['topic_id_2', 'topic_id_3'], 'topic_id_2': [], 'topic_id_3': [] - } + }, + 'is_published': True, + 'thumbnail_data': dummy_thumbnail_data.to_dict(), + 'banner_data': dummy_banner_data.to_dict() } self.physics_classroom = classroom_config_domain.Classroom.from_dict( self.physics_classroom_dict) - classroom_config_services.update_or_create_classroom_model( + classroom_config_services.create_new_classroom( self.physics_classroom) self.math_classroom_id = ( @@ -219,16 +223,20 @@ def setUp(self) -> None: 'name': 'math', 'url_fragment': 'math', 'course_details': 'Curated math foundations course.', + 'teaser_text': 'Teaser test for physics classroom', 'topic_list_intro': 'Start from the basics with our first topic.', 'topic_id_to_prerequisite_topic_ids': { 'topic_id_1': ['topic_id_2', 'topic_id_3'], 'topic_id_2': [], 'topic_id_3': [] - } + }, + 'is_published': True, + 'thumbnail_data': dummy_thumbnail_data.to_dict(), + 'banner_data': dummy_banner_data.to_dict() } self.math_classroom = classroom_config_domain.Classroom.from_dict( self.math_classroom_dict) - classroom_config_services.update_or_create_classroom_model( + classroom_config_services.create_new_classroom( self.math_classroom) def test_get_classroom_id_to_classroom_name(self) -> None: @@ -378,17 +386,21 @@ def setUp(self) -> None: 'name': 'physics', 'url_fragment': 'physics', 'course_details': 'Curated physics foundations course.', + 'teaser_text': 'Teaser test for physics classroom', 'topic_list_intro': 'Start from the basics with our first topic.', 'topic_id_to_prerequisite_topic_ids': { 'topic_id_1': ['topic_id_2', 'topic_id_3'], 'topic_id_2': [], 'topic_id_3': [], 'used_topic_1': [] - } + }, + 'is_published': True, + 'thumbnail_data': dummy_thumbnail_data.to_dict(), + 'banner_data': dummy_banner_data.to_dict() } self.physics_classroom = classroom_config_domain.Classroom.from_dict( self.physics_classroom_dict) - classroom_config_services.update_or_create_classroom_model( + classroom_config_services.create_new_classroom( self.physics_classroom) def test_returns_newly_added_unused_topics(self) -> None: @@ -459,7 +471,7 @@ def test_returns_topic_if_unused_in_classroom(self) -> None: self.physics_classroom.topic_id_to_prerequisite_topic_ids.pop( self.used_topic1.id ) - classroom_config_services.update_or_create_classroom_model( + classroom_config_services.update_classroom( self.physics_classroom) json_response = self.get_json(feconf.UNUSED_TOPICS_HANDLER_URL) self.assertEqual( diff --git a/core/controllers/contributor_dashboard_test.py b/core/controllers/contributor_dashboard_test.py index 3f6f6dbb1079..a5eaa6167e4e 100644 --- a/core/controllers/contributor_dashboard_test.py +++ b/core/controllers/contributor_dashboard_test.py @@ -21,7 +21,6 @@ from core import feconf from core.constants import constants -from core.domain import classroom_config_domain from core.domain import classroom_config_services from core.domain import exp_domain from core.domain import exp_fetchers @@ -116,15 +115,12 @@ def setUp(self) -> None: # Add skill opportunity topic to a classroom. self.classroom_id = classroom_config_services.get_new_classroom_id() - classroom = classroom_config_domain.Classroom( + self.save_new_valid_classroom( classroom_id=self.classroom_id, - name='math', - url_fragment='math-one', - course_details='', - topic_list_intro='', - topic_id_to_prerequisite_topic_ids={self.topic_id: []} + topic_id_to_prerequisite_topic_ids={ + self.topic_id: [] + } ) - classroom_config_services.update_or_create_classroom_model(classroom) self.expected_skill_opportunity_dict_0 = { 'id': self.skill_id_0, @@ -284,15 +280,12 @@ def test_get_skill_opportunity_data_pagination_multiple_fetches( topic, [skill_id_3, skill_id_4, skill_id_5]) # Add new topic to a classroom. - classroom = classroom_config_domain.Classroom( + self.save_new_valid_classroom( classroom_id=self.classroom_id, - name='math', - url_fragment='math-one', - course_details='', - topic_list_intro='', - topic_id_to_prerequisite_topic_ids={topic_id: []} + topic_id_to_prerequisite_topic_ids={ + topic_id: [] + } ) - classroom_config_services.update_or_create_classroom_model(classroom) # Opportunities with IDs skill_id_0, skill_id_1, skill_id_2 will be # fetched first. Since skill_id_0, skill_id_1, skill_id_2 are not linked diff --git a/core/controllers/learner_dashboard_test.py b/core/controllers/learner_dashboard_test.py index 2f2a8cd0ca11..144cc18bbb02 100644 --- a/core/controllers/learner_dashboard_test.py +++ b/core/controllers/learner_dashboard_test.py @@ -19,8 +19,6 @@ from core import feconf from core.constants import constants -from core.domain import classroom_config_domain -from core.domain import classroom_config_services from core.domain import learner_progress_services from core.domain import story_domain from core.domain import story_services @@ -233,17 +231,11 @@ def test_can_see_all_topics(self) -> None: self.owner_id, self.TOPIC_ID_1, self.STORY_ID_2) topic_services.publish_story( self.TOPIC_ID_1, self.STORY_ID_2, self.admin_id) - classroom = classroom_config_domain.Classroom( - classroom_id=classroom_config_services.get_new_classroom_id(), - name='math', - url_fragment='math', - course_details='', - topic_list_intro='', + self.save_new_valid_classroom( topic_id_to_prerequisite_topic_ids={ self.TOPIC_ID_1: [] } ) - classroom_config_services.update_or_create_classroom_model(classroom) self.logout() self.login(self.VIEWER_EMAIL) @@ -273,17 +265,11 @@ def test_can_see_untracked_topics(self) -> None: self.owner_id, self.TOPIC_ID_1, self.STORY_ID_2) topic_services.publish_story( self.TOPIC_ID_1, self.STORY_ID_2, self.admin_id) - classroom = classroom_config_domain.Classroom( - classroom_id=classroom_config_services.get_new_classroom_id(), - name='math', - url_fragment='math', - course_details='', - topic_list_intro='', + self.save_new_valid_classroom( topic_id_to_prerequisite_topic_ids={ self.TOPIC_ID_1: [] } ) - classroom_config_services.update_or_create_classroom_model(classroom) self.logout() self.login(self.VIEWER_EMAIL) @@ -507,17 +493,11 @@ def test_can_get_completed_chapters_count(self) -> None: topic_services.publish_topic(self.TOPIC_ID_1, self.admin_id) self.login(self.CURRICULUM_ADMIN_EMAIL, is_super_admin=True) - classroom = classroom_config_domain.Classroom( - classroom_id=classroom_config_services.get_new_classroom_id(), - name='math', - url_fragment='math', - course_details='', - topic_list_intro='', + self.save_new_valid_classroom( topic_id_to_prerequisite_topic_ids={ self.TOPIC_ID_1: [] } ) - classroom_config_services.update_or_create_classroom_model(classroom) self.logout() self.login(self.VIEWER_EMAIL) diff --git a/core/controllers/topic_editor_test.py b/core/controllers/topic_editor_test.py index 926378aa6622..eab80c6e8344 100644 --- a/core/controllers/topic_editor_test.py +++ b/core/controllers/topic_editor_test.py @@ -22,8 +22,6 @@ from core import feconf from core import utils from core.constants import constants -from core.domain import classroom_config_domain -from core.domain import classroom_config_services from core.domain import platform_parameter_list from core.domain import skill_services from core.domain import story_domain @@ -96,17 +94,11 @@ def setUp(self) -> None: self.set_topic_managers([self.TOPIC_MANAGER_USERNAME], self.topic_id) self.login(self.CURRICULUM_ADMIN_EMAIL, is_super_admin=True) - classroom = classroom_config_domain.Classroom( - classroom_id=classroom_config_services.get_new_classroom_id(), - name='math', - url_fragment='math', - course_details='', - topic_list_intro='', + self.save_new_valid_classroom( topic_id_to_prerequisite_topic_ids={ self.topic_id: [] } ) - classroom_config_services.update_or_create_classroom_model(classroom) self.logout() diff --git a/core/controllers/topics_and_skills_dashboard_test.py b/core/controllers/topics_and_skills_dashboard_test.py index 41ce0ab55b8f..ec46e2a6692e 100644 --- a/core/controllers/topics_and_skills_dashboard_test.py +++ b/core/controllers/topics_and_skills_dashboard_test.py @@ -22,8 +22,6 @@ from core import feconf from core import utils from core.constants import constants -from core.domain import classroom_config_domain -from core.domain import classroom_config_services from core.domain import question_services from core.domain import skill_domain from core.domain import skill_fetchers @@ -73,20 +71,11 @@ def setUp(self) -> None: subtopics=[subtopic], next_subtopic_id=2) self.set_topic_managers([self.TOPIC_MANAGER_USERNAME], self.topic_id) - math_classroom: classroom_config_domain.Classroom = ( - classroom_config_domain.Classroom( - classroom_id='math_classroom_id', - name='math', - url_fragment='math', - course_details='Course details', - topic_list_intro='Topics covered', - topic_id_to_prerequisite_topic_ids={ - self.topic_id: [] - } - ) + self.save_new_valid_classroom( + topic_id_to_prerequisite_topic_ids={ + self.topic_id: [] + } ) - classroom_config_services.update_or_create_classroom_model( - math_classroom) class TopicsAndSkillsDashboardPageDataHandlerTests( diff --git a/core/domain/android_services.py b/core/domain/android_services.py index 5e376196ae9a..4ece27e9f3dd 100644 --- a/core/domain/android_services.py +++ b/core/domain/android_services.py @@ -126,7 +126,7 @@ def initialize_android_test_data() -> str: del topic_id_to_prerequisite_topic_ids[topic.id] classroom.topic_id_to_prerequisite_topic_ids = ( topic_id_to_prerequisite_topic_ids) - classroom_config_services.update_or_create_classroom_model( + classroom_config_services.update_classroom( classroom) # Generate new Structure id for topic, story, skill and question. @@ -577,7 +577,7 @@ def initialize_android_test_data() -> str: classrooms = classroom_config_services.get_all_classrooms() for classroom in classrooms: classroom.topic_id_to_prerequisite_topic_ids[topic_id] = [] - classroom_config_services.update_or_create_classroom_model(classroom) + classroom_config_services.update_classroom(classroom) return topic_id diff --git a/core/domain/android_services_test.py b/core/domain/android_services_test.py index ef9213bf5446..248344cabbc8 100644 --- a/core/domain/android_services_test.py +++ b/core/domain/android_services_test.py @@ -23,7 +23,6 @@ from core import feconf from core.domain import android_services -from core.domain import classroom_config_domain from core.domain import classroom_config_services from core.domain import exp_fetchers from core.domain import exp_services @@ -51,10 +50,9 @@ class InitializeAndroidTestDataTests(test_utils.GenericTestBase): def setUp(self) -> None: super().setUp() classroom_id = classroom_config_services.get_new_classroom_id() - classroom = classroom_config_domain.Classroom( - classroom_id, 'Math', 'math', '', '', {} + self.save_new_valid_classroom( + classroom_id=classroom_id ) - classroom_config_services.update_or_create_classroom_model(classroom) def test_initialize_topic_is_published(self) -> None: android_services.initialize_android_test_data() diff --git a/core/domain/classroom_config_domain.py b/core/domain/classroom_config_domain.py index 7b33f4fa3d20..d1f1e076384c 100644 --- a/core/domain/classroom_config_domain.py +++ b/core/domain/classroom_config_domain.py @@ -32,9 +32,12 @@ class ClassroomDict(TypedDict): name: str url_fragment: str course_details: str + teaser_text: str topic_list_intro: str topic_id_to_prerequisite_topic_ids: Dict[str, List[str]] - + is_published: bool + thumbnail_data: ImageDataDict + banner_data: ImageDataDict # TODO(#17246): Currently, the classroom data is stored in the config model and # we are planning to migrate the storage into a new Classroom model. After the @@ -42,6 +45,7 @@ class ClassroomDict(TypedDict): # the exiting classroom domain file should be deleted, until then both of # the files will exist simultaneously. + class Classroom: """Domain object for a classroom.""" @@ -51,8 +55,12 @@ def __init__( name: str, url_fragment: str, course_details: str, + teaser_text: str, topic_list_intro: str, - topic_id_to_prerequisite_topic_ids: Dict[str, List[str]] + topic_id_to_prerequisite_topic_ids: Dict[str, List[str]], + is_published: bool, + thumbnail_data: ImageData, + banner_data: ImageData ) -> None: """Constructs a Classroom domain object. @@ -61,18 +69,27 @@ def __init__( name: str. The name of the classroom. url_fragment: str. The url fragment of the classroom. course_details: str. Course details for the classroom. + teaser_text: str. A text to provide a summary of the classroom. topic_list_intro: str. Topic list introduction for the classroom. topic_id_to_prerequisite_topic_ids: dict(str, list(str)). A dict with topic ID as key and a list of prerequisite topic IDs as value. + is_published: bool. Whether this classroom is published or not. + thumbnail_data: ImageData. Image data object for classroom + thumbnail. + banner_data: ImageData. Image data object for classroom banner. """ self.classroom_id = classroom_id self.name = name self.url_fragment = url_fragment self.course_details = course_details + self.teaser_text = teaser_text self.topic_list_intro = topic_list_intro self.topic_id_to_prerequisite_topic_ids = ( topic_id_to_prerequisite_topic_ids) + self.is_published = is_published + self.thumbnail_data = thumbnail_data + self.banner_data = banner_data @classmethod def from_dict(cls, classroom_dict: ClassroomDict) -> Classroom: @@ -90,8 +107,12 @@ def from_dict(cls, classroom_dict: ClassroomDict) -> Classroom: classroom_dict['name'], classroom_dict['url_fragment'], classroom_dict['course_details'], + classroom_dict['teaser_text'], classroom_dict['topic_list_intro'], - classroom_dict['topic_id_to_prerequisite_topic_ids'] + classroom_dict['topic_id_to_prerequisite_topic_ids'], + classroom_dict['is_published'], + ImageData.from_dict(classroom_dict['thumbnail_data']), + ImageData.from_dict(classroom_dict['banner_data']) ) def to_dict(self) -> ClassroomDict: @@ -105,9 +126,13 @@ def to_dict(self) -> ClassroomDict: 'name': self.name, 'url_fragment': self.url_fragment, 'course_details': self.course_details, + 'teaser_text': self.teaser_text, 'topic_list_intro': self.topic_list_intro, 'topic_id_to_prerequisite_topic_ids': ( - self.topic_id_to_prerequisite_topic_ids) + self.topic_id_to_prerequisite_topic_ids), + 'is_published': self.is_published, + 'thumbnail_data': self.thumbnail_data.to_dict(), + 'banner_data': self.banner_data.to_dict() } def get_topic_ids(self) -> List[str]: @@ -138,6 +163,87 @@ def require_valid_name(cls, name: str) -> None: 'Classroom name should be at most %d characters, received %s.' % (constants.MAX_CHARS_IN_CLASSROOM_NAME, name)) + @classmethod + def require_valid_teaser_text(cls, teaser_text: str) -> None: + """Checks whether the teaser text of the classroom is a valid one. + + Args: + teaser_text: str. The teaser text to validate. + """ + if not isinstance(teaser_text, str): + raise utils.ValidationError( + 'Expected teaser_text of the classroom to be a string, ' + 'received: %s.' % teaser_text) + + if teaser_text == '': + raise utils.ValidationError('teaser_text field should not be empty') + + if len(teaser_text) > constants.MAX_CHARS_IN_CLASSROOM_TEASER_TEXT: + error_message = ( + 'Classroom teaser_text should be at most %d characters, ' + 'received %s.' % ( + constants.MAX_CHARS_IN_CLASSROOM_TEASER_TEXT, + teaser_text + ) + ) + raise utils.ValidationError(error_message) + + @classmethod + def require_valid_topic_list_intro(cls, topic_list_intro: str) -> None: + """Checks whether the teaser text of the classroom is a valid one. + + Args: + topic_list_intro: str. The topic list intro to validate. + """ + if not isinstance(topic_list_intro, str): + raise utils.ValidationError( + 'Expected topic_list_intro of the classroom to be a string, ' + 'received: %s.' % topic_list_intro) + + if topic_list_intro == '': + raise utils.ValidationError( + 'topic_list_intro field should not be empty') + + if len( + topic_list_intro + ) > constants.MAX_CHARS_IN_CLASSROOM_TOPIC_LIST_INTRO: + error_message = ( + 'Classroom topic_list_intro should be at most %d ' + 'characters, received %s.' % ( + constants.MAX_CHARS_IN_CLASSROOM_TOPIC_LIST_INTRO, + topic_list_intro + ) + ) + raise utils.ValidationError(error_message) + + @classmethod + def require_valid_course_details(cls, course_details: str) -> None: + """Checks whether the teaser text of the classroom is a valid one. + + Args: + course_details: str. The course details to validate. + """ + if not isinstance(course_details, str): + raise utils.ValidationError( + 'Expected course_details of the classroom to be a string, ' + 'received: %s.' % course_details) + + if course_details == '': + raise utils.ValidationError( + 'course_details field should not be empty') + + if len( + course_details + ) > constants.MAX_CHARS_IN_CLASSROOM_COURSE_DETAILS: + error_message = ( + 'Classroom course_details should be at most %d characters, ' + 'received %s.' % ( + constants.MAX_CHARS_IN_CLASSROOM_COURSE_DETAILS, + course_details + ) + ) + raise utils.ValidationError(error_message) + @classmethod def require_valid_url_fragment(cls, url_fragment: str) -> None: """Checks whether the url fragment of the classroom is a valid one. @@ -152,46 +258,58 @@ def require_valid_url_fragment(cls, url_fragment: str) -> None: if url_fragment == '': raise utils.ValidationError( - 'Url fragment field should not be empty') + 'Url fragment field should not be empty' + ) utils.require_valid_url_fragment( url_fragment, 'Classroom URL Fragment', - constants.MAX_CHARS_IN_CLASSROOM_URL_FRAGMENT) - - def validate(self) -> None: - """Validates various properties of the Classroom.""" - - if not isinstance(self.classroom_id, str): - raise utils.ValidationError( - 'Expected ID of the classroom to be a string, received: %s.' - % self.classroom_id) + constants.MAX_CHARS_IN_CLASSROOM_URL_FRAGMENT + ) - self.require_valid_name(self.name) + @classmethod + def require_valid_bg_color( + cls, bg_color: str, is_thumbnail: bool) -> None: + """Checks whether the image bg_color of the classroom is valid. - self.require_valid_url_fragment(self.url_fragment) + Args: + bg_color: str. The background color of the image. + is_thumbnail: bool. Whether the image is thumbnail or not. + """ + image_type = 'thumbnail' if is_thumbnail else 'banner' - if not isinstance(self.course_details, str): + if bg_color == '': raise utils.ValidationError( - 'Expected course_details of the classroom to be a string, ' - 'received: %s.' % self.course_details) + f'{image_type}_bg_color field should not be empty') - if not isinstance(self.topic_list_intro, str): + if ( + bg_color not in ( + constants.ALLOWED_THUMBNAIL_BG_COLORS['classroom'])): raise utils.ValidationError( - 'Expected topic list intro of the classroom to be a string, ' - 'received: %s.' % self.topic_list_intro) + f'Classroom {image_type} background color ' + f'{bg_color} is not supported.') + + @classmethod + def check_for_cycles_in_topic_id_to_prerequisite_topic_ids( + cls, topic_id_to_prerequisite_topic_ids: Dict[str, List[str]] + ) -> None: + """Checks for loop in topic_id_to_prerequisite_topic_ids. - if not isinstance(self.topic_id_to_prerequisite_topic_ids, dict): + Args: + topic_id_to_prerequisite_topic_ids: + Dict[str, List[str]]. The topic ID to prerequisite ID mapping. + """ + if not isinstance(topic_id_to_prerequisite_topic_ids, dict): raise utils.ValidationError( 'Expected topic ID to prerequisite topic IDs of the classroom ' 'to be a string, received: %s.' % ( - self.topic_id_to_prerequisite_topic_ids)) - + topic_id_to_prerequisite_topic_ids)) cyclic_check_error = ( - 'The topic ID to prerequisite topic IDs graph should not contain ' - 'any cycles.') - for topic_id in self.topic_id_to_prerequisite_topic_ids: + 'The topic ID to prerequisite topic IDs graph ' + 'should not contain any cycles.' + ) + for topic_id in topic_id_to_prerequisite_topic_ids: ancestors = copy.deepcopy( - self.topic_id_to_prerequisite_topic_ids[topic_id]) + topic_id_to_prerequisite_topic_ids[topic_id]) visited_topic_ids_for_current_node = [] while len(ancestors) > 0: if topic_id in ancestors: @@ -203,7 +321,106 @@ def validate(self) -> None: continue ancestors.extend( - self.topic_id_to_prerequisite_topic_ids[ - ancestor_topic_id] + topic_id_to_prerequisite_topic_ids.get( + ancestor_topic_id, [] + ) ) visited_topic_ids_for_current_node.append(ancestor_topic_id) + + def validate(self) -> None: + """Validates various properties of the Classroom.""" + + if not isinstance(self.classroom_id, str): + raise utils.ValidationError( + 'Expected ID of the classroom to be a string, received: %s.' + % self.classroom_id) + if not isinstance(self.thumbnail_data, ImageData): + raise utils.ValidationError( + 'Expected thumbnail_data of the classroom to be a string, ' + 'received: %s.' % self.thumbnail_data + ) + if not isinstance(self.banner_data, ImageData): + raise utils.ValidationError( + 'Expected banner_data of the classroom to be a string, ' + 'received: %s.' % self.banner_data + ) + if self.thumbnail_data.filename == '': + raise utils.ValidationError( + 'thumbnail_filename field should not be empty') + if self.banner_data.filename == '': + raise utils.ValidationError( + 'banner_filename field should not be empty') + + self.require_valid_name(self.name) + self.require_valid_teaser_text(self.teaser_text) + self.require_valid_topic_list_intro(self.topic_list_intro) + self.require_valid_course_details(self.course_details) + self.require_valid_url_fragment(self.url_fragment) + self.check_for_cycles_in_topic_id_to_prerequisite_topic_ids( + self.topic_id_to_prerequisite_topic_ids) + if not isinstance(self.is_published, bool): + raise utils.ValidationError( + 'Expected is_published of the classroom to be a boolean, ' + 'received: %s.' % self.is_published) + self.require_valid_bg_color(self.thumbnail_data.bg_color, True) + self.require_valid_bg_color(self.banner_data.bg_color, False) + utils.require_valid_image_filename(self.banner_data.filename) + utils.require_valid_thumbnail_filename(self.thumbnail_data.filename) + + +class ImageDataDict(TypedDict, total=False): + """Dict type for thumbnail and banner image""" + + filename: str + bg_color: str + size_in_bytes: int + + +class ImageData: + """Domain object for a image.""" + + def __init__( + self, + filename: str, + bg_color: str, + size_in_bytes: int, + ) -> None: + """Constructs a ImageData domain object. + + Args: + filename: str. The filename of the image. + bg_color: str. The background color of the image. + size_in_bytes: int. The size of image in bytes. + """ + self.filename = filename + self.bg_color = bg_color + self.size_in_bytes = size_in_bytes + + @classmethod + def from_dict(cls, image_data_dict: ImageDataDict) -> ImageData: + """Returns a image data domain object from a dict. + + Args: + image_data_dict: dict. The dict representation of the image + object. + + Returns: + ImageData. The image data object instance. + """ + return cls( + image_data_dict['filename'], + image_data_dict['bg_color'], + image_data_dict['size_in_bytes'] + ) + + def to_dict(self) -> ImageDataDict: + """Returns a dict representing a image data domain object. + + Returns: + dict. A dict, mapping all fields of image data instance. + """ + return { + 'filename': self.filename, + 'bg_color': self.bg_color, + 'size_in_bytes': self.size_in_bytes, + } diff --git a/core/domain/classroom_config_domain_test.py b/core/domain/classroom_config_domain_test.py index 7daf94c26695..a177e2f59092 100644 --- a/core/domain/classroom_config_domain_test.py +++ b/core/domain/classroom_config_domain_test.py @@ -25,6 +25,7 @@ from __future__ import annotations from core import utils +from core.constants import constants from core.domain import classroom_config_domain from core.tests import test_utils @@ -33,27 +34,38 @@ class ClassroomDomainTests(test_utils.GenericTestBase): def setUp(self) -> None: super().setUp() + self.dummy_thumbnail_data = classroom_config_domain.ImageData( + 'thumbnail.svg', 'transparent', 1000 + ) + self.dummy_banner_data = classroom_config_domain.ImageData( + 'banner.png', 'transparent', 1000 + ) self.classroom = classroom_config_domain.Classroom( 'classroom_id', 'math', 'math', 'Curated math foundations course.', + 'Learn math through fun stories!', 'Start from the basics with our first topic.', { 'topic_id_1': ['topic_id_2', 'topic_id_3'], 'topic_id_2': [], 'topic_id_3': [] - } + }, True, self.dummy_thumbnail_data, self.dummy_banner_data ) self.classroom_dict: classroom_config_domain.ClassroomDict = { 'classroom_id': 'classroom_id', 'name': 'math', 'url_fragment': 'math', 'course_details': 'Curated math foundations course.', + 'teaser_text': 'Learn math through fun stories!', 'topic_list_intro': 'Start from the basics with our first topic.', 'topic_id_to_prerequisite_topic_ids': { 'topic_id_1': ['topic_id_2', 'topic_id_3'], 'topic_id_2': [], 'topic_id_3': [] - } + }, + 'is_published': True, + 'thumbnail_data': self.dummy_thumbnail_data.to_dict(), + 'banner_data': self.dummy_banner_data.to_dict() } def test_that_domain_object_is_created_correctly(self) -> None: @@ -64,6 +76,10 @@ def test_that_domain_object_is_created_correctly(self) -> None: self.classroom.course_details, 'Curated math foundations course.' ) + self.assertEqual( + self.classroom.teaser_text, + 'Learn math through fun stories!' + ) self.assertEqual( self.classroom.topic_list_intro, 'Start from the basics with our first topic.' @@ -80,6 +96,11 @@ def test_that_domain_object_is_created_correctly(self) -> None: self.classroom.get_topic_ids(), ['topic_id_1', 'topic_id_2', 'topic_id_3'] ) + self.assertTrue(self.classroom.is_published) + self.assertEqual( + self.classroom.thumbnail_data, self.dummy_thumbnail_data + ) + self.assertEqual(self.classroom.banner_data, self.dummy_banner_data) self.classroom.validate() def test_from_dict_method(self) -> None: @@ -93,6 +114,10 @@ def test_from_dict_method(self) -> None: classroom.course_details, 'Curated math foundations course.' ) + self.assertEqual( + classroom.teaser_text, + 'Learn math through fun stories!' + ) self.assertEqual( classroom.topic_list_intro, 'Start from the basics with our first topic.' @@ -105,6 +130,11 @@ def test_from_dict_method(self) -> None: 'topic_id_3': [] } ) + self.assertTrue(classroom.is_published) + self.assertEqual( + self.classroom.thumbnail_data, self.dummy_thumbnail_data + ) + self.assertEqual(self.classroom.banner_data, self.dummy_banner_data) def test_to_dict_method(self) -> None: self.assertEqual(self.classroom.to_dict(), self.classroom_dict) @@ -179,13 +209,29 @@ def test_invalid_classroom_url_fragment_should_raise_exception( # TODO(#13059): Here we use MyPy ignore because after we fully type # the codebase we plan to get rid of the tests that intentionally # test wrong inputs that we can normally catch by typing. - def test_invalid_course_details_should_raise_exception( - self - ) -> None: - self.classroom.course_details = 1 # type: ignore[assignment] + def test_invalid_teaser_text_should_raise_exception(self) -> None: + self.classroom.teaser_text = 1 # type: ignore[assignment] error_msg = ( - 'Expected course_details of the classroom to be a string, ' - 'received: 1.' + 'Expected teaser_text of the classroom to be a string, received: 1.' + ) + with self.assertRaisesRegex( + utils.ValidationError, error_msg): + self.classroom.validate() + + self.classroom.teaser_text = '' + error_msg = 'teaser_text field should not be empty' + with self.assertRaisesRegex( + utils.ValidationError, error_msg): + self.classroom.validate() + + self.classroom.teaser_text = 'long-teaser-text' * 10 + error_msg = ( + 'Classroom teaser_text should be at most %d characters, ' + 'received %s.' % + ( + constants.MAX_CHARS_IN_CLASSROOM_TEASER_TEXT, + self.classroom.teaser_text + ) ) with self.assertRaisesRegex( utils.ValidationError, error_msg): @@ -194,18 +240,67 @@ def test_invalid_course_details_should_raise_exception( # TODO(#13059): Here we use MyPy ignore because after we fully type # the codebase we plan to get rid of the tests that intentionally # test wrong inputs that we can normally catch by typing. - def test_invalid_topic_list_intro_should_raise_exception( - self - ) -> None: + def test_invalid_topic_list_intro_should_raise_exception(self) -> None: self.classroom.topic_list_intro = 1 # type: ignore[assignment] error_msg = ( - 'Expected topic list intro of the classroom to be a string, ' + 'Expected topic_list_intro of the classroom to be a string, ' + 'received: 1.' + ) + with self.assertRaisesRegex( + utils.ValidationError, error_msg): + self.classroom.validate() + + self.classroom.topic_list_intro = '' + error_msg = 'topic_list_intro field should not be empty' + with self.assertRaisesRegex( + utils.ValidationError, error_msg): + self.classroom.validate() + + self.classroom.topic_list_intro = 'a' * 241 + error_msg = ( + 'Classroom topic_list_intro should be at most %d characters, ' + 'received %s.' % + ( + constants.MAX_CHARS_IN_CLASSROOM_TOPIC_LIST_INTRO, + self.classroom.topic_list_intro + ) + ) + with self.assertRaisesRegex( + utils.ValidationError, error_msg): + self.classroom.validate() + + # TODO(#13059): Here we use MyPy ignore because after we fully type + # the codebase we plan to get rid of the tests that intentionally + # test wrong inputs that we can normally catch by typing. + def test_invalid_course_details_should_raise_exception(self) -> None: + self.classroom.course_details = 1 # type: ignore[assignment] + error_msg = ( + 'Expected course_details of the classroom to be a string, ' 'received: 1.' ) with self.assertRaisesRegex( utils.ValidationError, error_msg): self.classroom.validate() + self.classroom.course_details = '' + error_msg = 'course_details field should not be empty' + with self.assertRaisesRegex( + utils.ValidationError, error_msg): + self.classroom.validate() + + self.classroom.course_details = 'a' * 721 + error_msg = ( + 'Classroom course_details should be at most %d ' + 'characters, received %s.' % + ( + constants.MAX_CHARS_IN_CLASSROOM_COURSE_DETAILS, + self.classroom.course_details + ) + ) + with self.assertRaisesRegex( + utils.ValidationError, error_msg): + self.classroom.validate() + # TODO(#13059): Here we use MyPy ignore because after we fully type # the codebase we plan to get rid of the tests that intentionally # test wrong inputs that we can normally catch by typing. @@ -221,6 +316,19 @@ def test_invalid_topic_dependency_dict_should_raise_exception( utils.ValidationError, error_msg): self.classroom.validate() + # TODO(#13059): Here we use MyPy ignore because after we fully type + # the codebase we plan to get rid of the tests that intentionally + # test wrong inputs that we can normally catch by typing. + def test_invalid_is_published_should_raise_exception(self) -> None: + self.classroom.is_published = 1 # type: ignore[assignment] + error_msg = ( + 'Expected is_published of the classroom to be a boolean, ' + 'received: 1.' + ) + with self.assertRaisesRegex( + utils.ValidationError, error_msg): + self.classroom.validate() + def test_cycle_between_topic_id_and_prerequisites_should_raise_exception( self ) -> None: @@ -290,3 +398,132 @@ def test_valid_topic_id_to_prerequisite_topic_ids_graph(self) -> None: 'topic_id_3': ['topic_id_2', 'topic_id_1'] } self.classroom.validate() + + # TODO(#13059): Here we use MyPy ignore because after we fully type + # the codebase we plan to get rid of the tests that intentionally + # test wrong inputs that we can normally catch by typing. + def test_invalid_thumbnail_data_should_raise_exception(self) -> None: + self.classroom.thumbnail_data = 1 # type: ignore[assignment] + error_msg = ( + 'Expected thumbnail_data of the classroom to be a string, ' + 'received: 1.' + ) + with self.assertRaisesRegex( + utils.ValidationError, error_msg): + self.classroom.validate() + + # TODO(#13059): Here we use MyPy ignore because after we fully type + # the codebase we plan to get rid of the tests that intentionally + # test wrong inputs that we can normally catch by typing. + def test_invalid_banner_data_should_raise_exception(self) -> None: + self.classroom.banner_data = 1 # type: ignore[assignment] + error_msg = ( + 'Expected banner_data of the classroom to be a string, ' + 'received: 1.') + with self.assertRaisesRegex( + utils.ValidationError, error_msg): + self.classroom.validate() + + def test_invalid_thumbnail_bg_color_should_raise_exception(self) -> None: + error_msg = 'thumbnail_bg_color field should not be empty' + self.classroom.thumbnail_data = classroom_config_domain.ImageData( + 'valid_thumbnail.svg', '', 1000) + with self.assertRaisesRegex( + utils.ValidationError, error_msg): + self.classroom.validate() + + error_msg = ( + 'Classroom thumbnail background color #FFFF is not supported.' + ) + self.classroom.thumbnail_data.bg_color = '#FFFF' + with self.assertRaisesRegex( + utils.ValidationError, error_msg): + self.classroom.validate() + + def test_invalid_banner_bg_color_should_raise_exception(self) -> None: + error_msg = 'banner_bg_color field should not be empty' + self.classroom.banner_data = classroom_config_domain.ImageData( + 'valid_banner.png', '', 1000) + with self.assertRaisesRegex( + utils.ValidationError, error_msg): + self.classroom.validate() + + error_msg = ( + 'Classroom banner background color #FFFF is not supported.' + ) + self.classroom.banner_data.bg_color = '#FFFF' + with self.assertRaisesRegex( + utils.ValidationError, error_msg): + self.classroom.validate() + + def test_invalid_banner_filename_should_raise_exception(self) -> None: + error_msg = ( + 'Image filename should include an extension.' + ) + self.classroom.banner_data = classroom_config_domain.ImageData( + 'invalid_banner', 'transparent', 1000) + with self.assertRaisesRegex( + utils.ValidationError, error_msg): + self.classroom.validate() + error_msg = ( + 'banner_filename field should not be empty' + ) + self.classroom.banner_data = classroom_config_domain.ImageData( + '', 'transparent', 1000) + with self.assertRaisesRegex( + utils.ValidationError, error_msg): + self.classroom.validate() + + def test_invalid_thumbnail_filename_should_raise_exception(self) -> None: + error_msg = ( + 'Expected a filename ending in svg, received invalid_thumbnail.png' + ) + self.classroom.thumbnail_data = classroom_config_domain.ImageData( + 'invalid_thumbnail.png', 'transparent', 1000) + with self.assertRaisesRegex( + utils.ValidationError, error_msg): + self.classroom.validate() + error_msg = ( + 'thumbnail_filename field should not be empty' + ) + self.classroom.thumbnail_data = classroom_config_domain.ImageData( + '', 'transparent', 1000) + with self.assertRaisesRegex( + utils.ValidationError, error_msg): + self.classroom.validate() + + +class ImageDomainTests(test_utils.GenericTestBase): + + def setUp(self) -> None: + self.filename = 'test_image.png' + self.bg_color = '#FFFFFF' + self.size_in_bytes = 2048 + + self.image = classroom_config_domain.ImageData( + filename=self.filename, + bg_color=self.bg_color, + size_in_bytes=self.size_in_bytes + ) + + self.image_dict: classroom_config_domain.ImageDataDict = { + 'filename': self.filename, + 'bg_color': self.bg_color, + 'size_in_bytes': self.size_in_bytes + } + + def test_initialization(self) -> None: + self.assertEqual(self.image.filename, self.filename) + self.assertEqual(self.image.bg_color, self.bg_color) + self.assertEqual(self.image.size_in_bytes, self.size_in_bytes) + + def test_to_dict(self) -> None: + self.assertEqual(self.image.to_dict(), self.image_dict) + + def test_from_dict(self) -> None: + image_from_dict = classroom_config_domain.ImageData.from_dict( + self.image_dict + ) + self.assertEqual(image_from_dict.filename, self.filename) + self.assertEqual(image_from_dict.bg_color, self.bg_color) + self.assertEqual(image_from_dict.size_in_bytes, self.size_in_bytes) diff --git a/core/domain/classroom_config_services.py b/core/domain/classroom_config_services.py index 7245ef34f9a7..3f678fe15e85 100644 --- a/core/domain/classroom_config_services.py +++ b/core/domain/classroom_config_services.py @@ -18,6 +18,7 @@ from __future__ import annotations +from core import feconf from core.constants import constants from core.domain import classroom_config_domain from core.platform import models @@ -79,13 +80,25 @@ def get_classroom_from_classroom_model( Classroom. A classroom domain object corresponding to the given classroom model. """ + thumbnail_data = classroom_config_domain.ImageData( + classroom_model.thumbnail_filename, + classroom_model.thumbnail_bg_color, + classroom_model.thumbnail_size_in_bytes + ) + banner_data = classroom_config_domain.ImageData( + classroom_model.banner_filename, + classroom_model.banner_bg_color, + classroom_model.banner_size_in_bytes + ) return classroom_config_domain.Classroom( classroom_model.id, classroom_model.name, classroom_model.url_fragment, classroom_model.course_details, + classroom_model.teaser_text, classroom_model.topic_list_intro, - classroom_model.topic_id_to_prerequisite_topic_ids + classroom_model.topic_id_to_prerequisite_topic_ids, + classroom_model.is_published, thumbnail_data, banner_data ) @@ -175,23 +188,43 @@ def get_new_classroom_id() -> str: def update_classroom( - classroom: classroom_config_domain.Classroom, - classroom_model: classroom_models.ClassroomModel + classroom: classroom_config_domain.Classroom ) -> None: """Saves a Clasroom domain object to the datastore. Args: classroom: Classroom. The classroom domain object for the given classroom. - classroom_model: ClassroomModel. The classroom model instance. """ classroom.validate() + classroom_model = classroom_models.ClassroomModel.get( + classroom.classroom_id, strict=False) + + if not classroom_model: + return + classroom_model.name = classroom.name classroom_model.url_fragment = classroom.url_fragment classroom_model.course_details = classroom.course_details classroom_model.topic_list_intro = classroom.topic_list_intro classroom_model.topic_id_to_prerequisite_topic_ids = ( classroom.topic_id_to_prerequisite_topic_ids) + classroom_model.teaser_text = classroom.teaser_text + classroom_model.is_published = classroom.is_published + classroom_model.thumbnail_filename = ( + classroom.thumbnail_data.filename + ) + classroom_model.thumbnail_bg_color = ( + classroom.thumbnail_data.bg_color + ) + classroom_model.thumbnail_size_in_bytes = ( + classroom.thumbnail_data.size_in_bytes + ) + classroom_model.banner_filename = classroom.banner_data.filename + classroom_model.banner_bg_color = classroom.banner_data.bg_color + classroom_model.banner_size_in_bytes = ( + classroom.banner_data.size_in_bytes + ) classroom_model.update_timestamps() classroom_model.put() @@ -212,11 +245,64 @@ def create_new_classroom( classroom.name, classroom.url_fragment, classroom.course_details, + classroom.teaser_text, classroom.topic_list_intro, - classroom.topic_id_to_prerequisite_topic_ids + classroom.topic_id_to_prerequisite_topic_ids, + classroom.is_published, + classroom.thumbnail_data.filename, + classroom.thumbnail_data.bg_color, + classroom.thumbnail_data.size_in_bytes, + classroom.banner_data.filename, + classroom.banner_data.bg_color, + classroom.banner_data.size_in_bytes, ) +def create_new_default_classroom( + classroom_id: str, name: str, url_fragment: str + ) -> classroom_config_domain.Classroom: + """Creates a new default classroom model. + + Args: + classroom_id: str. The id of new classroom. + name: str. The name of the classroom. + url_fragment: str. The url fragment of the classroom. + + Returns: + Classroom. The domain object representing a classroom. + """ + classroom = classroom_config_domain.Classroom( + classroom_id=classroom_id, name=name, url_fragment=url_fragment, + teaser_text='', course_details='', topic_list_intro='', + topic_id_to_prerequisite_topic_ids={}, + is_published=feconf.DEFAULT_CLASSROOM_PUBLICATION_STATUS, + thumbnail_data=classroom_config_domain.ImageData('', '', 0), + banner_data=classroom_config_domain.ImageData('', '', 0) + ) + + classroom.require_valid_name(name) + classroom.require_valid_url_fragment(url_fragment) + + classroom_models.ClassroomModel.create( + classroom.classroom_id, + classroom.name, + classroom.url_fragment, + classroom.course_details, + classroom.teaser_text, + classroom.topic_list_intro, + classroom.topic_id_to_prerequisite_topic_ids, + classroom.is_published, + classroom.thumbnail_data.filename, + classroom.thumbnail_data.bg_color, + classroom.thumbnail_data.size_in_bytes, + classroom.banner_data.filename, + classroom.banner_data.bg_color, + classroom.banner_data.size_in_bytes, + ) + + return classroom + + def update_or_create_classroom_model( classroom: classroom_config_domain.Classroom ) -> None: @@ -229,7 +315,7 @@ def update_or_create_classroom_model( if model is None: create_new_classroom(classroom) else: - update_classroom(classroom, model) + update_classroom(classroom) def delete_classroom(classroom_id: str) -> None: diff --git a/core/domain/classroom_config_services_test.py b/core/domain/classroom_config_services_test.py index cf298ccf5bca..6625d4319512 100644 --- a/core/domain/classroom_config_services_test.py +++ b/core/domain/classroom_config_services_test.py @@ -42,18 +42,28 @@ class ClassroomServicesTests(test_utils.GenericTestBase): def setUp(self) -> None: super().setUp() + self.dummy_thumbnail_data = classroom_config_domain.ImageData( + 'thumbnail.svg', 'transparent', 1000 + ) + self.dummy_banner_data = classroom_config_domain.ImageData( + 'banner.png', 'transparent', 1000 + ) self.math_classroom_dict: classroom_config_domain.ClassroomDict = { 'classroom_id': 'math_classroom_id', 'name': 'math', 'url_fragment': 'math', 'course_details': 'Curated math foundations course.', + 'teaser_text': 'Teaser test for math classroom', 'topic_list_intro': 'Start from the basics with our first topic.', 'topic_id_to_prerequisite_topic_ids': { 'topic_id_1': ['topic_id_2', 'topic_id_3'], 'topic_id_2': [], 'topic_id_3': [] - } + }, + 'is_published': True, + 'thumbnail_data': self.dummy_thumbnail_data.to_dict(), + 'banner_data': self.dummy_banner_data.to_dict() } self.math_classroom = classroom_config_domain.Classroom.from_dict( self.math_classroom_dict) @@ -62,8 +72,16 @@ def setUp(self) -> None: self.math_classroom.name, self.math_classroom.url_fragment, self.math_classroom.course_details, + self.math_classroom.teaser_text, self.math_classroom.topic_list_intro, - self.math_classroom.topic_id_to_prerequisite_topic_ids + self.math_classroom.topic_id_to_prerequisite_topic_ids, + self.math_classroom.is_published, + self.math_classroom.thumbnail_data.filename, + self.math_classroom.thumbnail_data.bg_color, + self.math_classroom.thumbnail_data.size_in_bytes, + self.math_classroom.banner_data.filename, + self.math_classroom.banner_data.bg_color, + self.math_classroom.banner_data.size_in_bytes, ) self.physics_classroom_dict: classroom_config_domain.ClassroomDict = { @@ -71,12 +89,16 @@ def setUp(self) -> None: 'name': 'physics', 'url_fragment': 'physics', 'course_details': 'Curated physics foundations course.', + 'teaser_text': 'Teaser test for physics classroom', 'topic_list_intro': 'Start from the basics with our first topic.', 'topic_id_to_prerequisite_topic_ids': { 'topic_id_1': ['topic_id_2', 'topic_id_3'], 'topic_id_2': [], 'topic_id_3': [] - } + }, + 'is_published': True, + 'thumbnail_data': self.dummy_thumbnail_data.to_dict(), + 'banner_data': self.dummy_banner_data.to_dict() } self.physics_classroom = classroom_config_domain.Classroom.from_dict( self.physics_classroom_dict) @@ -85,8 +107,16 @@ def setUp(self) -> None: self.physics_classroom.name, self.physics_classroom.url_fragment, self.physics_classroom.course_details, + self.physics_classroom.teaser_text, self.physics_classroom.topic_list_intro, - self.physics_classroom.topic_id_to_prerequisite_topic_ids + self.physics_classroom.topic_id_to_prerequisite_topic_ids, + self.physics_classroom.is_published, + self.physics_classroom.thumbnail_data.filename, + self.physics_classroom.thumbnail_data.bg_color, + self.physics_classroom.thumbnail_data.size_in_bytes, + self.physics_classroom.banner_data.filename, + self.physics_classroom.banner_data.bg_color, + self.physics_classroom.banner_data.size_in_bytes ) def test_get_classroom_by_id(self) -> None: @@ -116,8 +146,12 @@ def test_get_classroom_url_fragment_for_existing_topic(self) -> None: 'name': 'chem', 'url_fragment': 'chem', 'course_details': 'Curated Chemistry foundations course.', + 'teaser_text': 'Teaser test for chemistry classroom', 'topic_list_intro': 'Start from the basics with our first topic.', - 'topic_id_to_prerequisite_topic_ids': {'topic_id_chem': []} + 'topic_id_to_prerequisite_topic_ids': {'topic_id_chem': []}, + 'is_published': True, + 'thumbnail_data': self.dummy_thumbnail_data.to_dict(), + 'banner_data': self.dummy_banner_data.to_dict() } chemistry_classroom = classroom_config_domain.Classroom.from_dict( chemistry_classroom_dict) @@ -126,8 +160,16 @@ def test_get_classroom_url_fragment_for_existing_topic(self) -> None: chemistry_classroom.name, chemistry_classroom.url_fragment, chemistry_classroom.course_details, + chemistry_classroom.teaser_text, chemistry_classroom.topic_list_intro, - chemistry_classroom.topic_id_to_prerequisite_topic_ids + chemistry_classroom.topic_id_to_prerequisite_topic_ids, + chemistry_classroom.is_published, + chemistry_classroom.thumbnail_data.filename, + chemistry_classroom.thumbnail_data.bg_color, + chemistry_classroom.thumbnail_data.size_in_bytes, + chemistry_classroom.banner_data.filename, + chemistry_classroom.banner_data.bg_color, + chemistry_classroom.banner_data.size_in_bytes ) classroom_url_fragment = ( classroom_config_services. @@ -174,19 +216,20 @@ def test_create_new_classroom_model(self) -> None: chemistry_classroom = classroom_config_domain.Classroom( new_classroom_id, 'chemistry', 'chemistry', 'Curated chemistry foundations course.', + 'Teaser test for chemistry classroom', 'Start from the basics with our first topic.', { 'topic_id_1': ['topic_id_2', 'topic_id_3'], 'topic_id_2': [], 'topic_id_3': [] - } + }, True, self.dummy_thumbnail_data, self.dummy_banner_data ) self.assertIsNone( classroom_config_services.get_classroom_by_id( new_classroom_id, strict=False) ) - classroom_config_services.update_or_create_classroom_model( + classroom_config_services.create_new_classroom( chemistry_classroom) self.assertEqual( classroom_config_services.get_classroom_by_id( @@ -202,7 +245,7 @@ def test_update_existing_classroom_model(self) -> None: ) self.physics_classroom.name = 'Quantum physics' - classroom_config_services.update_or_create_classroom_model( + classroom_config_services.update_classroom( self.physics_classroom) self.assertEqual( @@ -211,6 +254,18 @@ def test_update_existing_classroom_model(self) -> None: 'Quantum physics' ) + def test_do_not_update_invalid_classroom(self) -> None: + self.physics_classroom.classroom_id = 'invalid' + self.physics_classroom.name = 'Updated physics' + classroom_config_services.update_classroom( + self.physics_classroom) + + self.assertEqual( + classroom_config_services.get_classroom_by_id( + 'physics_classroom_id').name, + 'physics' + ) + def test_delete_classroom_model(self) -> None: self.assertIsNotNone( classroom_config_services.get_classroom_by_id('math_classroom_id')) @@ -220,3 +275,37 @@ def test_delete_classroom_model(self) -> None: self.assertIsNone( classroom_config_services.get_classroom_by_id( 'math_classroom_id', strict=False)) + + def test_create_new_default_classroom(self) -> None: + classroom_id = classroom_config_services.get_new_classroom_id() + history_classroom = ( + classroom_config_services.create_new_default_classroom( + classroom_id, 'history', 'history' + )) + history_classroom_data = classroom_config_services.get_classroom_by_id( + classroom_id + ) + + self.assertEqual(history_classroom.name, history_classroom_data.name) + self.assertEqual( + history_classroom.url_fragment, history_classroom_data.url_fragment + ) + self.assertFalse(history_classroom_data.is_published) + + def test_update_or_create_new_classroom(self) -> None: + self.math_classroom.name = 'Updated Math' + classroom_config_services.update_or_create_classroom_model( + self.math_classroom) + math_classroom = classroom_config_services.get_classroom_by_id( + self.math_classroom.classroom_id + ) + self.assertEqual(self.math_classroom.name, math_classroom.name) + + self.math_classroom.classroom_id = 'new_classroom' + classroom_config_services.update_or_create_classroom_model( + self.math_classroom + ) + math_classroom = classroom_config_services.get_classroom_by_id( + 'new_classroom' + ) + self.assertEqual(self.math_classroom.name, math_classroom.name) diff --git a/core/domain/learner_group_services_test.py b/core/domain/learner_group_services_test.py index 8cbd558e7531..aa3b2af01026 100644 --- a/core/domain/learner_group_services_test.py +++ b/core/domain/learner_group_services_test.py @@ -20,8 +20,6 @@ from core import feature_flag_list from core.constants import constants -from core.domain import classroom_config_domain -from core.domain import classroom_config_services from core.domain import learner_group_fetchers from core.domain import learner_group_services from core.domain import topic_domain @@ -246,15 +244,7 @@ def test_get_syllabus_to_add_with_matching_story_name(self) -> None: def test_get_matching_syllabus_to_add_with_classroom_filter(self) -> None: # Test 4: Classroom name filter. - classroom = classroom_config_domain.Classroom( - classroom_id=classroom_config_services.get_new_classroom_id(), - name='math', - url_fragment='math', - course_details='', - topic_list_intro='', - topic_id_to_prerequisite_topic_ids={} - ) - classroom_config_services.update_or_create_classroom_model(classroom) + self.save_new_valid_classroom() matching_syllabus = ( learner_group_services.get_matching_learner_group_syllabus_to_add( self.LEARNER_GROUP_ID, 'Place', 'All', diff --git a/core/domain/learner_progress_services_test.py b/core/domain/learner_progress_services_test.py index e9bb9a0d5afb..c884a8d771f5 100644 --- a/core/domain/learner_progress_services_test.py +++ b/core/domain/learner_progress_services_test.py @@ -21,8 +21,6 @@ import datetime from core.constants import constants -from core.domain import classroom_config_domain -from core.domain import classroom_config_services from core.domain import collection_domain from core.domain import collection_services from core.domain import exp_fetchers @@ -1598,18 +1596,12 @@ def test_get_all_partially_learnt_topic_ids(self) -> None: def test_get_all_and_untracked_topic_ids(self) -> None: # Add topics to config_domain. - classroom = classroom_config_domain.Classroom( - classroom_id=classroom_config_services.get_new_classroom_id(), - name='math', - url_fragment='math-one', - course_details='', - topic_list_intro='', + self.save_new_valid_classroom( topic_id_to_prerequisite_topic_ids={ self.TOPIC_ID_0: [], self.TOPIC_ID_1: [] } ) - classroom_config_services.update_or_create_classroom_model(classroom) self.login(self.USER_EMAIL) partially_learnt_topic_ids = ( @@ -2114,17 +2106,11 @@ def test_get_ids_of_activities_in_learner_dashboard(self) -> None: def test_get_all_activity_progress(self) -> None: # Add topics to config_domain. - classroom = classroom_config_domain.Classroom( - classroom_id=classroom_config_services.get_new_classroom_id(), - name='math', - url_fragment='math-one', - course_details='', - topic_list_intro='', + self.save_new_valid_classroom( topic_id_to_prerequisite_topic_ids={ self.TOPIC_ID_3: [] } ) - classroom_config_services.update_or_create_classroom_model(classroom) # Add activities to the completed section. learner_progress_services.mark_exploration_as_completed( diff --git a/core/domain/skill_services_test.py b/core/domain/skill_services_test.py index 6dd6ff61e9cc..437e91292884 100644 --- a/core/domain/skill_services_test.py +++ b/core/domain/skill_services_test.py @@ -20,8 +20,6 @@ from core import feconf from core.constants import constants -from core.domain import classroom_config_domain -from core.domain import classroom_config_services from core.domain import question_domain from core.domain import skill_domain from core.domain import skill_fetchers @@ -481,11 +479,11 @@ def test_filter_skills_by_status_assigned(self) -> None: uncategorized_skill_ids=[self.SKILL_ID2], subtopics=[], next_subtopic_id=1) - math_classroom = classroom_config_domain.Classroom( - classroom_config_services.get_new_classroom_id(), - 'Math', 'math', '', '', {topic_id: []}) - classroom_config_services.update_or_create_classroom_model( - math_classroom) + self.save_new_valid_classroom( + topic_id_to_prerequisite_topic_ids={ + topic_id: [] + } + ) augmented_skill_summaries, next_cursor, more = ( skill_services.get_filtered_skill_summaries( @@ -531,17 +529,11 @@ def test_filter_skills_by_classroom_name(self) -> None: uncategorized_skill_ids=[self.SKILL_ID2], subtopics=[], next_subtopic_id=1) - classroom = classroom_config_domain.Classroom( - classroom_id=classroom_config_services.get_new_classroom_id(), - name='math', - url_fragment='math', - course_details='Course Details', - topic_list_intro='Topics Covered', + self.save_new_valid_classroom( topic_id_to_prerequisite_topic_ids={ topic_id: [] } ) - classroom_config_services.update_or_create_classroom_model(classroom) augmented_skill_summaries, next_cursor, more = ( skill_services.get_filtered_skill_summaries( self.num_queries_to_fetch, None, 'math', [], diff --git a/core/feconf.py b/core/feconf.py index c1d597ad3c77..993cdf9f4e1d 100644 --- a/core/feconf.py +++ b/core/feconf.py @@ -1081,6 +1081,7 @@ def get_empty_ratings() -> Dict[str, int]: UNUSED_TOPICS_HANDLER_URL = '/unused_topics' NEW_CLASSROOM_ID_HANDLER_URL = '/new_classroom_id_handler' CLASSROOM_HANDLER_URL = '/classroom' +NEW_CLASSROOM_URL = '/classroom_admin/create_new' CLASSROOM_URL_FRAGMENT_HANDLER = '/classroom_url_fragment_handler' CLASSROOM_ID_HANDLER_URL = '/classroom_id_handler' VOICEOVER_ADMIN_DATA_HANDLER_URL = '/voiceover_admin_data_handler' @@ -1669,6 +1670,8 @@ def get_empty_ratings() -> Dict[str, int]: MIN_ALLOWED_MISSING_OR_UPDATE_NEEDED_WRITTEN_TRANSLATIONS = 10 +DEFAULT_CLASSROOM_PUBLICATION_STATUS = False + class TranslatableEntityType(enum.Enum): """Represents all possible entity types which support new translations diff --git a/core/storage/classroom/gae_models.py b/core/storage/classroom/gae_models.py index 24e3464c590f..7944a87eae22 100644 --- a/core/storage/classroom/gae_models.py +++ b/core/storage/classroom/gae_models.py @@ -47,6 +47,9 @@ class ClassroomModel(base_models.BaseModel): # A text to provide course details present in the classroom. course_details = datastore_services.StringProperty( indexed=True, required=True) + # A text to provide a summary of the classroom. + teaser_text = datastore_services.StringProperty( + indexed=True, required=True) # A text to provide an introduction for all the topics in the classroom. topic_list_intro = datastore_services.StringProperty( indexed=True, required=True) @@ -55,6 +58,22 @@ class ClassroomModel(base_models.BaseModel): # prerequisite topic IDs as value. topic_id_to_prerequisite_topic_ids = datastore_services.JsonProperty( indexed=False, required=False) + # Whether this classroom is published or not. + # False if classroom is hidden, True if published. + is_published = datastore_services.BooleanProperty( + indexed=True, required=True, default=False) + # The thumbnail filename of the classroom. + thumbnail_filename = datastore_services.StringProperty(indexed=True) + # The thumbnail background color of the classroom. + thumbnail_bg_color = datastore_services.StringProperty(indexed=True) + # The thumbnail size in bytes of the classroom. + thumbnail_size_in_bytes = datastore_services.IntegerProperty(indexed=False) + # The banner filename of the classroom. + banner_filename = datastore_services.StringProperty(indexed=True) + # The banner background color of the classroom. + banner_bg_color = datastore_services.StringProperty(indexed=True) + # The banner size in bytes of the classroom. + banner_size_in_bytes = datastore_services.IntegerProperty(indexed=False) @staticmethod def get_deletion_policy() -> base_models.DELETION_POLICY: @@ -74,9 +93,17 @@ def get_export_policy(cls) -> Dict[str, base_models.EXPORT_POLICY]: 'name': base_models.EXPORT_POLICY.NOT_APPLICABLE, 'url_fragment': base_models.EXPORT_POLICY.NOT_APPLICABLE, 'course_details': base_models.EXPORT_POLICY.NOT_APPLICABLE, + 'teaser_text': base_models.EXPORT_POLICY.NOT_APPLICABLE, 'topic_list_intro': base_models.EXPORT_POLICY.NOT_APPLICABLE, 'topic_id_to_prerequisite_topic_ids': ( - base_models.EXPORT_POLICY.NOT_APPLICABLE) + base_models.EXPORT_POLICY.NOT_APPLICABLE), + 'is_published': base_models.EXPORT_POLICY.NOT_APPLICABLE, + 'thumbnail_filename': base_models.EXPORT_POLICY.NOT_APPLICABLE, + 'thumbnail_bg_color': base_models.EXPORT_POLICY.NOT_APPLICABLE, + 'thumbnail_size_in_bytes': base_models.EXPORT_POLICY.NOT_APPLICABLE, + 'banner_filename': base_models.EXPORT_POLICY.NOT_APPLICABLE, + 'banner_bg_color': base_models.EXPORT_POLICY.NOT_APPLICABLE, + 'banner_size_in_bytes': base_models.EXPORT_POLICY.NOT_APPLICABLE }) @classmethod @@ -104,8 +131,11 @@ def generate_new_classroom_id(cls) -> str: @classmethod def create( cls, classroom_id: str, name: str, url_fragment: str, - course_details: str, topic_list_intro: str, - topic_id_to_prerequisite_topic_ids: Dict[str, List[str]] + course_details: str, teaser_text: str, topic_list_intro: str, + topic_id_to_prerequisite_topic_ids: Dict[str, List[str]], + is_published: bool, thumbnail_filename: str, thumbnail_bg_color: str, + thumbnail_size_in_bytes: int, banner_filename: str, + banner_bg_color: str, banner_size_in_bytes: int ) -> ClassroomModel: """Creates a new ClassroomModel entry. @@ -115,10 +145,18 @@ def create( url_fragment: str. The url fragment of the classroom. course_details: str. A text to provide course details present in the classroom. + teaser_text: str. A text to provide a summary of the classroom. topic_list_intro: str. A text to provide an introduction for all the topics in the classroom. topic_id_to_prerequisite_topic_ids: dict(str, list(str)). A dict with topic ID as key and list of topic IDs as value. + is_published: bool. Whether this classroom is published or not. + thumbnail_filename: str. Classroom's thumbnail filename. + thumbnail_bg_color: str. Classroom's thumbnail background color. + thumbnail_size_in_bytes: int. The thumbnail size in bytes. + banner_filename: str. Classroom's banner filename. + banner_bg_color: str. Classroom's banner background color. + banner_size_in_bytes: int. The banner size in bytes. Returns: ClassroomModel. The newly created ClassroomModel instance. @@ -135,9 +173,17 @@ def create( name=name, url_fragment=url_fragment, course_details=course_details, + teaser_text=teaser_text, topic_list_intro=topic_list_intro, topic_id_to_prerequisite_topic_ids=( - topic_id_to_prerequisite_topic_ids) + topic_id_to_prerequisite_topic_ids), + is_published=is_published, + thumbnail_filename=thumbnail_filename, + thumbnail_bg_color=thumbnail_bg_color, + thumbnail_size_in_bytes=thumbnail_size_in_bytes, + banner_filename=banner_filename, + banner_bg_color=banner_bg_color, + banner_size_in_bytes=banner_size_in_bytes ) entity.update_timestamps() entity.put() diff --git a/core/storage/classroom/gae_models_test.py b/core/storage/classroom/gae_models_test.py index 539028fbbac9..336f4afbd4e6 100644 --- a/core/storage/classroom/gae_models_test.py +++ b/core/storage/classroom/gae_models_test.py @@ -43,8 +43,13 @@ def setUp(self) -> None: name='math', url_fragment='math', course_details='Curated math foundations course.', + teaser_text='Learn math through fun stories!', topic_list_intro='Start from the basics with our first topic.', - topic_id_to_prerequisite_topic_ids={} + topic_id_to_prerequisite_topic_ids={}, + is_published=True, thumbnail_filename='thumbnail.svg', + thumbnail_bg_color='transparent', thumbnail_size_in_bytes=1000, + banner_filename='banner.png', banner_bg_color='transparent', + banner_size_in_bytes=1000 ) self.classroom_model.update_timestamps() self.classroom_model.put() @@ -54,15 +59,34 @@ def test_create_new_model(self) -> None: classroom_models.ClassroomModel.generate_new_classroom_id()) classroom_model_instance = (classroom_models.ClassroomModel.create( classroom_id, 'physics', 'physics', 'Curated physics course.', - 'Start from the basic physics.', {})) + 'Learn physics through fun stories!', + 'Start from the basic physics.', {}, False, + 'thumbnail.svg', 'transparent', 1000, 'banner.png', + 'transparent', 1000)) self.assertEqual(classroom_model_instance.name, 'physics') self.assertEqual(classroom_model_instance.url_fragment, 'physics') self.assertEqual( classroom_model_instance.course_details, 'Curated physics course.') + self.assertEqual( + classroom_model_instance.teaser_text, + 'Learn physics through fun stories!') self.assertEqual( classroom_model_instance.topic_list_intro, - 'Start from the basic physics.') + 'Start from the basic physics.') + self.assertEqual(classroom_model_instance.is_published, False) + self.assertEqual( + classroom_model_instance.thumbnail_filename, 'thumbnail.svg') + self.assertEqual( + classroom_model_instance.thumbnail_bg_color, 'transparent') + self.assertEqual( + classroom_model_instance.thumbnail_size_in_bytes, 1000) + self.assertEqual( + classroom_model_instance.banner_filename, 'banner.png') + self.assertEqual( + classroom_model_instance.banner_bg_color, 'transparent') + self.assertEqual( + classroom_model_instance.banner_size_in_bytes, 1000) def test_get_export_policy_not_applicable(self) -> None: self.assertEqual( @@ -74,9 +98,19 @@ def test_get_export_policy_not_applicable(self) -> None: 'name': base_models.EXPORT_POLICY.NOT_APPLICABLE, 'url_fragment': base_models.EXPORT_POLICY.NOT_APPLICABLE, 'course_details': base_models.EXPORT_POLICY.NOT_APPLICABLE, + 'teaser_text': base_models.EXPORT_POLICY.NOT_APPLICABLE, 'topic_list_intro': base_models.EXPORT_POLICY.NOT_APPLICABLE, 'topic_id_to_prerequisite_topic_ids': ( - base_models.EXPORT_POLICY.NOT_APPLICABLE) + base_models.EXPORT_POLICY.NOT_APPLICABLE), + 'is_published': base_models.EXPORT_POLICY.NOT_APPLICABLE, + 'thumbnail_filename': ( + base_models.EXPORT_POLICY.NOT_APPLICABLE), + 'thumbnail_bg_color': base_models.EXPORT_POLICY.NOT_APPLICABLE, + 'thumbnail_size_in_bytes': ( + base_models.EXPORT_POLICY.NOT_APPLICABLE), + 'banner_filename': base_models.EXPORT_POLICY.NOT_APPLICABLE, + 'banner_bg_color': base_models.EXPORT_POLICY.NOT_APPLICABLE, + 'banner_size_in_bytes': base_models.EXPORT_POLICY.NOT_APPLICABLE } ) @@ -138,7 +172,10 @@ def test_raise_exception_by_mocking_collision(self) -> None: classroom_model_cls.create( 'classroom_id', 'math', 'math', 'Curated math foundations course.', - 'Start from the basic math.', {} + 'Learn math through fun stories!', + 'Start from the basic math.', {}, True, + 'thumbnail.svg', 'transparent', 1000, 'banner.png', + 'transparent', 1000 ) # Test generate_new_classroom_id method. diff --git a/core/templates/domain/classroom/classroom-backend-api.service.spec.ts b/core/templates/domain/classroom/classroom-backend-api.service.spec.ts index a0055b2783a1..f446f915e341 100644 --- a/core/templates/domain/classroom/classroom-backend-api.service.spec.ts +++ b/core/templates/domain/classroom/classroom-backend-api.service.spec.ts @@ -329,6 +329,18 @@ describe('Classroom backend API service', function () { course_details: 'Curated math foundations course.', topic_list_intro: 'Start from the basics with our first topic.', topic_id_to_prerequisite_topic_ids: {}, + teaser_text: 'Teaser text of the classroom', + is_published: true, + thumbnail_data: { + filename: 'thumbnail.svg', + bg_color: 'transparent', + size_in_bytes: 1000, + }, + banner_data: { + filename: 'banner.svg', + bg_color: 'transparent', + size_in_bytes: 1000, + }, }; let payload = { classroom_dict: classroomBackendDict, @@ -361,6 +373,18 @@ describe('Classroom backend API service', function () { course_details: 'Curated math foundations course.', topic_list_intro: 'Start from the basics with our first topic.', topic_id_to_prerequisite_topic_ids: {}, + teaser_text: 'Teaser text of the classroom', + is_published: true, + thumbnail_data: { + filename: 'thumbnail.svg', + bg_color: 'transparent', + size_in_bytes: 1000, + }, + banner_data: { + filename: 'banner.svg', + bg_color: 'transparent', + size_in_bytes: 1000, + }, }; let payload = { classroom_dict: classroomBackendDict, diff --git a/core/templates/domain/classroom/classroom-backend-api.service.ts b/core/templates/domain/classroom/classroom-backend-api.service.ts index 9ebbb10d0f30..bba979e5d013 100644 --- a/core/templates/domain/classroom/classroom-backend-api.service.ts +++ b/core/templates/domain/classroom/classroom-backend-api.service.ts @@ -211,7 +211,31 @@ export class ClassroomBackendApiService { ); this.http - .put(classroomUrl, {classroom_dict: classroomDict}) + // TODO(#20418): Update this once Frontend part of M1.2 is complete. + .put(classroomUrl, { + classroom_dict: { + classroom_id: classroomDict.classroom_id, + name: classroomDict.name, + url_fragment: classroomDict.url_fragment, + course_details: classroomDict.course_details || 'Course details', + topic_list_intro: + classroomDict.topic_list_intro || 'Topic List intro', + topic_id_to_prerequisite_topic_ids: + classroomDict.topic_id_to_prerequisite_topic_ids, + teaser_text: 'Teaser text of the classroom', + is_published: true, + thumbnail_data: { + filename: 'thumbnail.svg', + bg_color: 'transparent', + size_in_bytes: 1000, + }, + banner_data: { + filename: 'banner.svg', + bg_color: 'transparent', + size_in_bytes: 1000, + }, + }, + }) .toPromise() .then( response => { diff --git a/core/tests/test_utils.py b/core/tests/test_utils.py index 9535f5b1bbbc..e1b09f9cc3dd 100644 --- a/core/tests/test_utils.py +++ b/core/tests/test_utils.py @@ -46,6 +46,8 @@ from core.domain import blog_services from core.domain import caching_domain from core.domain import classifier_domain +from core.domain import classroom_config_domain +from core.domain import classroom_config_services from core.domain import collection_domain from core.domain import collection_services from core.domain import exp_domain @@ -4221,6 +4223,79 @@ def _create_valid_question_data( state.interaction.default_outcome.dest = None return state + def save_new_valid_classroom( + self, + classroom_id: str = 'math_classroom_id', + name: str = 'math', + url_fragment: str = 'math', + course_details: str = 'Course Details', + teaser_text: str = 'Teaser Text', + topic_list_intro: str = 'Topic list intro', + topic_id_to_prerequisite_topic_ids: Optional[ + Dict[str, List[str]]] = None, + is_published: bool = True, + thumbnail_data: Optional[ + classroom_config_domain.ImageData + ] = None, + banner_data: Optional[ + classroom_config_domain.ImageData + ] = None + ) -> classroom_config_domain.Classroom: + """Saves a new strictly-validated classroom. + + Args: + classroom_id: str. Classroom ID of the newly-created classroom. + name: str. The name of the classroom. + url_fragment: str. The url fragment of the classroom. + course_details: str. A text to provide course details present in + the classroom. + teaser_text: str. A text to provide a summary of the classroom. + topic_list_intro: str. A text to provide an introduction for all + the topics in the classroom. + topic_id_to_prerequisite_topic_ids: Dict[str, List[str]]. A dict + with topic ID as key and list of topic IDs as value. + is_published: bool. Whether this classroom is published or not. + thumbnail_data: Optional[ImageData]. Image data object for + thumbnail. + banner_data: Optional[ImageData]. Image data object for banner. + + Returns: + Classroom. The classroom domain object. + """ + dummy_thumbnail_data = classroom_config_domain.ImageData( + 'thumbnail.svg', 'transparent', 1000 + ) + dummy_banner_data = classroom_config_domain.ImageData( + 'banner.png', 'transparent', 1000 + ) + classroom = classroom_config_domain.Classroom( + classroom_id=classroom_id, + name=name, + url_fragment=url_fragment, + teaser_text=teaser_text, + course_details=course_details, + topic_list_intro=topic_list_intro, + topic_id_to_prerequisite_topic_ids=( + topic_id_to_prerequisite_topic_ids + if topic_id_to_prerequisite_topic_ids is not None + else {} + ), + is_published=is_published, + thumbnail_data=( + thumbnail_data + if thumbnail_data is not None + else dummy_thumbnail_data + ), + banner_data=( + banner_data + if banner_data is not None + else dummy_banner_data + ) + ) + + classroom_config_services.create_new_classroom(classroom) + return classroom + class LinterTestBase(GenericTestBase): """Base class for linter tests."""