Repository: edunext/eox-nelp Files analyzed: 396 Estimated tokens: 2.5M Directory structure: └── edunext-eox-nelp/ ├── AUTHORS.txt ├── CHANGELOG.rst ├── commitlint.config.js ├── conftest.py ├── LICENSE.txt ├── Makefile ├── manage.py ├── MANIFEST.in ├── package.json ├── README.rst ├── setup.cfg ├── setup.py ├── tox.ini ├── webpack.common.config.js ├── webpack.dev.config.js ├── webpack.prod.config.js ├── .babelrc ├── .env.frontend ├── docs/ │ └── decisions/ │ └── 0001-course-experience.rst ├── eox_nelp/ │ ├── __init__.py │ ├── apps.py │ ├── cms_urls.py │ ├── init_pipeline.py │ ├── middleware.py │ ├── urls.py │ ├── utils.py │ ├── validators.py │ ├── views.py │ ├── admin/ │ │ ├── __init__.py │ │ ├── certificates.py │ │ ├── course_creators.py │ │ ├── register_admin_model.py │ │ ├── student.py │ │ ├── user.py │ │ └── tests/ │ │ ├── __init__.py │ │ └── test_admin.py │ ├── cms/ │ │ ├── __init__.py │ │ └── api/ │ │ ├── __init__.py │ │ ├── urls.py │ │ └── v1/ │ │ ├── __init__.py │ │ ├── permissions.py │ │ ├── routers.py │ │ ├── urls.py │ │ ├── views.py │ │ └── tests/ │ │ ├── __init__.py │ │ └── test_views.py │ ├── course_api/ │ │ ├── __init__.py │ │ ├── urls.py │ │ └── v1/ │ │ ├── __init__.py │ │ ├── serializers.py │ │ ├── urls.py │ │ ├── views.py │ │ └── tests/ │ │ ├── __init__.py │ │ └── test_views.py │ ├── course_experience/ │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── models.py │ │ ├── api/ │ │ │ ├── __init__.py │ │ │ ├── urls.py │ │ │ └── v1/ │ │ │ ├── __init__.py │ │ │ ├── filters.py │ │ │ ├── relations.py │ │ │ ├── routers.py │ │ │ ├── serializers.py │ │ │ ├── urls.py │ │ │ ├── views.py │ │ │ └── tests/ │ │ │ ├── __init__.py │ │ │ ├── mixins_helpers.py │ │ │ └── test_views.py │ │ ├── frontend/ │ │ │ ├── __init__.py │ │ │ ├── urls.py │ │ │ ├── views.py │ │ │ ├── src/ │ │ │ │ └── components/ │ │ │ │ └── FeedbackCarousel/ │ │ │ │ ├── index.jsx │ │ │ │ └── index.scss │ │ │ ├── templates/ │ │ │ │ ├── __init__.py │ │ │ │ └── feedback_courses.html │ │ │ └── tests/ │ │ │ ├── __init__.py │ │ │ └── test_views.py │ │ └── tests/ │ │ ├── __init__.py │ │ └── test_models.py │ ├── edxapp_wrapper/ │ │ ├── branding.py │ │ ├── bulk_email.py │ │ ├── certificates.py │ │ ├── cms_api.py │ │ ├── contentstore.py │ │ ├── course_api.py │ │ ├── course_blocks.py │ │ ├── course_creators.py │ │ ├── course_experience.py │ │ ├── course_overviews.py │ │ ├── courseware.py │ │ ├── django_comment_common.py │ │ ├── edxmako.py │ │ ├── event_routing_backends.py │ │ ├── grades.py │ │ ├── instructor.py │ │ ├── mfe_config_view.py │ │ ├── modulestore.py │ │ ├── site_configuration.py │ │ ├── student.py │ │ ├── third_party_auth.py │ │ ├── user_api.py │ │ ├── user_authn.py │ │ ├── backends/ │ │ │ ├── branding_m_v1.py │ │ │ ├── bulk_email_m_v1.py │ │ │ ├── certificates_m_v1.py │ │ │ ├── cms_api_m_v1.py │ │ │ ├── contentstore_r_v1.py │ │ │ ├── course_api_m_v1.py │ │ │ ├── course_blocks_m_v1.py │ │ │ ├── course_creators_k_v1.py │ │ │ ├── course_experience_p_v1.py │ │ │ ├── course_overviews_m_v1.py │ │ │ ├── courseware_m_v1.py │ │ │ ├── django_comment_common_r_v1.py │ │ │ ├── edxmako_m_v1.py │ │ │ ├── event_routing_backends_m_v1.py │ │ │ ├── grades_m_v1.py │ │ │ ├── instructor_m_v1.py │ │ │ ├── mfe_config_view_m_v1.py │ │ │ ├── modulestore_m_v1.py │ │ │ ├── site_configuration_m_v1.py │ │ │ ├── student_m_v1.py │ │ │ ├── third_party_auth_r_v1.py │ │ │ ├── user_api_m_v1.py │ │ │ └── user_authn_r_v1.py │ │ ├── test_backends/ │ │ │ ├── __init__.py │ │ │ ├── branding_m_v1.py │ │ │ ├── bulk_email_m_v1.py │ │ │ ├── certificates_m_v1.py │ │ │ ├── cms_api_m_v1.py │ │ │ ├── contentstore_r_v1.py │ │ │ ├── course_api_m_v1.py │ │ │ ├── course_blocks_m_v1.py │ │ │ ├── course_creators_k_v1.py │ │ │ ├── course_experience_p_v1.py │ │ │ ├── course_overviews_m_v1.py │ │ │ ├── courseware_m_v1.py │ │ │ ├── django_comment_common_r_v1.py │ │ │ ├── edxmako_m_v1.py │ │ │ ├── event_routing_backends_m_v1.py │ │ │ ├── grades_m_v1.py │ │ │ ├── instructor_m_v1.py │ │ │ ├── mfe_config_view_m_v1.py │ │ │ ├── modulestore_m_v1.py │ │ │ ├── site_configuration_m_v1.py │ │ │ ├── student_m_v1.py │ │ │ ├── third_party_auth_r_v1.py │ │ │ ├── user_api_m_v1.py │ │ │ ├── user_authn_r_v1.py │ │ │ └── users_p_v1.py │ │ └── tests/ │ │ └── integration/ │ │ └── test_backends.py │ ├── external_certificates/ │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── models.py │ │ ├── tasks.py │ │ ├── api/ │ │ │ ├── __init__.py │ │ │ ├── urls.py │ │ │ └── v1/ │ │ │ ├── __init__.py │ │ │ ├── serializers.py │ │ │ ├── urls.py │ │ │ └── views.py │ │ └── tests/ │ │ ├── __init__.py │ │ ├── test_models.py │ │ ├── test_tasks.py │ │ └── api/ │ │ ├── __init__.py │ │ └── v1/ │ │ ├── __init__.py │ │ └── test_views.py │ ├── i18n/ │ │ └── index.js │ ├── locale/ │ │ ├── config.yaml │ │ └── ar/ │ │ └── LC_MESSAGES/ │ │ ├── django.mo │ │ └── django.po │ ├── management/ │ │ ├── __init__.py │ │ └── commands/ │ │ ├── __init__.py │ │ ├── notify_course_due_dates.py │ │ ├── set_default_advanced_modules.py │ │ └── tests/ │ │ ├── __init__.py │ │ ├── test_check_notify_course_due_dates.py │ │ └── test_set_default_advanced_modules.py │ ├── mfe_config_api/ │ │ ├── urls.py │ │ └── v1/ │ │ ├── urls.py │ │ └── views.py │ ├── migrations/ │ │ ├── 0001_initial.py │ │ ├── 0002_upcomingcourseduedate.py │ │ ├── 0003_feedback.py │ │ ├── 0004_feedback_public_default_to_false.py │ │ ├── 0005_paymentnotification.py │ │ ├── 0006_auto_20230726_1707.py │ │ ├── 0007_auto_20240529_1724.py │ │ ├── 0008_pearsonrtenevent.py │ │ ├── 0009_pearsonrtenevent_appointments.py │ │ ├── 0010_pearsonrtenevent_candidate.py │ │ ├── 0011_pearsonrtenevent_course.py │ │ ├── 0012_add_pearson_engines_register.py │ │ ├── 0013_delete_pearsonrtenevent.py │ │ ├── 0014_add_external_certificate.py │ │ ├── 0015_alter_reportcourse_reason.py │ │ ├── 0016_data_update_sc_to_ic_report_reason.py │ │ ├── 0017_alter_reportunit_reason.py │ │ └── __init__.py │ ├── notifications/ │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── models.py │ │ ├── notify_course_due_date.py │ │ ├── tasks.py │ │ ├── utils.py │ │ └── tests/ │ │ ├── __init__.py │ │ ├── test_admin.py │ │ ├── test_models.py │ │ ├── test_notify_course_due_date.py │ │ ├── test_tasks.py │ │ └── test_utils.py │ ├── one_time_password/ │ │ ├── __init__.py │ │ ├── generators.py │ │ ├── view_decorators.py │ │ └── api/ │ │ ├── __init__.py │ │ ├── urls.py │ │ └── v1/ │ │ ├── __init__.py │ │ ├── urls.py │ │ ├── views.py │ │ └── tests/ │ │ ├── __init__.py │ │ └── test_views.py │ ├── openedx_filters/ │ │ ├── __init__.py │ │ ├── tests/ │ │ │ ├── __init__.py │ │ │ └── xapi/ │ │ │ ├── __init__.py │ │ │ └── test_filters.py │ │ └── xapi/ │ │ ├── __init__.py │ │ └── filters.py │ ├── payment_notifications/ │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── context_processor.py │ │ ├── models.py │ │ ├── urls.py │ │ ├── views.py │ │ └── templates/ │ │ ├── __init__.py │ │ ├── payment_notifications.html │ │ └── payment_notifications_with_frame.html │ ├── pearson_vue_engine/ │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── constants.py │ │ ├── decorators.py │ │ ├── models.py │ │ ├── tasks.py │ │ ├── utils.py │ │ └── tests/ │ │ ├── __init__.py │ │ ├── test_models.py │ │ ├── test_tasks.py │ │ └── test_utils.py │ ├── processors/ │ │ ├── __init__.py │ │ ├── tests/ │ │ │ ├── __init__.py │ │ │ └── xapi/ │ │ │ ├── __init__.py │ │ │ ├── mixins.py │ │ │ ├── test_mixins.py │ │ │ └── event_transformers/ │ │ │ ├── __init__.py │ │ │ ├── test_course_experience_events.py │ │ │ ├── test_grade_events.py │ │ │ └── test_initialized_events.py │ │ └── xapi/ │ │ ├── __init__.py │ │ ├── constants.py │ │ ├── mixins.py │ │ └── event_transformers/ │ │ ├── __init__.py │ │ ├── course_experience_events.py │ │ ├── grade_events.py │ │ └── initialized_events.py │ ├── programs/ │ │ ├── README.md │ │ ├── __init__.py │ │ └── api/ │ │ ├── __init__.py │ │ ├── urls.py │ │ └── v1/ │ │ ├── __init__.py │ │ ├── constants.py │ │ ├── permissions.py │ │ ├── serializers.py │ │ ├── urls.py │ │ ├── utils.py │ │ ├── views.py │ │ └── tests/ │ │ ├── __init__.py │ │ ├── test_utils.py │ │ └── test_views.py │ ├── settings/ │ │ ├── __init__.py │ │ ├── common.py │ │ ├── devstack.py │ │ ├── production.py │ │ └── test.py │ ├── signals/ │ │ ├── __init__.py │ │ ├── receivers.py │ │ ├── tasks.py │ │ ├── utils.py │ │ └── tests/ │ │ ├── __init__.py │ │ ├── test_receivers.py │ │ ├── test_tasks.py │ │ └── test_utils.py │ ├── static/ │ │ ├── __init__.py │ │ ├── feedback_carousel/ │ │ │ ├── index.html │ │ │ ├── css/ │ │ │ │ └── feedback_carousel.css │ │ │ └── js/ │ │ │ ├── feedback_carousel.js │ │ │ └── feedback_carousel.js.LICENSE.txt │ │ ├── tenant_stats/ │ │ │ ├── index.html │ │ │ ├── css/ │ │ │ │ └── tenant_stats.css │ │ │ └── js/ │ │ │ ├── tenant_stats.js │ │ │ └── tenant_stats.js.LICENSE.txt │ │ └── user_profile/ │ │ ├── index.html │ │ ├── css/ │ │ │ └── user_profile.css │ │ └── js/ │ │ ├── user_profile.js │ │ └── user_profile.js.LICENSE.txt │ ├── stats/ │ │ ├── __init__.py │ │ ├── decorators.py │ │ ├── metrics.py │ │ ├── urls.py │ │ ├── views.py │ │ ├── api/ │ │ │ ├── __init__.py │ │ │ ├── urls.py │ │ │ └── v1/ │ │ │ ├── __init__.py │ │ │ ├── urls.py │ │ │ └── views.py │ │ ├── frontend/ │ │ │ └── src/ │ │ │ └── components/ │ │ │ └── TenantStats/ │ │ │ ├── index.jsx │ │ │ └── index.scss │ │ ├── templates/ │ │ │ ├── __init__.py │ │ │ └── tenant_stats.html │ │ └── tests/ │ │ ├── __init__.py │ │ ├── test_decorators.py │ │ ├── test_metrics.py │ │ ├── test_views.py │ │ └── api/ │ │ ├── __init__.py │ │ └── v1/ │ │ ├── __init__.py │ │ └── test_views.py │ ├── tests/ │ │ ├── __init__.py │ │ ├── mixins.py │ │ ├── test_init_pipeline.py │ │ ├── test_utils.py │ │ ├── test_validators.py │ │ ├── test_views.py │ │ ├── utils.py │ │ └── integration/ │ │ ├── __init__.py │ │ ├── constants.py │ │ └── test_views.py │ ├── third_party_auth/ │ │ ├── __init__.py │ │ ├── exceptions.py │ │ ├── pipeline.py │ │ ├── utils.py │ │ └── tests/ │ │ ├── __init__.py │ │ └── test_pipeline.py │ ├── tutor_plugins/ │ │ └── override_default_config.py │ ├── user_authn/ │ │ ├── __init__.py │ │ ├── utils.py │ │ ├── api/ │ │ │ ├── __init__.py │ │ │ └── patches.py │ │ ├── tests/ │ │ │ ├── __init__.py │ │ │ ├── test_views.py │ │ │ └── api/ │ │ │ ├── __init__.py │ │ │ └── test_patches.py │ │ └── views/ │ │ └── registration_form.py │ ├── user_profile/ │ │ ├── __init__.py │ │ ├── required_fields_validation.py │ │ ├── api/ │ │ │ ├── __init__.py │ │ │ ├── urls.py │ │ │ └── v1/ │ │ │ ├── __init__.py │ │ │ ├── helpers.py │ │ │ ├── urls.py │ │ │ ├── views.py │ │ │ └── tests/ │ │ │ ├── __init__.py │ │ │ └── test_views.py │ │ ├── frontend/ │ │ │ └── src/ │ │ │ └── components/ │ │ │ └── UserProfileForm/ │ │ │ ├── index.jsx │ │ │ └── index.scss │ │ ├── templates/ │ │ │ ├── __init__.py │ │ │ └── user_profile_form.html │ │ └── tests/ │ │ ├── __init__.py │ │ └── test_required_fields_validation.py │ └── users/ │ ├── __init__.py │ ├── urls.py │ ├── api/ │ │ ├── __init__.py │ │ └── v1/ │ │ ├── serializers.py │ │ ├── urls.py │ │ └── views.py │ └── tests/ │ ├── __init__.py │ ├── test_serializers.py │ └── test_views.py ├── requirements/ │ ├── base.in │ ├── base.txt │ ├── constraints.txt │ ├── django.txt │ ├── eox-audit-model.in │ ├── pip-tools.in │ ├── pip-tools.txt │ ├── test.in │ ├── test.txt │ ├── tox.in │ └── tox.txt ├── scripts/ │ └── execute_integration_tests.sh └── .github/ ├── labeler.yml ├── pull_request_template.md └── workflows/ ├── commitlint.yml ├── integration-test.yml ├── lines_labeler.yml ├── scope_labeler.yml ├── strain_dispatcher.yml └── tests.yml ================================================ FILE: AUTHORS.txt ================================================ ================================================ FILE: CHANGELOG.rst ================================================ Change Log ========== .. All enhancements and patches to eox_nelp will be documented in this file. It adheres to the structure of http://keepachangelog.com/ , but in reStructuredText instead of Markdown (for ease of incorporation into Sphinx documentation and the PyPI description). This project adheres to Semantic Versioning (http://semver.org/). .. There should always be an "Unreleased" section for changes pending release. Unreleased ---------- [1.0.0] - 2022-10-18 --------------------- Added ~~~~~ * Maple compatibility. Removed ~~~~~ * Koa compatibility [0.2.1] - 2022-09-19 --------------------- Added ~~~~~ * Add image url functionality to courses endpoint. [0.2.0] - 2022-08-23 --------------------- Added ~~~~~ * User search button to add user in course creator model admin. [0.1.0] - 2022-08-22 --------------------- Added ~~~~~ * Admin `course_creator` model to studio. Changed ~~~~~~~ * **BREAKING CHANGE**: Remove unnecessary files from `eox-core` ancestors * **BREAKING CHANGE**: Changed plugin settings to the requirement of nelp. * **BREAKING CHANGE**: Copy from edx-platform and Keep only course_api to customize the courses endpoint. [0.0.0] - 2022-03-30 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Added ~~~~~ * Hello world for the plugin. Starting in Koa. ================================================ FILE: commitlint.config.js ================================================ module.exports = { parserPreset: 'conventional-changelog-conventionalcommits', rules: { 'body-leading-blank': [1, 'always'], 'body-max-line-length': [1, 'always', 100], 'footer-leading-blank': [1, 'always'], 'footer-max-line-length': [1, 'always', 100], 'header-max-length': [2, 'always', 100], 'subject-case': [ 2, 'never', ['sentence-case', 'start-case', 'pascal-case', 'upper-case'], ], 'subject-empty': [2, 'never'], 'subject-full-stop': [2, 'never', '.'], 'type-case': [2, 'always', 'lower-case'], 'type-empty': [2, 'never'], 'type-enum': [ 2, 'always', [ 'build', 'chore', 'ci', 'docs', 'feat', 'fix', 'perf', 'refactor', 'revert', 'style', 'test', ], ], }, prompt: { questions: { type: { description: "Select the type of change that you're committing", enum: { feat: { description: 'A new feature', title: 'Features', emoji: '✨', }, fix: { description: 'A bug fix', title: 'Bug Fixes', emoji: '🐛', }, docs: { description: 'Documentation only changes', title: 'Documentation', emoji: '📚', }, style: { description: 'Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)', title: 'Styles', emoji: '💎', }, refactor: { description: 'A code change that neither fixes a bug nor adds a feature', title: 'Code Refactoring', emoji: '📦', }, perf: { description: 'A code change that improves performance', title: 'Performance Improvements', emoji: '🚀', }, test: { description: 'Adding missing tests or correcting existing tests', title: 'Tests', emoji: '🚨', }, build: { description: 'Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)', title: 'Builds', emoji: '🛠', }, ci: { description: 'Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)', title: 'Continuous Integrations', emoji: '⚙️', }, chore: { description: "Other changes that don't modify src or test files", title: 'Chores', emoji: '♻️', }, revert: { description: 'Reverts a previous commit', title: 'Reverts', emoji: '🗑', }, }, }, scope: { description: 'What is the scope of this change (e.g. component or file name)', }, subject: { description: 'Write a short, imperative tense description of the change', }, body: { description: 'Provide a longer description of the change', }, isBreaking: { description: 'Are there any breaking changes?', }, breakingBody: { description: 'A BREAKING CHANGE commit requires a body. Please enter a longer description of the commit itself', }, breaking: { description: 'Describe the breaking changes', }, isIssueAffected: { description: 'Does this change affect any open issues?', }, issuesBody: { description: 'If issues are closed, the commit requires a body. Please enter a longer description of the commit itself', }, issues: { description: 'Add issue references (e.g. "fix #123", "re #123".)', }, }, }, }; ================================================ FILE: conftest.py ================================================ """Pytest configuration to enable database access globally in all tests.""" import pathlib import pytest @pytest.fixture(autouse=True) def conditional_db_access(request): """Enable DB access only for tests outside of integration folder.""" test_path = pathlib.Path(str(request.node.fspath)) if "tests/integration" not in str(test_path): # Import and activate the real db fixture only if not integration test request.getfixturevalue("db") ================================================ FILE: LICENSE.txt ================================================ GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . ================================================ FILE: Makefile ================================================ ############################################### # # Nelp plugin for custom development commands. # ############################################### intl_imports = ./node_modules/.bin/intl-imports.js OPENEDX_ATLAS_PULL=true ATLAS_OPTIONS=--repository=nelc/futurex-translations --revision=open-release/redwood.master # Define PIP_COMPILE_OPTS=-v to get more information during make upgrade. PIP_COMPILE = pip-compile --rebuild --upgrade $(PIP_COMPILE_OPTS) .DEFAULT_GOAL := help ifdef TOXENV TOX := tox -- #to isolate each tox environment if TOXENV is defined endif help: ## display this help message @echo "Please use \`make ' where is one of" @grep '^[a-zA-Z]' $(MAKEFILE_LIST) | sort | awk -F ':.*?## ' 'NF==2 {printf "\033[36m %-25s\033[0m %s\n", $$1, $$2}' clean: ## delete most git-ignored files find . -name '__pycache__' -exec rm -rf {} + find . -name '*.pyc' -exec rm -f {} + find . -name '*.pyo' -exec rm -f {} + find . -name '*~' -exec rm -f {} + rm -fr *.egg-info clean-coverage: clean ##clean and clean coverage file coverage erase requirements: ## install environment requirements pip install -r requirements/base.txt install-automation-reqs: ## install tox requirements pip install -r requirements/tox.txt install-test-reqs: ## install test requirements $(TOX) pip install -r requirements/test.txt --exists-action w upgrade: export CUSTOM_COMPILE_COMMAND=make upgrade upgrade: ## update the requirements/*.txt files with the latest packages satisfying requirements/*.in pip install -qr requirements/pip-tools.txt # Make sure to compile files after any other files they include! $(PIP_COMPILE) -o requirements/pip-tools.txt requirements/pip-tools.in $(PIP_COMPILE) -o requirements/base.txt requirements/base.in $(PIP_COMPILE) -o requirements/test.txt requirements/test.in $(PIP_COMPILE) -o requirements/tox.txt requirements/tox.in grep -e "^django==" requirements/test.txt > requirements/django.txt quality: clean install-test-reqs## check coding style with pycodestyle and pylint $(TOX) pycodestyle ./eox_nelp $(TOX) pylint ./eox_nelp --rcfile=./setup.cfg --fail-on=I0021 $(TOX) isort --check-only --diff ./eox_nelp python-test: clean install-test-reqs## Run test suite. $(TOX) coverage run --omit="./eox_nelp/tests/integration/*" --source="./eox_nelp" -m pytest -s ./eox_nelp --ignore-glob='**/integration/*' $(TOX) coverage report -m --fail-under=93 complexity: clean install-test-reqs## Run complexity suite with flake8. $(TOX) flake8 --max-complexity 10 eox_nelp run-tests: python-test quality complexity## run all tests automation-run-tests: install-automation-reqs run-tests ## run all tests with tox build_react_apps: pull_react_translations npm run build git checkout -- eox_nelp/i18n/index.js ##-----------------------------Translations section------------------------- extract-translations: ## extract strings to be translated, outputting .mo files ./manage.py makemessages -l ar -i manage -i setup -i "venv/*" compile-translations: ## compile translation files, outputting .po files for each supported language cd eox_nelp && ../manage.py compilemessages detect-changed-source-translations: cd eox_nelp && i18n_tool changed pull-translations: ## pull translations from Transifex tx pull -af --mode reviewed push-translations: ## push source translation files (.po) from Transifex tx push -s pull_react_translations: rm -rf src/i18n/messages mkdir -p src/i18n/messages cd src/i18n/messages \ && export PATH="$(shell pwd)/node_modules/.bin:$$PATH" && atlas pull $(ATLAS_OPTIONS) \ translations/frontend-platform/src/i18n/messages:frontend-platform \ translations/paragon/src/i18n/messages:paragon \ translations/frontend-essentials/src/i18n/messages:frontend-essentials $(intl_imports) frontend-platform paragon frontend-essentials cp -r src/i18n/* eox_nelp/i18n/ rm -rf src run-integration-tests: install-test-reqs pytest -rPf ./eox_nelp/tests/integration ================================================ FILE: manage.py ================================================ #!/usr/bin/env python """ Django administration utility. """ import os import sys PWD = os.path.abspath(os.path.dirname(__file__)) if __name__ == '__main__': os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eox_nelp.settings.test') sys.path.append(PWD) try: from django.core.management import execute_from_command_line # pylint: disable=wrong-import-position except ImportError: # The above import may fail for some other reason. Ensure that the # issue is really that Django is missing to avoid masking other # exceptions on Python 2. try: import django # pylint: disable=unused-import, wrong-import-position except ImportError: raise ImportError( "Couldn't import Django. Are you sure it's installed and " "available on your PYTHONPATH environment variable? Did you " "forget to activate a virtual environment?" ) raise execute_from_command_line(sys.argv) ================================================ FILE: MANIFEST.in ================================================ include CHANGELOG.rst include LICENSE.txt include README.md include requirements/base.in include requirements/eox-audit-model.in recursive-include eox_nelp *.html *.png *.gif *js *.css *jpg *jpeg *svg *py ================================================ FILE: package.json ================================================ { "name": "eox-nelp", "version": "1.0.0", "description": "Openedx plugin for rendering frontend-essentials library components and embedding their functionality into templates.", "main": "index.js", "scripts": { "build": "fedx-scripts webpack --config ./webpack.prod.config.js", "dev": "fedx-scripts webpack --config ./webpack.dev.config.js --watch" }, "repository": { "type": "git", "url": "git+ssh://git@bitbucket.org/edunext/eox-nelp.git" }, "author": "", "license": "ISC", "homepage": "https://bitbucket.org/edunext/eox-nelp#readme", "dependencies": { "@edunext/frontend-essentials": "^4.7.0", "@edx/brand": "github:nelc/brand-openedx#open-release/redwood.nelp", "@edx/frontend-platform": "npm:@edunext/frontend-platform@^7.1.2-alpha-nelc.1", "@openedx/paragon": "23.0.0-alpha.1", "babel-loader": "^8.2.3", "react": "17.0.2", "react-dom": "17.0.2", "webpack": "^5.86.0" }, "devDependencies": { "@edx/browserslist-config": "1.2.0", "@edx/openedx-atlas": "0.6.0", "@openedx/frontend-build": "github:edunext/frontend-build#ednx-release/css-variables-13.1.4", "clean-webpack-plugin": "^4.0.0", "dotenv-webpack": "^8.0.1", "html-webpack-plugin": "^5.5.4", "sass": "1.72.0", "sass-loader": "14.1.1", "webpack-merge": "^5.9.0" } } ================================================ FILE: README.rst ================================================ =================================== Nelp plugin for custom development. =================================== Features ######## - Courses api endpoint modified. {lms-doamin}/eox-nelp/courses/v1/courses/ - Extra `course_creator` model add option in studio admin. Installation ############ Open edX devstack ***************** - Clone this repo in the src folder of your devstack. - Open a new Lms/Devstack shell. - Install the plugin as follows: pip install -e /path/to/your/src/folder - Restart Lms/Studio services. Usage ##### Extend `edx-platform` for Nelp requirements without changing base platform code. ================================================ FILE: setup.cfg ================================================ [bumpversion] current_version = 1.0.0 commit = True tag = True [tool:pytest] DJANGO_SETTINGS_MODULE = eox_nelp.settings.test [coverage:run] data_file = .coverage omit = venv/* */backends/* node_modules/* .tox/* ./setup.py .tox/* .git/* migrations */settings/production.py */settings/devstack.py */eox_tenant_cache_programs.py [pycodestyle] count = False ignore = E501, W503, R903 max-line-length = 120 statistics = True [flake8] max-line-length = 120 ignore = R903, W503 per-file-ignores = */__init__.py: F403 */settings/*.py: F403 */migrations/*: E501 [isort] default_section = THIRDPARTY known_first_party = eox_nelp include_trailing_comma = True indent = ' ' line_length = 120 multi_line_output = 3 skip_glob=*/migrations/* [pylint] ignore = CVS, migrations, eox_tenant_cache_programs.py max-line-length = 120 disable = too-few-public-methods, #R0903, too-many-ancestors, #R0901, enable = useless-suppression [bumpversion:file:eox_nelp/__init__.py] ================================================ FILE: setup.py ================================================ """ Setup file for eox_nelp Django plugin. """ from __future__ import print_function import os import re from setuptools import setup def load_requirements(*requirements_paths): """ Load all requirements from the specified requirements files. Returns a list of requirement strings. """ requirements = set() for path in requirements_paths: requirements.update( line.split('#')[0].strip() for line in open(path).readlines() if is_requirement(line) ) return list(requirements) def is_requirement(line): """ Return True if the requirement line is a package requirement; that is, it is not blank, a comment, or editable. """ # Remove whitespace at the start/end of the line line = line.strip() # Skip blank lines, comments, and editable installs return not ( line == '' or line.startswith('-r') or line.startswith('#') or line.startswith('-e') or line.startswith('git+') or line.startswith('-c') ) def get_version(*file_paths): """ Extract the version string from the file at the given relative path fragments. """ filename = os.path.join(os.path.dirname(__file__), *file_paths) version_file = open(filename).read() version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M) if version_match: return version_match.group(1) raise RuntimeError('Unable to find version string.') with open("README.rst", "r") as fh: README = fh.read() VERSION = get_version('eox_nelp', '__init__.py') setup( name='eox-nelp', version=VERSION, author='eduNEXT', author_email='contact@edunext.co', description='Nelp plugin for custom development.', license='AGPL', long_description=README, long_description_content_type='text/x-rst', classifiers=[ 'Development Status :: 5 - Production/Stable', 'Framework :: Django :: 1.11', 'Framework :: Django :: 2.2', 'Intended Audience :: Developers', 'License :: OSI Approved :: GNU Affero General Public License v3', 'Operating System :: OS Independent', 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.8', ], packages=[ 'eox_nelp', ], include_package_data=True, install_requires=load_requirements('requirements/base.in'), extras_require={ "eox-audit": load_requirements('requirements/eox-audit-model.in'), }, zip_safe=False, entry_points={ "lms.djangoapp": [ 'eox_nelp = eox_nelp.apps:EoxNelpConfig', ], "cms.djangoapp": [ 'eox_nelp = eox_nelp.apps:EoxNelpCMSConfig', ], }, ) ================================================ FILE: tox.ini ================================================ [tox] envlist = py{310,311,312}-django{40} [testenv] envdir= # Use the same environment for all commands running under a specific python version py310: {toxworkdir}/py310 py311: {toxworkdir}/py311 py312: {toxworkdir}/py312 deps = django40: -r requirements/django.txt -r requirements/test.txt commands = {posargs} ================================================ FILE: webpack.common.config.js ================================================ const path = require('path'); const Dotenv = require('dotenv-webpack'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const { createConfig } = require('@openedx/frontend-build'); const { CleanWebpackPlugin } = require('clean-webpack-plugin'); const configuration = []; // Set entries. const entries = { tenant_stats: { js: './eox_nelp/stats/frontend/src/components/TenantStats/index', template: 'eox_nelp/stats/templates/tenant_stats.html', }, feedback_carousel: { js: './eox_nelp/course_experience/frontend/src/components/FeedbackCarousel/index', template: 'eox_nelp/course_experience/frontend/templates/feedback_courses.html', }, user_profile: { js: './eox_nelp/user_profile/frontend/src/components/UserProfileForm/index', template: 'eox_nelp/user_profile/templates/user_profile_form.html', }, } Object.entries(entries).forEach((entry) => { const [key, value] = entry; const config = createConfig('webpack-prod'); // Override entries. config.entry = { [key]: value['js'], } // Override output configuration in order to get a unique folder per entry. config.output = { path: path.resolve(`./eox_nelp/static/${key}`), filename: 'js/[name].js', chunkFilename: 'js/[name].js', } // This change is to avoid the default chunks behavior, since this implementation will require a unique file per view. config.optimization = { minimize: true, } // Override frontend-platform default plugins const existingPluginsCopy = config.plugins.slice(); existingPluginsCopy.splice(2, 1, new CleanWebpackPlugin({ cleanOnceBeforeBuildPatterns: [ '!__init_.py', ] })) existingPluginsCopy.splice(3, 3, new MiniCssExtractPlugin({ // Override MiniCssExtractPlugin in order to change the file name filename: 'css/[name].css', }), new HtmlWebpackPlugin({ inject: true, minify: false, publicPath: `/static/${key}/`, template: path.resolve(process.cwd(), value['template']), }), new Dotenv({ // Override the Dotenv plugin in order to use env.frontend instead of .env path: path.resolve(process.cwd(), '.env.frontend'), systemvars: true, }), ) config.plugins = [... existingPluginsCopy] configuration.push(config); }) module.exports = configuration; ================================================ FILE: webpack.dev.config.js ================================================ const { merge } = require('webpack-merge'); const commonConfig = require('./webpack.common.config'); const configuration = []; commonConfig.forEach((entry) => { configuration.push( merge(entry, { mode: 'development', devtool: 'eval-source-map', }) ) }) module.exports = configuration ================================================ FILE: webpack.prod.config.js ================================================ const { merge } = require('webpack-merge'); const commonConfig = require('./webpack.common.config'); const configuration = []; commonConfig.forEach((entry) => { configuration.push( merge(entry, { mode: 'production', devtool: false, ignoreWarnings: [/Failed to parse source map/] }) ) }) module.exports = configuration ================================================ FILE: .babelrc ================================================ { "presets": ["@babel/preset-env", "@babel/preset-react"], "plugins": ["@babel/plugin-proposal-class-properties"] } ================================================ FILE: .env.frontend ================================================ BASE_URL='http://lms.mango.edunext.link:8000' ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload' LMS_BASE_URL=http://lms.mango.edunext.link:8000 BASE_URL='http://localhost:2000' CONTACT_URL='http://lms.mango.edunext.link:8000/contact' CREDENTIALS_BASE_URL='http://localhost:18150' CREDIT_HELP_LINK_URL='https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_credit_courses.html#keep-track-of-credit-requirements' CSRF_TOKEN_API_PATH='/csrf/api/v1/token' DISCOVERY_API_BASE_URL='http://localhost:18381' DISCUSSIONS_MFE_BASE_URL='http://localhost:2002' ECOMMERCE_BASE_URL='http://localhost:18130' ENABLE_JUMPNAV='true' ENABLE_NOTICES='' ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734' EXAMS_BASE_URL='http://localhost:18740' FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico IGNORED_ERROR_REGEX='' LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference' LOGIN_URL='http://lms.mango.edunext.link:8000/login' LOGOUT_URL='http://lms.mango.edunext.link:8000/logout' LOGO_URL='http://lms.mango.edunext.link:8000/theming/asset/images/logo.png' LOGO_TRADEMARK_URL='http://lms.mango.edunext.link:8000/theming/asset/images/logo.png' LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg LEGACY_THEME_NAME='' MARKETING_SITE_BASE_URL='http://lms.mango.edunext.link:8000' REFRESH_ACCESS_TOKEN_ENDPOINT='http://lms.mango.edunext.link:8000/login_refresh' SEARCH_CATALOG_URL='http://lms.mango.edunext.link:8000/courses' SEGMENT_KEY='' SITE_NAME='edX' SOCIAL_UTM_MILESTONE_CAMPAIGN='edxmilestone' STUDIO_BASE_URL='http://localhost:18010' SUPPORT_URL='https://support.edx.org' SUPPORT_URL_CALCULATOR_MATH='https://support.edx.org/hc/en-us/articles/360000038428-Entering-math-expressions-in-assignments-or-the-calculator' SUPPORT_URL_ID_VERIFICATION='https://support.edx.org/hc/en-us/articles/206503858-How-do-I-verify-my-identity' SUPPORT_URL_VERIFIED_CERTIFICATE='https://support.edx.org/hc/en-us/articles/206502008-What-is-a-verified-certificate' TERMS_OF_SERVICE_URL='https://www.edx.org/edx-terms-service' TWITTER_HASHTAG='myedxjourney' TWITTER_URL='https://twitter.com/edXOnline' USER_INFO_COOKIE_NAME='edx-user-info' SESSION_COOKIE_DOMAIN='mango.edunext.link:8000' MFE_CONFIG_API_URL=/eox-nelp/api/mfe_config/v1/ COURSE_EXPERIENCE_API_URL=/eox-nelp/api/experience/v1 ================================================ FILE: docs/decisions/0001-course-experience.rst ================================================ Courses units in LMS -------------- Status ====== Accepted Context ======= NELP business case requires multiples features that are not part of open-edx platform core, and it's necessary a new implementation that allows users to report and share their opinions about the course content and experience during the course. Decisions ========= 1. Create a course experience module. 2. Create ``LikeDislikeCourse`` model with the following fields. * author => Foreign key to user model, the user who set their opinion. * status => Boolean field with choices: 1=Liked, 0=disliked and None=not-set. * course_id => Foreign key to course overview model, course identifier. 3. Create ``LikeDislikeUnit`` model with the following fields. * author => Foreign key to user model, the user who set their opinion. * status => Boolean field with choices: 1=Liked, 0=disliked and None=not-set. * item_id => UsageKeyField, this is course unit identifier. * course_id => Foreign key to course overview model, course identifier. 4. Create ``ReportCourse`` model with the following fields. * author => Foreign key to user model, the user who set their opinion. * reason => String field with choices: - Sexual content - Graphic violence. - Hateful or abusive content. - Copycat or impersonation. - Other objection. * course_id => Foreign key to course overview model, course identifier. 5. Create ``ReportUnit`` model with the following fields. * author => Foreign key to user model, the user who set their opinion. * reason => String field with choices: - Sexual content - Graphic violence. - Hateful or abusive content. - Copycat or impersonation. - Other objection. * item_id => UsageKeyField, this is course unit identifier. * course_id => Foreign key to course overview model, course identifier. 6. Set model constrains, each model should be unique per units and user or course and user. 7. Create API views for each model. API Examples for units ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ GET request /eox-nelp/api/experience/v1/like/units// Response .. code-block:: json { "data": { "type": "like-unit" "attributes": { "usage-id": "block-v1:edX+C102+2022-t3+type@vertical+block@437dedc792a648e0b90911b8349d769f", "status": "disliked" }, "links": { "self": "/eox-nelp/api/experience/v1/like/units/block-v1:edX+C102+2022-t3+type@vertical+block@437dedc792a648e0b90911b8349d769f/" } } } POST request /eox-nelp/api/experience/v1/like/units// .. code-block:: json { "usage-id": "block-v1:edX+C102+2022-t3+type@vertical+block@437dedc792a648e0b90911b8349d769f", "status": "liked" } Response .. code-block:: json { "data": { "type": "like-unit" "attributes": { "usage-id": "block-v1:edX+C102+2022-t3+type@vertical+block@437dedc792a648e0b90911b8349d769f", "status": "liked" }, "links": { "self": "/eox-nelp/api/experience/v1/like/units/block-v1:edX+C102+2022-t3+type@vertical+block@437dedc792a648e0b90911b8349d769f/" } } } GET request /eox-nelp/api/experience/v1/report/units// Response .. code-block:: json { "data": { "type": "report-unit" "attributes": { "usage-id": "block-v1:edX+C102+2022-t3+type@vertical+block@437dedc792a648e0b90911b8349d769f", "reason": "sexual_content" }, "links": { "self": "/eox-nelp/api/experience/v1/report/units/block-v1:edX+C102+2022-t3+type@vertical+block@437dedc792a648e0b90911b8349d769f/" } } } POST request /eox-nelp/api/experience/v1/report/units// .. code-block:: json { "usage-id": "block-v1:edX+C102+2022-t3+type@vertical+block@437dedc792a648e0b90911b8349d769f", "reason": "sexual_content" } Response .. code-block:: json { "data": { "type": "report-unit" "attributes": { "usage-id": "block-v1:edX+C102+2022-t3+type@vertical+block@437dedc792a648e0b90911b8349d769f", "reason": "sexual_content" }, "links": { "self": "/eox-nelp/api/experience/v1/report/units/block-v1:edX+C102+2022-t3+type@vertical+block@437dedc792a648e0b90911b8349d769f/" } } } API Examples for courses ~~~~~~~~~~~~~~~~~~~~~~~~ GET request /eox-nelp/api/experience/v1/like/courses// Response .. code-block:: json { "data": { "type": "rate-course" "attributes": { "course-id": "course-v1:test+CS501+2022_T4", "status": "disliked" }, "links": { "self": "/eox-nelp/api/experience/v1/like/courses/course-v1:test+CS501+2022_T4/" } } } POST request /eox-nelp/api/experience/v1/like/courses// .. code-block:: json { "course-id": "course-v1:test+CS501+2022_T4", "status": "liked" } Response .. code-block:: json { "data": { "type": "rate-course" "attributes": { "course-id": "course-v1:test+CS501+2022_T4", "status": "liked" }, "links": { "self": "/eox-nelp/api/experience/v1/like/courses/course-v1:test+CS501+2022_T4/" } } } GET request /eox-nelp/api/experience/v1/report/courses// Response .. code-block:: json { "data": { "type": "report-course" "attributes": { "course-id": "course-v1:test+CS501+2022_T4", "reason": "sexual_content" }, "links": { "self": "/eox-nelp/api/experience/v1/report/courses/course-v1:test+CS501+2022_T4/" } } } POST request /eox-nelp/api/experience/v1/report/courses// .. code-block:: json { "course-id": "course-v1:test+CS501+2022_T4", "reason": "sexual_content" } Response .. code-block:: json { "data": { "type": "report-course" "attributes": { "course-id": "course-v1:test+CS501+2022_T4", "reason": "sexual_content" }, "links": { "self": "/eox-nelp/api/experience/v1/report/courses/course-v1:test+CS501+2022_T4/" } } } Consequences ============ 1. This won't modify or alter the current platform behavior. 2. This doesn't cover the client experience, this just covers the backend requirements, therefore its frontend implementation must be done later in the right place. ================================================ FILE: eox_nelp/__init__.py ================================================ """ Init module for eox_nelp. """ from __future__ import unicode_literals __version__ = '1.0.0' ================================================ FILE: eox_nelp/apps.py ================================================ """ App configuration for eox_nelp. """ from __future__ import unicode_literals from django.apps import AppConfig from eox_nelp.init_pipeline import run_init_pipeline class EoxNelpConfig(AppConfig): """ Nelp plugin for custom development. configuration. """ name = 'eox_nelp' verbose_name = 'Nelp plugin for custom development.' plugin_app = { 'url_config': { 'lms.djangoapp': { 'namespace': 'eox-nelp', 'regex': r'^eox-nelp/', 'relative_path': 'urls', }, }, 'settings_config': { 'lms.djangoapp': { 'test': {'relative_path': 'settings.test'}, 'common': {'relative_path': 'settings.common'}, 'production': {'relative_path': 'settings.production'}, 'devstack': {'relative_path': 'settings.devstack'}, }, }, 'signals_config': { 'lms.djangoapp': { 'relative_path': 'signals.receivers', 'receivers': [ { 'receiver_func_name': 'block_completion_progress_publisher', 'signal_path': 'django.db.models.signals.post_save', 'dispatch_uid': 'block_completion_publisher_receviver', 'sender_path': 'completion.models.BlockCompletion', }, { 'receiver_func_name': 'emit_initialized_course_event', 'signal_path': 'django.db.models.signals.post_save', 'dispatch_uid': 'emit_initialized_course_event_receviver', 'sender_path': 'completion.models.BlockCompletion', }, { 'receiver_func_name': 'course_grade_changed_progress_publisher', 'signal_path': 'openedx.core.djangoapps.signals.signals.COURSE_GRADE_CHANGED', 'dispatch_uid': 'course_grade_publisher_receiver', }, { 'receiver_func_name': 'certificate_publisher', 'signal_path': 'openedx_events.learning.signals.CERTIFICATE_CREATED', 'dispatch_uid': 'certificate_publisher_receiver', }, { 'receiver_func_name': 'enrollment_publisher', 'signal_path': 'django.db.models.signals.post_save', 'dispatch_uid': 'enrollment_publisher_receiver', 'sender_path': 'common.djangoapps.student.models.CourseEnrollment', }, { 'receiver_func_name': 'create_usersignupsource_by_enrollment', 'signal_path': 'django.db.models.signals.post_save', 'dispatch_uid': 'create_usersignupsource_by_enrollment_receiver', 'sender_path': 'common.djangoapps.student.models.CourseEnrollment', }, { 'receiver_func_name': 'update_payment_notifications', 'signal_path': 'django.db.models.signals.post_save', 'dispatch_uid': 'update_payment_notifications_receiver', 'sender_path': 'common.djangoapps.student.models.CourseEnrollment', }, { 'receiver_func_name': 'include_tracker_context', 'signal_path': 'celery.signals.before_task_publish', }, { 'receiver_func_name': 'update_async_tracker_context', 'signal_path': 'celery.signals.task_prerun', }, { 'receiver_func_name': 'emit_subsection_attempt_event', 'signal_path': 'lms.djangoapps.grades.signals.signals.PROBLEM_WEIGHTED_SCORE_CHANGED', }, { 'receiver_func_name': 'emit_subsection_attempt_event', 'signal_path': 'lms.djangoapps.grades.signals.signals.SUBSECTION_OVERRIDE_CHANGED', }, { 'receiver_func_name': 'mt_course_completion_handler', 'signal_path': 'django.db.models.signals.post_save', 'dispatch_uid': 'mt_course_completion_receviver', 'sender_path': 'completion.models.BlockCompletion', }, { 'receiver_func_name': 'mt_course_passed_handler', 'signal_path': 'openedx.core.djangoapps.signals.signals.COURSE_GRADE_NOW_PASSED', 'dispatch_uid': 'mt_course_passed_receiver', }, { 'receiver_func_name': 'mt_course_failed_handler', 'signal_path': 'openedx.core.djangoapps.signals.signals.COURSE_GRADE_NOW_FAILED', 'dispatch_uid': 'mt_course_failed_receiver', }, { 'receiver_func_name': 'pearson_vue_course_completion_handler', 'signal_path': 'django.db.models.signals.post_save', 'dispatch_uid': 'pearson_vue_course_completion_receiver', 'sender_path': 'completion.models.BlockCompletion', }, { 'receiver_func_name': 'pearson_vue_course_passed_handler', 'signal_path': 'openedx.core.djangoapps.signals.signals.COURSE_GRADE_NOW_PASSED', 'dispatch_uid': 'pearson_vue_course_passed_receiver', }, ], }, }, } def ready(self): """ Method to perform actions after apps registry is ended. """ run_init_pipeline() class EoxNelpCMSConfig(AppConfig): """App configuration""" name = 'eox_nelp' verbose_name = "Nelp Openedx Extensions" plugin_app = { 'url_config': { 'cms.djangoapp': { 'namespace': 'eox-nelp', 'regex': r'^eox-nelp/', 'relative_path': 'cms_urls', }, }, 'settings_config': { 'cms.djangoapp': { 'test': {'relative_path': 'settings.test'}, 'common': {'relative_path': 'settings.common'}, 'production': {'relative_path': 'settings.production'}, 'devstack': {'relative_path': 'settings.devstack'}, }, }, 'signals_config': { 'cms.djangoapp': { 'relative_path': 'signals.receivers', 'receivers': [ { 'receiver_func_name': 'create_course_notifications', 'signal_path': 'xmodule.modulestore.django.COURSE_PUBLISHED', 'dispatch_uid': 'create_course_notifications_receiver', }, { 'receiver_func_name': 'receive_course_created', 'signal_path': 'openedx_events.content_authoring.signals.COURSE_CREATED', 'dispatch_uid': 'course_published_receiver', } ], }, }, } ================================================ FILE: eox_nelp/cms_urls.py ================================================ """eox_nelp CMS URL Configuration The `urlpatterns` list routes URLs to views. For more information please see: https://docs.djangoproject.com/en/3.2/topics/http/urls/ Examples: Function views 1. Add an import: from my_app import views 2. Add a URL to urlpatterns: path(r'^$', views.home, name='home') Class-based views 1. Add an import: from other_app.views import Home 2. Add a URL to urlpatterns: path(r'^$', Home.as_view(), name='home') Including another URLconf 1. Import the include() function: from django.conf.urls import path, include 2. Add a URL to urlpatterns: path(r'^blog/', include('blog.urls')) """ from django.urls import include, path from eox_nelp import views app_name = 'eox_nelp' # pylint: disable=invalid-name urlpatterns = [ path('eox-info/', views.info_view, name='eox-info'), path('api/', include('eox_nelp.cms.api.urls', namespace='cms-api')), path('api/programs/', include('eox_nelp.programs.api.urls', namespace='programs-api')), ] ================================================ FILE: eox_nelp/init_pipeline.py ================================================ """ This file contains all the processes that must run after app registration. Functions: run_init_pipeline: Executes all initialization processes required before the Django application starts. Acts as an entry point to trigger the patching and setup routines defined below. patch_user_gender_choices: Updates the default Open edX gender field options to include only "Male" and "Female" for compatibility with specific business rules. set_mako_templates: Adds plugin template directories to the Mako configuration to ensure that the custom templates are properly discovered at runtime. register_xapi_transformers: Imports and registers all available xAPI event transformers to enable event tracking. update_permissions: Adjusts and extends Open edX permission rules to support additional business roles and use cases (e.g., data researcher, staff, instructor). patch_generate_password: Replaces the default `generate_password` implementation from `edx_django_utils` with a custom NELP version for improved tenant-specific logic. patch_registration_form_factory: Overrides the default `RegistrationFormFactory` used in user authentication with the custom NELP implementation to support extended registration logic. patch_form_fields_getattr: Dynamically patches the `form_fields` module to include a custom `__getattr__` method, enabling runtime generation of field handlers based on configuration. """ import os from django.utils.translation import gettext_noop def run_init_pipeline(): """ Executes multiple processes that must run before starting the django application. """ patch_user_gender_choices() set_mako_templates() register_xapi_transformers() update_permissions() patch_generate_password() patch_registration_form_factory() patch_form_fields_getattr() def patch_user_gender_choices(): """ This overwrites the available gender choices in order to allow the Male and Female options. """ # pylint: disable=import-outside-toplevel # This cannot be at the top of the file since this file is imported the plugin initialization # and therefore the settings has not been set yet from eox_nelp.edxapp_wrapper.student import UserProfile UserProfile.GENDER_CHOICES = ( ('m', gettext_noop('Male')), ('f', gettext_noop('Female')), ) def set_mako_templates(): """This method adds the plugin templates to mako configuration.""" # pylint: disable=import-outside-toplevel # This cannot be at the top of the file since this file is imported on the plugin initialization # and therefore the settings has not been set yet from eox_nelp import static as static_module from eox_nelp.edxapp_wrapper.edxmako import edxmako from eox_nelp.payment_notifications import templates as payment_notifications_templates module_templates_to_include = [ static_module, payment_notifications_templates, ] for module in module_templates_to_include: path_to_templates = os.path.dirname(module.__file__) if path_to_templates not in edxmako.LOOKUP['main'].directories: edxmako.paths.add_lookup('main', path_to_templates) def register_xapi_transformers(): """This method just import the event transformers in order to register all of them.""" # pylint: disable=import-outside-toplevel, unused-import from eox_nelp.processors.xapi import event_transformers as xapi_event_transformers # noqa: F401 def update_permissions(): """This method just change permissions for bussiness cases""" # pylint: disable=import-outside-toplevel from bridgekeeper import perms from bridgekeeper.rules import is_staff from eox_nelp.edxapp_wrapper.courseware import rules from eox_nelp.edxapp_wrapper.instructor import permissions perms.pop(permissions.CAN_RESEARCH, None) perms[permissions.CAN_RESEARCH] = ( is_staff # pylint: disable=unsupported-binary-operation, useless-suppression | rules.HasRolesRule("data_researcher") | rules.HasAccessRule("staff") | rules.HasAccessRule("instructor") ) def patch_generate_password(): """This method patch `generate_password` of edx_django_util package, with custom nelp `generate_password`. """ # pylint: disable=import-outside-toplevel from eox_tenant.tenant_wise import set_as_proxy from eox_nelp.user_authn.utils import generate_password set_as_proxy( modules=[ "lms.djangoapps.support.views.manage_user", "openedx.core.djangoapps.user_api.accounts.utils", "openedx.core.djangoapps.user_authn.views.auto_auth", "openedx.core.djangoapps.user_authn.views.register", ], model="generate_password", proxy=generate_password ) def patch_registration_form_factory(): """This method patches `RegistrationFormFactory` of user_auth.view.registration_form , with custom nelp `NelpRegistrationFormFactory`. """ # pylint: disable=import-outside-toplevel from eox_nelp.edxapp_wrapper.user_authn import views from eox_nelp.user_authn.views.registration_form import NelpRegistrationFormFactory views.registration_form.RegistrationFormFactory = NelpRegistrationFormFactory def patch_form_fields_getattr(): """ Patches the `form_fields` module within the Open edX user authentication app by dynamically injecting a custom `__getattr__` method. This enables on-the-fly resolution of field handlers (e.g., `add__field`) based on runtime configuration, allowing flexible field definition without directly modifying the upstream module. """ # pylint: disable=import-outside-toplevel from eox_nelp.edxapp_wrapper.user_authn import form_fields from eox_nelp.user_authn.api.patches import form_field_getattr_patch setattr(form_fields, "__getattr__", form_field_getattr_patch) ================================================ FILE: eox_nelp/middleware.py ================================================ """Middleware file. Required NELP middlewares that allow to customize edx-platform. """ ================================================ FILE: eox_nelp/urls.py ================================================ """eox_nelp URL Configuration The `urlpatterns` list routes URLs to views. For more information please see: https://docs.djangoproject.com/en/1.11/topics/http/urls/ Examples: Function views 1. Add an import: from my_app import views 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') Class-based views 1. Add an import: from other_app.views import Home 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') Including another URLconf 1. Import the include() function: from django.conf.urls import url, include 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) """ from django.urls import include, path from eox_nelp import views app_name = 'eox_nelp' # pylint: disable=invalid-name urlpatterns = [ path('eox-info/', views.info_view, name='eox-info'), path('courses/', include('eox_nelp.course_api.urls', namespace='nelp-course-api')), path('api/mfe_config/', include('eox_nelp.mfe_config_api.urls', namespace='mfe-config-api')), path('api/experience/', include('eox_nelp.course_experience.api.urls', namespace='course-experience-api')), path( 'frontend/experience/', include('eox_nelp.course_experience.frontend.urls', namespace='course-experience-frontend'), ), path('api/stats/', include('eox_nelp.stats.api.urls', namespace='stats-api')), path('stats/', include('eox_nelp.stats.urls', namespace='stats')), path('payment-notifications/', include('eox_nelp.payment_notifications.urls', namespace='payment-notifications')), path('api/user-profile/', include('eox_nelp.user_profile.api.urls', namespace='user-profile-api')), path('api/one-time-password/', include('eox_nelp.one_time_password.api.urls', namespace='one-time-password-api')), path( 'api/external-certificates/', include('eox_nelp.external_certificates.api.urls', namespace='external-certificates-api'), ), path('api/users/', include('eox_nelp.users.urls', namespace='users')), path('api/programs/', include('eox_nelp.programs.api.urls', namespace='programs-api')), ] ================================================ FILE: eox_nelp/utils.py ================================================ """Utils that can be used for the plugin project""" import re from copy import copy from custom_reg_form.forms import ExtraInfoForm from custom_reg_form.models import ExtraInfo from django.forms.models import model_to_dict from opaque_keys.edx.keys import CourseKey from eox_nelp.edxapp_wrapper.course_overviews import get_course_overviews from eox_nelp.edxapp_wrapper.user_api import errors NATIONAL_ID_REGEX = r"^[1-2]\d{9}$" COURSE_ID_REGEX = r'(course-v1:[^/+]+(/|\+)[^/+]+(/|\+)[^/?]+)' def map_instance_attributes_to_dict(instance, attributes_mapping): """Create a dictionary that represents some fields or attributes of a instance based on a attributes_mapping dictionary. This dict would have key, values which the key represent the attribute to look in the instance and the value the key name in the output dict. Based in the `attributes_mapping` you should use a dict with the following config: { "key_name": "field_name" } This would check in the instace instance.field_name and the value send it to output dict like {"key_name": instance.field_name} Also its is possible to check nested fields if you declarate the field of instance separated by `__` eg: { "key_name": "field_level1__field_level2" } This example would check in the instace like instance.field_level1.field_level2 and in the output dict like {"key_name": instance.field_level1.field_level2} Args: instance (instance class): Model or instance of class to retrieved fields. attributes_mapping (dict): Dictionary map that has the fields to search and the keys name to output, Returns: instance_dict: dict representing the instance """ instance_dict = {} for extra_field, instance_field in attributes_mapping.items(): extra_value = None instance_level = copy(instance) for instance_field in instance_field.split("__"): if hasattr(instance_level, instance_field): instance_level = getattr(instance_level, instance_field) extra_value = instance_level instance_dict[extra_field] = extra_value return instance_dict def check_regex(string, regex): """Checks if the string matches the regex. Args: string: The string to check. regex: The regex to match against. Returns: True if the string matches the regex, False otherwise. """ pattern = re.compile(regex) return pattern.match(string) is not None def is_valid_national_id(national_id, raise_exception=False): """Validate if a national_id has the shape of a national_id Args: national_id: The string of national_id to check. Returns: True if the national_id is ok, False otherwise. Raise: ValueError: This will be raised when the username are excluded dont match national Id regex. """ check_national_id = check_regex(national_id, NATIONAL_ID_REGEX) if raise_exception and not check_national_id: raise ValueError( f"The username or national_id: {national_id} doesnt match national ID regex ({NATIONAL_ID_REGEX})", ) return check_national_id def extract_course_id_from_string(string): """This return a sub-string that matches the course_ir regex Arguments: string: This is a string that could contains a sub-string that matches with the course_id form. Returns: course_id : Returns course id or an empty string. """ matches = re.search(COURSE_ID_REGEX, string) if matches: return matches.group() return "" def get_course_from_id(course_id): """ Get Course object using the `course_id`. Arguments: course_id (str) : ID of the course Returns: Course """ course_key = CourseKey.from_string(course_id) course_overviews = get_course_overviews([course_key]) if course_overviews: return course_overviews[0] raise ValueError(f"Course with id {course_id} does not exist.") def get_item_label(item): """By definition the label of a Problem is the text between double greater and lees than symbols, example, >>label<<, this method extracts and returns that information from the item markdown value. Arguments: item : This is a specification for an element of a course. This case should be a problem. Returns: label : Label data if it's found otherwise empty string. """ if not (hasattr(item, "markdown") and isinstance(item.markdown, str)): return "" regex = re.compile(r'>>\s*(.*?)\s*<<') matches = regex.search(item.markdown) if matches: return matches.group(1) return "" def camel_to_snake(string): """Convert string from camel case to snake case. Args: string: String in camel case format. Returns: String in snake case. """ return re.sub(r'(?A verbose description of the course.

" "start": "2015-07-17T12:00:00Z", "start_display": "July 17, 2015", "start_type": "timestamp", "pacing": "instructor", "course_about_url" "https://domain.course/about", "course_overview": "

...

", "overview_object": { "about_description": [ { "titles": [ "About This Course" ], "paragraphs": [ "Include your long course description here. Description should contain 150-400 words.", } ], "staff": { "titles": [ [ [ "Course Staff" ] ] ], "teachers": [ { "name": [ "Staff Member #1" ], "bio": [ "Biography of instructor/staff member #1" ], "image_url": [ "http://lms.mango.edunext.link:8000/static/images/placeholder-faculty.png" ] } ] }, "prereqs": [ { "titles": [ "Requirements" ], "paragraphs": [ "Add information about the skills and knowledge students need to take this course." ] } ], "faq": [ { "h3_questions": [ "What web browser should I use?" ], "p_answers": [ "The Open edX platform works with Chrome, Edge, Firefox, Internet Explorer, or Safari.", "See our list of supported browsers for the most up-to-date information." ] }, ] } } """ serializer_class = NelpCourseDetailSerializer class NelpCourseListView(CourseListView): """ **Use Cases** Request information on all courses visible to the specified user. **Example Requests** GET /api/courses/v1/courses/ **Response Values** Body comprises a list of objects as returned by `CourseDetailView`. **Parameters** search_term (optional): Search term to filter courses (used by ElasticSearch). username (optional): The username of the specified user whose visible courses we want to see. The username is not required only if the API is requested by an Anonymous user. org (optional): If specified, visible `CourseOverview` objects are filtered such that only those belonging to the organization with the provided org code (e.g., "HarvardX") are returned. Case-insensitive. **Returns** * 200 on success, with a list of course discovery objects as returned by `CourseDetailView`. * 400 if an invalid parameter was sent or the username was not provided for an authenticated request. * 403 if a user who does not have permission to masquerade as another user specifies a username other than their own. * 404 if the specified user does not exist, or the requesting user does not have permission to view their courses. Example response: [ { "blocks_url": "/api/courses/v1/blocks/?course_id=edX%2Fexample%2F2012_Fall", "media": { "course_image": { "uri": "/c4x/edX/example/asset/just_a_test.jpg", "name": "Course Image" } }, "description": "An example course.", "end": "2015-09-19T18:00:00Z", "enrollment_end": "2015-07-15T00:00:00Z", "enrollment_start": "2015-06-15T00:00:00Z", "course_id": "edX/example/2012_Fall", "name": "Example Course", "number": "example", "org": "edX", "start": "2015-07-17T12:00:00Z", "start_display": "July 17, 2015", "start_type": "timestamp", "course_about_url" "https://domain.course/about", "course_overview": "

...

", "overview_object": { "about_description": [ { "titles": [ "About This Course" ], "paragraphs": [ "Include your long course description here. should contain 150-400 words.", } ], "staff": { "titles": [ [ [ "Course Staff" ] ] ], "teachers": [ { "name": [ "Staff Member #1" ], "bio": [ "Biography of instructor/staff member #1" ], "image_url": [ "http://lms.mango.edunext.link:8000/static/images/placeholder-faculty.png" ] } ] }, "prereqs": [ { "titles": [ "Requirements" ], "paragraphs": [ "Add information about the skills and knowledge students need to take this course." ] } ], "faq": [ { "h3_questions": [ "What web browser should I use?" ], "p_answers": [ "The Open edX works best with Chrome, Edge, Firefox, Internet Explorer, or Safari.", "See our list of supported browsers for the most up-to-date information." ] }, ] } } ] """ serializer_class = NelpCourseDetailSerializer ================================================ FILE: eox_nelp/course_api/v1/tests/__init__.py ================================================ ================================================ FILE: eox_nelp/course_api/v1/tests/test_views.py ================================================ """This file contains the test case for nelp courses api view. AS this is an external plugin using the ancestor class of the CourseApiView, the fields of that views and serializers are not tested. This test only test the fields added apart from the other field of the ancestor class. TestCases: - NelpCoursesApiViewsTestCase: test cases for the Nelp views. """ from django.contrib.auth import get_user_model from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient, APITestCase from eox_nelp.edxapp_wrapper.course_experience import course_home_url from eox_nelp.edxapp_wrapper.course_overviews import CourseOverview from eox_nelp.edxapp_wrapper.test_backends.course_api_m_v1 import TEST_RAW_OVERVIEW User = get_user_model() class NelpCoursesApiViewsTestCase(APITestCase): """ Test Nelp Courses API views. """ def setUp(self): """ Set base variables and objects across experience test cases. """ self.client = APIClient() self.user = User.objects.create_user(username="vader", password="vaderpass") self.course1 = CourseOverview.objects.create(id=f"{self.BASE_COURSE_ID}1") self.course2 = CourseOverview.objects.create(id=f"{self.BASE_COURSE_ID}2") self.client.force_authenticate(self.user) RESPONSE_CONTENT_TYPES = ["application/json"] BASE_COURSE_ID = "course-v1:sky+walker+2023-v" reverse_viewname_list = "nelp-course-api:v1:list" reverse_viewname_detail = "nelp-course-api:v1:detail" expected_overview_object = { "about_description": [ { "titles": ["About This Course"], "paragraphs": [ "The long course description should contain 150-400 words.", "This is paragraph 2 of the long course description. Add more paragraphs as needed.", ], } ], "staff": { "titles": [[["Course Staff"]]], "teachers": [ { "name": ["Staff Member #1"], "bio": ["Biography of instructor/staff member #1"], "image_url": ["http://testserver/static/images/placeholder-faculty.png"], }, { "name": ["Staff Member #2"], "bio": ["Biography of instructor/staff member #2"], "image_url": ["http://testserver/static/images/placeholder-faculty.png"], }, ], }, "prereqs": [ { "titles": ["Requirements"], "paragraphs": ["Add information about the skills and knowledge students need to take this course."], } ], "faq": [ { "h3_questions": ["What web browser should I use?"], "p_answers": [ "The Open edX platform works best with current versions of: ", "Chrome, Edge, Firefox, Internet Explorer, or Safari.", "See our \nlist of supported browsers for the most up-to-date information.", ], }, {"h3_questions": ["Question #2"], "p_answers": ["Your answer would be displayed here."]}, ], } def test_get_nelp_courses_list(self): """Test a get for list of nelp courses endpoint. Expected behavior: - Status code 200. - Return expected content types. - Return expected content dict, with a pagination and results list of 2 elements. """ url_endpoint = reverse(self.reverse_viewname_list) expected_value = { "results": [ { "course_about_url": f"http://testserver/courses/{self.BASE_COURSE_ID}1/about", "course_home_url": course_home_url(f"{self.BASE_COURSE_ID}1"), "overview": TEST_RAW_OVERVIEW, "overview_object": self.expected_overview_object, }, { "course_about_url": f"http://testserver/courses/{self.BASE_COURSE_ID}2/about", "course_home_url": course_home_url(f"{self.BASE_COURSE_ID}2"), "overview": TEST_RAW_OVERVIEW, "overview_object": self.expected_overview_object, }, ], "pagination": {"next": None, "previous": None, "count": 2, "num_pages": 1}, } response = self.client.get(url_endpoint) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIn(response.headers["Content-Type"], self.RESPONSE_CONTENT_TYPES) self.assertDictEqual(response.json(), expected_value) def test_get_nelp_course_detail(self): """Test a get for a especific course_id of nelp courses endpoint. Expected behavior: - Status code 200. - Return expected content types. - Return expected content dict of 1 element. """ course_kwarg = {"course_key_string": f"{self.BASE_COURSE_ID}1"} url_endpoint = reverse(self.reverse_viewname_detail, kwargs=course_kwarg) expected_value = { "course_about_url": f"http://testserver/courses/{self.BASE_COURSE_ID}1/about", "course_home_url": course_home_url(f"{self.BASE_COURSE_ID}1"), "overview": TEST_RAW_OVERVIEW, "overview_object": self.expected_overview_object, } response = self.client.get(url_endpoint) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIn(response.headers["Content-Type"], self.RESPONSE_CONTENT_TYPES) self.assertDictEqual(response.json(), expected_value) ================================================ FILE: eox_nelp/course_experience/__init__.py ================================================ ================================================ FILE: eox_nelp/course_experience/admin.py ================================================ """Course experience admin file. Contains the admin models for user course experiences. classes: LikeDislikeUnitAdmin: Admin class for LikeDislikeUnit model. LikeDislikeCourseAdmin: Admin class for LikeDislikeCourse model. ReportCourseAdmin: Admin class for ReportCourse model. ReportUnitAdmin: Admin class for ReportUnit model. """ from typing import Type from django.contrib import admin from django.contrib.auth import get_user_model from eox_nelp.course_experience.models import ( FeedbackCourse, FeedbackUnit, LikeDislikeCourse, LikeDislikeUnit, ReportCourse, ReportUnit, ) User = get_user_model() class BaseAdmin(admin.ModelAdmin): """Base class that allow to extract username from author field. methods: get_author_username: Returns username from User instance. """ raw_id_fields = ["author", "course_id"] @admin.display(ordering="author__username", description="Author") def get_author_username(self, obj: Type[User]) -> str: """Return username from User instance""" return obj.author.username class LikeDislikeUnitAdmin(BaseAdmin): """Admin class for LikeDislikeUnit. attributes: list_display: Fields to be shown in admin interface. """ list_display = ("get_author_username", "status", "item_id", "course_id") class LikeDislikeCourseAdmin(BaseAdmin): """Admin class for LikeDislikeCourse. attributes: list_display: Fields to be shown in admin interface. """ list_display = ("get_author_username", "status", "course_id") class ReportUnitAdmin(BaseAdmin): """Admin class for ReportUnit. attributes: list_display: Fields to be shown in admin interface. """ list_display = ("get_author_username", "reason", "item_id", "course_id") class ReportCourseAdmin(BaseAdmin): """Admin class for ReportCourse. attributes: list_display: Fields to be shown in admin interface. """ list_display = ("get_author_username", "reason", "course_id") class FeedbackUnitAdmin(BaseAdmin): """Admin class for FeedbackUnit. attributes: list_display: Fields to be shown in admin interface. """ list_display = ( "get_author_username", "item_id", "course_id", "rating_content", "feedback", "public", ) class FeedbackCourseAdmin(BaseAdmin): """Admin class for FeedbackCourse. attributes: list_display: Fields to be shown in admin interface. """ list_display = ( "get_author_username", "course_id", "rating_content", "feedback", "public", "rating_instructors", "recommended", ) search_fields = ("course_id__id", "author__username", "feedback") list_filter = ("public", "rating_content", "rating_instructors") admin.site.register(LikeDislikeUnit, LikeDislikeUnitAdmin) admin.site.register(LikeDislikeCourse, LikeDislikeCourseAdmin) admin.site.register(ReportUnit, ReportUnitAdmin) admin.site.register(ReportCourse, ReportCourseAdmin) admin.site.register(FeedbackUnit, FeedbackUnitAdmin) admin.site.register(FeedbackCourse, FeedbackCourseAdmin) ================================================ FILE: eox_nelp/course_experience/models.py ================================================ """ Course experience models. This contains all the model related with the course user experience. Models: LikeDislikeUnit: Store user decision(like or dislike) for specific unit. LikeDislikeCourse: Store user decision(like or dislike) for a course. ReportUnit: Store report reason about a specific unit. ReportCourse: Store report reason for a course. """ from django.contrib.auth import get_user_model from django.db import models from eventtracking import tracker from opaque_keys.edx.django.models import UsageKeyField from eox_nelp.edxapp_wrapper.course_overviews import CourseOverview from eox_nelp.utils import camel_to_snake User = get_user_model() RATING_OPTIONS = [ (0, '0'), (1, '1'), (2, '2'), (3, '3'), (4, '4'), (5, '5') ] class BaseLikeDislike(models.Model): """Base abstract model for like and dislike records. fields: author: Makes reference to the user record associated with the status. status: True = Liked, False =d isliked and None = not-set course_id: Reference to a specific course. """ author = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) status = models.BooleanField(null=True) course_id = models.ForeignKey(CourseOverview, null=True, on_delete=models.SET_NULL) class Meta: """Set model abstract""" abstract = True class BaseReport(models.Model): """Base abstract model for reporting records. fields: author: Makes reference to the user record associated with the reason. reason: Store report reason as a code, e.g IC => Inappropriate content. course_id: Reference to a specific course. """ REPORT_REASONS = [ ("IC", "Inappropriate content"), ("GV", "Graphic violence"), ("HA", "Hateful or abusive content"), ("CI", "Copycat or impersonation"), ("OO", "Other objection"), ] author = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) reason = models.CharField( max_length=2, null=True, blank=True, choices=REPORT_REASONS, default=None ) course_id = models.ForeignKey(CourseOverview, null=True, on_delete=models.SET_NULL) class Meta: """Set model abstract""" abstract = True class BaseFeedback(models.Model): """Base abstract model for rating records. fields: author: Makes reference to the user record associated with the status. rating_content: Base rate From 0 to 5. feedback: Feedbacl related to the object. Max 500 chars. course_id: Reference to a specific course. public: Default True, if true the user accept showing the rating. """ author = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) rating_content = models.IntegerField(blank=True, null=True, choices=RATING_OPTIONS) feedback = models.CharField(max_length=500, blank=True, null=True) public = models.BooleanField(null=True, default=False) course_id = models.ForeignKey(CourseOverview, null=True, on_delete=models.SET_NULL) class Meta: """Set model abstract""" abstract = True def save(self, *args, **kwargs): """Overrides save method in order to add extra functionalities.""" super().save(*args, **kwargs) self.emit_feedback_event() def emit_feedback_event(self): """Emit event base on the instance attributes.""" class_name = camel_to_snake(self.__class__.__name__) event_name = f"nelc.eox_nelp.course_experience.{class_name}" private_fields = {"id"} event_data = { field.name: field.value_to_string(self) for field in self._meta.fields # pylint: disable=no-member if field.name not in private_fields } tracker.emit(event_name, event_data) class LikeDislikeUnit(BaseLikeDislike): """Extends from BaseLikeDislike, this model will store an opinion about a specific unit. fields: item_id: Unit identifier. """ item_id = UsageKeyField(max_length=255) class Meta: """Set constrain for author an item id""" unique_together = [["author", "item_id"]] class LikeDislikeCourse(BaseLikeDislike): """Extends from BaseLikeDislike, this model will store an opinion about a specific course and set constrains. """ class Meta: """Set constrain for author an course id""" unique_together = [["author", "course_id"]] class ReportUnit(BaseReport): """Extends from BaseReport, this model will store a report about a specific unit. fields: item_id: Unit identifier. """ item_id = UsageKeyField(max_length=255) class Meta: """Set constrain for author an item id""" unique_together = [["author", "item_id"]] class ReportCourse(BaseReport): """Extends from BaseReport, this model will store a report about a specific course and set constrains. """ class Meta: """Set constrain for author an course id""" unique_together = [["author", "course_id"]] class FeedbackUnit(BaseFeedback): """Extends from BaseFeedback, this model will store a report about a specific unit. fields: item_id: Unit identifier. """ item_id = UsageKeyField(max_length=255) class Meta: """Set constrain for author an item id""" unique_together = [["author", "item_id"]] class FeedbackCourse(BaseFeedback): """Extends from BaseFeedback, this model will store a report about a specific course and set constrains. fields: rating_instructors:: Rate the staff and instructors related the course. From 0 to 5. recommended: recommeded the course with true. """ rating_instructors = models.IntegerField(blank=True, null=True, choices=RATING_OPTIONS) recommended = models.BooleanField(null=True, default=True) class Meta: """Set constrain for author an course id""" unique_together = [["author", "course_id"]] ================================================ FILE: eox_nelp/course_experience/api/__init__.py ================================================ ================================================ FILE: eox_nelp/course_experience/api/urls.py ================================================ """eox_nelp course_experience api urls """ from django.urls import include, path app_name = "eox_nelp" # pylint: disable=invalid-name urlpatterns = [ path("v1/", include("eox_nelp.course_experience.api.v1.urls", namespace="v1")) ] ================================================ FILE: eox_nelp/course_experience/api/v1/__init__.py ================================================ ================================================ FILE: eox_nelp/course_experience/api/v1/filters.py ================================================ """Filters used for the experience views.""" from django_filters.rest_framework import FilterSet from eox_nelp.course_experience.models import FeedbackCourse class FeedbackCourseFieldsFilter(FilterSet): """Filter class that configure the query params of a Args: FilterSet: Ancestor related filterset from rest framework. """ class Meta: """Meta configuration for field for FeedbackCourse model. """ model = FeedbackCourse fields = [ "rating_content", "rating_instructors", "public", "recommended", "course_id__id", "author__username", "id", ] ================================================ FILE: eox_nelp/course_experience/api/v1/relations.py ================================================ """Relations used for customize experience views. The relations are used to specify how to manage the relations fields in the serializers. https://github.com/encode/django-rest-framework/blob/master/rest_framework/relations.py """ from rest_framework_json_api.relations import ResourceRelatedField class ExperienceResourceRelatedField(ResourceRelatedField): """Class to configure relations for course experience API. Ancestors: relation (ResourceRelatedField): the ResourceRelatedField relation from json api """ def __init__(self, **kwargs): """ Include an additional kwargs parameter to manage the extra model fields to be shown. The value of the kwarg should be a function with kwargs accepting value: (value=instance). get_extra_fields (function) """ self.get_extra_fields = kwargs.pop('get_extra_fields', None) super().__init__(**kwargs) def to_representation(self, value): """Add to the base json api representation extra fields apart from `id` and `type` using a function passed via `self.get_extra_fields` https://github.com/django-json-api/django-rest-framework-json-api/blob/main/rest_framework_json_api/relations.py#L255 The attributes shape is based on https://jsonapi.org/format/#document-resource-objects Args: value (instance model): instance of foreign model extracted from relation Returns: json_api_representation (ordered-dict): json api representation with extra model data in attributes field. """ json_api_representation = super().to_representation(value) if self.get_extra_fields and callable(self.get_extra_fields): json_api_representation.update(self.get_extra_fields(value=value)) return json_api_representation ================================================ FILE: eox_nelp/course_experience/api/v1/routers.py ================================================ """Routes configuration for course experience views.""" from rest_framework import routers from eox_nelp.course_experience.api.v1 import views router = routers.DefaultRouter() router.register("like/units", views.LikeDislikeUnitExperienceView, basename='like-units') router.register("report/units", views.ReportUnitExperienceView, basename='report-units') router.register("like/courses", views.LikeDislikeCourseExperienceView, basename='like-courses') router.register("report/courses", views.ReportCourseExperienceView, basename='report-courses') router.register("feedback/courses", views.FeedbackCourseExperienceView, basename='feedback-courses') # Public-routes router.register( "feedback/public/courses", views.PublicFeedbackCourseExperienceView, basename='feedback-public-courses', ) ================================================ FILE: eox_nelp/course_experience/api/v1/serializers.py ================================================ """Serializers used for the experience views.""" from django.conf import settings from django.contrib.auth import get_user_model from rest_framework_json_api import serializers from eox_nelp.course_experience.api.v1.relations import ExperienceResourceRelatedField from eox_nelp.course_experience.models import ( FeedbackCourse, LikeDislikeCourse, LikeDislikeUnit, ReportCourse, ReportUnit, ) from eox_nelp.edxapp_wrapper.course_overviews import CourseOverview from eox_nelp.utils import map_instance_attributes_to_dict User = get_user_model() COURSE_OVERVIEW_EXTRA_FIELD_MAPPING = {"display_name": "display_name"} USER_EXTRA_FIELD_MAPPING = { "first_name": "first_name", "last_name": "last_name", "profile_name": "profile__name", "username": "username", } def get_course_extra_attributes(value=None): """Function to retrieve CourseOverview extra fields Args: value (CourseOverview instance): CourseOverview that the relation analize. Defaults to None. Returns: dict: dict object too add course extra fields """ course_overview_mapping = getattr( settings, "COURSE_EXPERIENCE_SETTINGS", {}, ).get("COURSE_OVERVIEW_EXTRA_FIELD_MAPPING", COURSE_OVERVIEW_EXTRA_FIELD_MAPPING) return {"attributes": map_instance_attributes_to_dict(value, course_overview_mapping)} def get_user_extra_attributes(value=None): """Function to retrieve User extra fields Args: value (Userinstance): User that the relation analize. Defaults to None. Returns: dict: dict object too add user extra fields """ user_mapping = getattr( settings, "COURSE_EXPERIENCE_SETTINGS", {}, ).get("USER_EXTRA_FIELD_MAPPING", USER_EXTRA_FIELD_MAPPING) return {"attributes": map_instance_attributes_to_dict(value, user_mapping)} class ExperienceSerializer(serializers.ModelSerializer): """Class to configure serializer for Experiences. Ancestors: serializer (serializers.ModelSerializer): the model serializer from json api """ username = serializers.CharField( source="author.username", required=False, allow_blank=True ) course_id = ExperienceResourceRelatedField( queryset=CourseOverview.objects, get_extra_fields=get_course_extra_attributes, ) author = ExperienceResourceRelatedField( queryset=User.objects, get_extra_fields=get_user_extra_attributes, ) class LikeDislikeUnitExperienceSerializer(ExperienceSerializer): """Class to configure serializer for LikeDislikeUnitExperience. Ancestors: UnitExperienceSerializer: the serializer for unit experiences. """ class Meta: """Class to configure serializer with model LikeDislikeUnit""" model = LikeDislikeUnit fields = "__all__" class ReportUnitExperienceSerializer(ExperienceSerializer): """Class to configure serializer for ReportUnitExperience. Ancestors: UnitExperienceSerializer: the serializer for unit experiences. """ class Meta: """Class to configure serializer with model ReportUnit""" model = ReportUnit fields = "__all__" class LikeDislikeCourseExperienceSerializer(ExperienceSerializer): """Class to configure serializer for LikeDislikeCourseExperience. Ancestors: UnitExperienceSerializer: the serializer for course experiences. """ class Meta: """Class to configure serializer with model LikeDislikeCourse""" model = LikeDislikeCourse fields = "__all__" class ReportCourseExperienceSerializer(ExperienceSerializer): """Class to configure serializer for ReportCourseExperience. Ancestors: UnitExperienceSerializer: the serializer for unit experiences. """ class Meta: """Class to configure serializer with model ReportCourse""" model = ReportCourse fields = "__all__" class FeedbackCourseExperienceSerializer(ExperienceSerializer): """Class to configure serializer for FeedbackCourseExperience. Ancestors: UnitExperienceSerializer: the serializer for unit experiences. """ class Meta: """Class to configure serializer with model ReportCourse""" model = FeedbackCourse fields = "__all__" ================================================ FILE: eox_nelp/course_experience/api/v1/urls.py ================================================ """eox_nelp course_experience_api v1 urls """ from eox_nelp.course_experience.api.v1.routers import router app_name = "eox_nelp" # pylint: disable=invalid-name urlpatterns = router.urls ================================================ FILE: eox_nelp/course_experience/api/v1/views.py ================================================ # pylint: disable=too-many-lines """The generic views for course-experience API. Nelp flavour. Classes: - BaseJsonAPIView: General config of rest json api - ExperienceView: Config of experience views - UnitExperienceView: config for unit-exp views - LikeDislikeUnitExperienceView: class-view(`/eox-nelp/api/experience/v1/like/units/`) - ReportUnitExperienceView: class-view(`/eox-nelp/api/experience/v1/report/units/`) - CourseExperienceView: config for course-exp views - LikeDislikeCourseExperienceView: class-view(`/eox-nelp/api/experience/v1/like/courses/`) - ReportCourseExperienceView: class-view(`/eox-nelp/api/experience/v1/report/courses/`) - FeedbackCourseExperienceView: class-view(`/eox-nelp/api/experience/v1/feedback/courses/`) - PublicBaseJsonAPIView: General config of rest json api - PublicFeedbackCourseExperienceView: class-view(`/eox-nelp/api/experience/v1/feedback/public/courses/`) """ from django.conf import settings from django.db.models import Q from django.http import Http404 from django.http.request import QueryDict from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from opaque_keys import InvalidKeyError from rest_framework.exceptions import ValidationError from rest_framework.filters import SearchFilter from rest_framework.parsers import FormParser, MultiPartParser from rest_framework.permissions import IsAuthenticated from rest_framework_json_api.django_filters import DjangoFilterBackend from rest_framework_json_api.filters import OrderingFilter, QueryParameterValidationFilter from rest_framework_json_api.metadata import JSONAPIMetadata from rest_framework_json_api.pagination import JsonApiPageNumberPagination from rest_framework_json_api.parsers import JSONParser from rest_framework_json_api.renderers import BrowsableAPIRenderer, JSONRenderer from rest_framework_json_api.schemas.openapi import AutoSchema from rest_framework_json_api.views import ModelViewSet, ReadOnlyModelViewSet from eox_nelp.course_experience.models import ( FeedbackCourse, LikeDislikeCourse, LikeDislikeUnit, ReportCourse, ReportUnit, ) from eox_nelp.edxapp_wrapper.site_configuration import configuration_helpers from .filters import FeedbackCourseFieldsFilter from .serializers import ( FeedbackCourseExperienceSerializer, LikeDislikeCourseExperienceSerializer, LikeDislikeUnitExperienceSerializer, ReportCourseExperienceSerializer, ReportUnitExperienceSerializer, ) try: from eox_audit_model.decorators import audit_drf_api except ImportError: def audit_drf_api(*args, **kwargs): # pylint: disable=unused-argument """Identity decorator""" return lambda x: x INVALID_KEY_ERROR = { "error": "bad opaque key(item_id or course_id) `InvalidKeyError`" } class BaseJsonAPIView(ModelViewSet): """class to configure base json api parameter Ancestors: ModelViewSet : Django rest json api ModelViewSet """ allowed_methods = ["POST", "GET", "PATCH"] authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser) permission_classes = (IsAuthenticated,) pagination_class = JsonApiPageNumberPagination parses_clasess = [ JSONParser, FormParser, MultiPartParser, ] renderer_classes = [JSONRenderer, BrowsableAPIRenderer] if getattr(settings, 'DEBUG', None) else [JSONRenderer] metadata_class = JSONAPIMetadata schema_class = AutoSchema filter_backends = [ QueryParameterValidationFilter, OrderingFilter, DjangoFilterBackend, SearchFilter, ] search_param = "filter[search]" class ExperienceView(BaseJsonAPIView): """Class to set functionality of an ExperienceView. Ancestors: BaseJsonAPIView: Inherited for the rest json api config. """ def get_queryset(self, *args, **kwargs): """Filter the queryset before being used. Returns: Queryset: queysyset using the super method, but filtered. """ return super().get_queryset(*args, **kwargs).filter(author_id=self.request.user.id).order_by('id') def get_object(self): try: return super().get_object() except InvalidKeyError as exc: raise Http404 from exc @audit_drf_api( action="eox-nelp-course-experience-api-v1-experienceviewset:create", data_filter=["username", "item_id", "course_id"], method_name="eox_nelp_audited_experience_create", save_all_parameters=True, ) def create(self, request, *args, **kwargs): """Perform processing for the request before use the base create method. Args: request: the request that arrives for create options. Returns: The return of ancestor create method with the request after processing. """ request = self.change_author_data_2_request_user(request) try: return super().create(request, *args, **kwargs) except InvalidKeyError as exc: raise ValidationError(INVALID_KEY_ERROR) from exc @audit_drf_api( action="eox-nelp-course-experience-api-v1-experienceviewset:update", data_filter=["username", "item_id", "course_id"], method_name="eox_nelp_audited_experience_update", save_all_parameters=True, ) def update(self, request, *args, **kwargs): """Perform processing for the request before use the base update method. Args: request: the request that arrives for create options. Returns: The return of ancestor update method with the request after processing. """ request = self.change_author_data_2_request_user(request) try: return super().update(request, *args, **kwargs) except InvalidKeyError as exc: raise ValidationError(INVALID_KEY_ERROR) from exc def change_author_data_2_request_user(self, request): """Set the author object based in the request user. Args: request: The request to set the user. Returns: request: request with author updated. """ if isinstance(request.data, QueryDict): request.data._mutable = True # pylint: disable=protected-access request.data["author"] = f'{{"type": "User", "id": "{request.user.id}"}}' request.data._mutable = False # pylint: disable=protected-access else: request.data["author"] = f'{{"type": "User", "id": "{request.user.id}"}}' return request class UnitExperienceView(ExperienceView): """Class with Experience view for units. Ancestors: ExperienceView: Inherited to set experience views config. """ lookup_field = "item_id" lookup_url_kwarg = "item_id" lookup_value_regex = r"block[\w\W]*" class CourseExperienceView(ExperienceView): """Class with Experience view for courses. Args: ExperienceView: Inherited to set experience views config. """ lookup_field = "course_id" lookup_url_kwarg = "course_id" lookup_value_regex = r"course[\w\W]*" class LikeDislikeUnitExperienceView(UnitExperienceView): """Class view for LikeDislike unit experiences. Ancestors: UnitExperienceView: Inherited for units views config. ## Usage ### **GET** /eox-nelp/api/experience/v1/like/units/ **GET Response Values** ``` json { "links": { "first": "https://lms-exmple.com/eox-nelp/api/experience/v1/like/units/?page%5Bnumber%5D=1", "last": "https://lms-exmple.com/eox-nelp/api/experience/v1/like/units/?page%5Bnumber%5D=1", "next": null, "prev": null }, "data": [ { "type": "LikeDislikeUnit", "id": "1", "attributes": { "username": "michael", "status": true, "item_id": "block-v1:edX+test+t1+type@vertical+block@new_item" }, "relationships": { "author": { "data": { "type": "User", "id": "7" } }, "course_id": { "data": { "type": "CourseOverview", "id": "course-v1:edX+213+2121" } } } }, { "type": "LikeDislikeUnit", "id": "2", "attributes": { "username": "michael", "status": true, "item_id": "block-v1:edX+test+t1+type@vertical+block@new_item2" }, "relationships": { "author": { "data": { "type": "User", "id": "7" } }, "course_id": { "data": { "type": "CourseOverview", "id": "course-v1:edX+213+2121" } } } } ], "meta": { "pagination": { "page": 1, "pages": 1, "count": 2 } } } ``` ### **POST** /eox-nelp/api/experience/v1/like/units/ request example data: ``` json { "status": false, "item_id": "block-v1:edX+test+t1+type@vertical+block@new_item2", "course_id": { "type": "CourseOverview", "id": "course-v1:edX+213+2121" } } ``` ### **GET-SPECIFIC** /eox-nelp/api/experience/v1/like/units/block-v1:edX+test+t1+type@vertical+block@new_item34/ ### **PATCH** /eox-nelp/api/experience/v1/like/units/block-v1:edX+test+t1+type@vertical+block@new_item34/ request example data: ``` json { "status": false, } ``` **POST, GET-ESPECIFIC, PATCH Response Values** ``` json { "data": { "type": "LikeDislikeUnit", "id": "4", "attributes": { "username": "michael", "status": true, "item_id": "block-v1:edX+test+t1+type@vertical+block@new_item345" }, "relationships": { "author": { "data": { "type": "User", "id": "7" } }, "course_id": { "data": { "type": "CourseOverview", "id": "course-v1:edX+2323+232" } } } } } ``` """ queryset = LikeDislikeUnit.objects.all() # pylint: disable=no-member serializer_class = LikeDislikeUnitExperienceSerializer resource_name = "LikeDislikeUnit" class ReportUnitExperienceView(UnitExperienceView): """Class view for Report unit experiences. Ancestors: UnitExperienceView: Inherited for units views config. ## Usage ### **GET** /eox-nelp/api/experience/v1/report/units/ **GET Response Values** ``` json { "links": { "first": "https://lms-exmple.com/eox-nelp/api/experience/v1/report/units/?page%5Bnumber%5D=1", "last": "https://lms-exmple.com/eox-nelp/api/experience/v1/report/units/?page%5Bnumber%5D=1", "next": null, "prev": null }, "data": [ { "type": "ReportUnit", "id": "1", "attributes": { "username": "michael", "reason": "OO", "item_id": "block-v1:edX+test+t1+type@vertical+block@new_item" }, "relationships": { "author": { "data": { "type": "User", "id": "7" } }, "course_id": { "data": { "type": "CourseOverview", "id": "course-v1:edX+213+2121" } } } }, { "type": "ReportUnit", "id": "2", "attributes": { "username": "michael", "reason": "OO", "item_id": "block-v1:edX+test+t1+type@vertical+block@new_item2" }, "relationships": { "author": { "data": { "type": "User", "id": "7" } }, "course_id": { "data": { "type": "CourseOverview", "id": "course-v1:edX+213+2121" } } } } ], "meta": { "pagination": { "page": 1, "pages": 1, "count": 2 } } } ``` ### **POST** /eox-nelp/api/experience/v1/report/units/ request example data: ``` json { "reason": "SC", "item_id": "block-v1:edX+test+t1+type@vertical+block@new_item2", "course_id": { "type": "CourseOverview", "id": "course-v1:edX+213+2121" } } ``` ### **GET-SPECIFIC** /eox-nelp/api/experience/v1/report/units/block-v1:edX+test+t1+type@vertical+block@new_item34/ ### **PATCH** /eox-nelp/api/experience/v1/report/units/block-v1:edX+test+t1+type@vertical+block@new_item34/ request example data: ``` json { "reason": "SC", } ``` **POST, GET-ESPECIFIC, PATCH Response Values** ``` json { "data": { "type": "ReportUnit", "id": "4", "attributes": { "username": "michael", "reason": "SC", "item_id": "block-v1:edX+test+t1+type@vertical+block@new_item345" }, "relationships": { "author": { "data": { "type": "User", "id": "7" } }, "course_id": { "data": { "type": "CourseOverview", "id": "course-v1:edX+2323+232" } } } } } ``` """ queryset = ReportUnit.objects.all() # pylint: disable=no-member serializer_class = ReportUnitExperienceSerializer resource_name = "ReportUnit" class LikeDislikeCourseExperienceView(CourseExperienceView): """Class view for LikeDislike course experiences. Ancestors: UnitExperienceView: Inherited for courses views config. ## Usage ### **GET** /eox-nelp/api/experience/v1/like/courses/ **GET Response Values** ``` json { "links": { "first": "https://lms-example.com/eox-nelp/api/experience/v1/like/courses/?page%5Bnumber%5D=1", "last": "https://lms-example.com/eox-nelp/api/experience/v1/like/courses/?page%5Bnumber%5D=1", "next": null, "prev": null }, "data": [ { "type": "LikeDislikeCourse", "id": "1", "attributes": { "username": "michael", "status": false }, "relationships": { "author": { "data": { "type": "User", "id": "7" } }, "course_id": { "data": { "type": "CourseOverview", "id": "course-v1:edX+213+2121" } } } }, { "type": "LikeDislikeCourse", "id": "2", "attributes": { "username": "michael", "status": false }, "relationships": { "author": { "data": { "type": "User", "id": "7" } }, "course_id": { "data": { "type": "CourseOverview", "id": "course-v1:bragi+cd101+2023y1" } } } }, ], "meta": { "pagination": { "page": 1, "pages": 1, "count": 2 } } } ``` ### **POST** /eox-nelp/api/experience/v1/like/courses/ request example data: ``` json { "status": true, "course_id": { "type": "CourseOverview", "id": "course-v1:edX+213+2121" } } ``` ### **GET-SPECIFIC** /eox-nelp/api/experience/v1/like/courses/course-v1:edX+test+2023/ ### **PATCH** /eox-nelp/api/experience/v1/like/courses/course-v1:edX+test+2023/ request example data: ``` json { "status": false, } ``` **POST, GET-ESPECIFIC, PATCH Response Values** ``` json { "data": { "type": "LikeDislikeCourse", "id": "6", "attributes": { "username": "michael", "status": true }, "relationships": { "author": { "data": { "type": "User", "id": "7" } }, "course_id": { "data": { "type": "CourseOverview", "id": "course-v1:edX+test+2023" } } } } } ``` """ queryset = LikeDislikeCourse.objects.all() # pylint: disable=no-member serializer_class = LikeDislikeCourseExperienceSerializer resource_name = "LikeDislikeCourse" class ReportCourseExperienceView(CourseExperienceView): """Class view for Report course experiences. Ancestors: UnitExperienceView: Inherited for courses views config. ## Usage ### **GET** /eox-nelp/api/experience/v1/report/courses/ **GET Response Values** ``` json { "links": { "first": "https://lms-example.com/eox-nelp/api/experience/v1/report/courses/?page%5Bnumber%5D=1", "last": "https://lms-example.com/eox-nelp/api/experience/v1/report/courses/?page%5Bnumber%5D=1", "next": null, "prev": null }, "data": [ { "type": "ReportCourse", "id": "1", "attributes": { "username": "michael", "reason": "OO" }, "relationships": { "author": { "data": { "type": "User", "id": "7" } }, "course_id": { "data": { "type": "CourseOverview", "id": "course-v1:edX+213+2121" } } } }, { "type": "ReportCourse", "id": "2", "attributes": { "username": "michael", "reason": "OO" }, "relationships": { "author": { "data": { "type": "User", "id": "7" } }, "course_id": { "data": { "type": "CourseOverview", "id": "course-v1:bragi+cd101+2023y1" } } } }, ], "meta": { "pagination": { "page": 1, "pages": 1, "count": 2 } } } ``` ### **POST** /eox-nelp/api/experience/v1/report/courses/ request example data: ``` json { "report": "SC", "course_id": { "type": "CourseOverview", "id": "course-v1:edX+213+2121" } } ``` ### **GET-SPECIFIC** /eox-nelp/api/experience/v1/report/courses/course-v1:edX+test+2023/ ### **PATCH** /eox-nelp/api/experience/v1/report/courses/course-v1:edX+test+2023/ request example data: ``` json { "reason": "SC", } ``` **POST, GET-ESPECIFIC, PATCH Response Values** ``` json { "data": { "type": "ReportCourse", "id": "6", "attributes": { "username": "michael", "reason": "SC" }, "relationships": { "author": { "data": { "type": "User", "id": "7" } }, "course_id": { "data": { "type": "CourseOverview", "id": "course-v1:edX+test+2023" } } } } } ``` """ queryset = ReportCourse.objects.all() # pylint: disable=no-member serializer_class = ReportCourseExperienceSerializer resource_name = "ReportCourse" class FeedbackCourseExperienceView(CourseExperienceView): """Class view for Report course experiences. Ancestors: CourseExperienceView: Inherited for courses views config. ## Usage ### **GET** /eox-nelp/api/experience/v1/feedback/courses/ **GET Response Values** ``` json { "links": { "first": "http://lms-example.com/eox-nelp/api/experience/v1/feedback/courses/?page%5Bnumber%5D=1", "last": "http://lms-example.com/eox-nelp/api/experience/v1/feedback/courses/?page%5Bnumber%5D=1", "next": null, "prev": null }, "data": [ { "type": "FeedbackCourse", "id": "1", "attributes": { "username": "michael", "rating_content": 3, "feedback": "some feedback opinion hehe 1", "public": true, "rating_instructors": 1, "recommended": true }, "relationships": { "author": { "data": { "type": "User", "id": "7" } }, "course_id": { "data": { "type": "CourseOverview", "id": "course-v1:edX+test+2023" } } } }, { "type": "FeedbackCourse", "id": "2", "attributes": { "username": "michael", "rating_content": 5, "feedback": "my feedback ma", "public": false, "rating_instructors": 4, "recommended": false }, "relationships": { "author": { "data": { "type": "User", "id": "7" } }, "course_id": { "data": { "type": "CourseOverview", "id": "course-v1:edX+cd101+220-t2" } } } } ], "meta": { "pagination": { "page": 1, "pages": 1, "count": 2 } } } ``` ### **POST** /eox-nelp/api/experience/v1/report/courses/ request example data: ``` json { "rating_content": "2", "rating_instructors": 2, "public": true, "recommended": false "course_id": { "type": "CourseOverview", "id": "course-v1:edX+213+2121" } } ``` ### **GET-SPECIFIC** /eox-nelp/api/experience/v1/report/courses/course-v1:edX+test+2023/ ### **PATCH** /eox-nelp/api/experience/v1/report/courses/course-v1:edX+test+2023/ request example data: ``` json { "rating_content": "0", "rating_instructors": 3, "public": true, "recommended": false } ``` **POST, GET-ESPECIFIC, PATCH Response Values** ``` json { "data": { "type": "ReportCourse", "id": "6", "attributes": { "username": "michael", "feedback": "some feedback opinion hehe 1" "rating_content": 4, "rating_instructors": 3, "public": true, "recommended": false }, "relationships": { "author": { "data": { "type": "User", "id": "7" } }, "course_id": { "data": { "type": "CourseOverview", "id": "course-v1:edX+test+2023" } } } } } ``` """ queryset = FeedbackCourse.objects.all() # pylint: disable=no-member serializer_class = FeedbackCourseExperienceSerializer resource_name = "FeedbackCourse" # -------------------------- ------------------------- PUBLIC VIEWS----------------------------------------------------- class PublicBaseJsonAPIView(ReadOnlyModelViewSet): """class to configure base json api parameter Ancestors: ReadOnlyModelViewSet : Django rest json api ReadOnlyModelViewSet """ allowed_methods = ["GET"] authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser) permission_classes = () http_method_names = ['get'] def get_queryset(self, *args, **kwargs): """This allows configure the queryset with business configuration. Returns: Queryset filtered first by tenant org belowing the course org and then by staff or superuser permission for private records. """ experience_qs = ReadOnlyModelViewSet.get_queryset(self, *args, **kwargs) current_site_orgs = configuration_helpers.get_current_site_orgs() org_filter = Q() # Avoiding the `reduce()` for more readability, so a no-op filter starter is needed. for org in current_site_orgs: org_filter |= Q(course_id__org__iexact=org) experience_qs = experience_qs.filter(org_filter) if self.request.user.is_superuser or self.request.user.is_staff: return experience_qs.order_by('id') return experience_qs.filter(public=True).order_by('id') def get_object(self): """Disallow the specific retrieve due could be more than one record associated.""" raise Http404 def list(self, request, *args, **kwargs): try: return super().list(request, *args, **kwargs) except InvalidKeyError as exc: raise ValidationError(INVALID_KEY_ERROR) from exc class PublicFeedbackCourseExperienceView(PublicBaseJsonAPIView, FeedbackCourseExperienceView): """View to Public the FeedbackCourseExperienceView. Ancestors: PublicBaseJsonAPIView : Base for json api view configuration FeedbackCourseExperienceView: Base for feedback configuration view. ## Usage ### **GET** /eox-nelp/api/experience/v1/feedback/public/courses/ #### Allowed to query param by using for example `filter[rating_content]=3` - course_id.id - author.username - rating_content - rating_instructors - recommended - public(only superusers) Query params are url encoded.eg course_id.id change `+`to `%2b`. **GET Response Values** ``` json { "links": { "first": "http://lms.com/eox-nelp/api/experience/v1/feedback/public/courses/?page%5Bnumber%5D=1", "last": "http://lms.com/eox-nelp/api/experience/v1/feedback/public/courses/?page%5Bnumber%5D=1", "next": null, "prev": null }, "data": [ { "type": "FeedbackCourse", "id": "1", "attributes": { "username": "michael", "rating_content": 3, "feedback": "feedback option1", "public": true, "rating_instructors": 1, "recommended": true }, "relationships": { "author": { "data": { "type": "User", "id": "7" } }, "course_id": { "data": { "type": "CourseOverview", "id": "course-v1:edX+2323+232" } } } }, { "type": "FeedbackCourse", "id": "3", "attributes": { "username": "jordan", "rating_content": 2, "feedback": "werwer", "public": true, "rating_instructors": 1, "recommended": true }, "relationships": { "author": { "data": { "type": "User", "id": "9" } }, "course_id": { "data": { "type": "CourseOverview", "id": "course-v1:edX+cd101+2023-t2" } } } }, { "type": "FeedbackCourse", "id": "5", "attributes": { "username": "otto", "rating_content": 3, "feedback": "discovery feedback", "public": true, "rating_instructors": 1, "recommended": true }, "relationships": { "author": { "data": { "type": "User", "id": "4" } }, "course_id": { "data": { "type": "CourseOverview", "id": "course-v1:edX+2323+232" } } } } ], "meta": { "pagination": { "page": 1, "pages": 1, "count": 3 } } } ``` """ filterset_class = FeedbackCourseFieldsFilter ================================================ FILE: eox_nelp/course_experience/api/v1/tests/__init__.py ================================================ ================================================ FILE: eox_nelp/course_experience/api/v1/tests/mixins_helpers.py ================================================ """This file contains mixins to use for experience views test cases. Mixins: - ExperienceTestMixin. - UnitExperienceTestMixin - CourseExperienceTestMixin """ from urllib.parse import quote from django.conf import settings from django.contrib.auth import get_user_model from django.test import override_settings from django.urls import reverse from mock import patch from rest_framework import status from rest_framework.test import APIClient from eox_nelp.course_experience.api.v1.serializers import get_course_extra_attributes, get_user_extra_attributes from eox_nelp.course_experience.api.v1.views import INVALID_KEY_ERROR from eox_nelp.edxapp_wrapper.course_overviews import CourseOverview User = get_user_model() RESPONSE_CONTENT_TYPES = ["application/vnd.api+json", "application/json"] BASE_ITEM_ID = "block-v1:edX+cd1011+2020t1+type@vertical+block@base_item" BASE_COURSE_ID = "course-v1:edX+cd101+2023-t2" class ExperienceTestMixin: """ A mixin class with base experience setup configuration for testing. """ def setUp(self): # pylint: disable=invalid-name """ Set base variables and objects across experience test cases. """ self.client = APIClient() self.user, _ = User.objects.get_or_create(username="vader") self.my_course, _ = CourseOverview.objects.get_or_create(id=BASE_COURSE_ID) self.client.force_authenticate(self.user) def make_relationships_data(self): """ Make the relationships dict with custom extra attributes based in the attributes of the serializers withe variables COURSE_OVERVIEW_EXTRA_ATTRIBUTES and USER_EXTRA_ATTRIBUTES Returns: dict: relationships dict with the corresponding shape, and key-values. """ return { "author": { "data": { "type": "User", "id": f"{self.user.id}", **get_user_extra_attributes(self.user) } }, "course_id": { "data": { "type": "CourseOverview", "id": f"{self.my_course.id}", **get_course_extra_attributes(self.my_course) } }, } def test_get_object_list_by_user(self): """ Test a get request to the list endpoint for the desired view. Expected behavior: - Return expected content type. - Status code 200. - Return a json list with at least one object. """ url_endpoint = reverse(self.reverse_viewname_list) response = self.client.get(url_endpoint) self.assertIn(response.headers["Content-Type"], RESPONSE_CONTENT_TYPES) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertTrue(response.json()['data']) for element in response.json()['data']: self.assertEqual(element["attributes"]["username"], self.user.username) self.assertEqual(element["relationships"]["author"]["data"]["id"], str(self.user.id)) @override_settings() def test_get_relationships_keys_mapped_for_settings(self): """ Test the course experiences api returns the keys configured via the COURSE_EXPERIENCE_SETTINGS` in settings. Expected behavior: - Return expected content type. - Status code 200. - Check all the elements have the relationship attrs keys presented for course according setting config. - Check all the elements have the relationship attrs keys for user(author) according setting config. """ url_endpoint = reverse(self.reverse_viewname_list) test_course_experience_settings = { "COURSE_OVERVIEW_EXTRA_FIELD_MAPPING": { "ultra_name": "courseultra", "course_key": "instance__field", }, "USER_EXTRA_FIELD_MAPPING": { "mega_name": "userultra", "user_key": "user__field", } } setattr(settings, "COURSE_EXPERIENCE_SETTINGS", test_course_experience_settings) response = self.client.get(url_endpoint) self.assertIn(response.headers["Content-Type"], RESPONSE_CONTENT_TYPES) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertTrue(response.json()['data']) for element in response.json()['data']: self.assertEqual( element["relationships"]["course_id"]["data"]["attributes"].keys(), test_course_experience_settings["COURSE_OVERVIEW_EXTRA_FIELD_MAPPING"].keys(), ) self.assertEqual( element["relationships"]["author"]["data"]["attributes"].keys(), test_course_experience_settings["USER_EXTRA_FIELD_MAPPING"].keys(), ) def test_not_authenticated_user(self): """ Test disallow by credentials the get request to the list endpoint for the desired view. Expected behavior: - Return expected content. - Status code 401. """ self.client.force_authenticate(user=None) url_endpoint = reverse(self.reverse_viewname_list) response = self.client.get(url_endpoint) self.assertContains( response, "Authentication credentials were not provided", status_code=status.HTTP_401_UNAUTHORIZED, ) def test_patch_object_json(self): """ Test a patch request to the detail endpoint for the desired view using form data (type xhr in browser). Expected behavior: - Return expected content type. - Status code 200. - Check the response is equal to the expected patched. """ url_endpoint = reverse(self.reverse_viewname_detail, kwargs=self.object_url_kwarg) expected_data = self.base_data.copy() expected_data["data"]["attributes"].update(self.patch_data) response = self.client.patch(url_endpoint, self.patch_data, format="json") self.assertIn(response.headers["Content-Type"], RESPONSE_CONTENT_TYPES) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.json(), expected_data) def test_patch_object_form_data(self): """ Test a patch request to the detail endpoint for the desired view using form data (type document in browser). Expected behavior: - Return expected content type. - Status code 200. - Check the response is equal to the expected patched. """ url_endpoint = reverse(self.reverse_viewname_detail, kwargs=self.object_url_kwarg) expected_data = self.base_data.copy() expected_data["data"]["attributes"].update(self.patch_data) response = self.client.patch(url_endpoint, self.patch_data, format="multipart") self.assertIn(response.headers["Content-Type"], RESPONSE_CONTENT_TYPES) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.json(), expected_data) def test_block_url_object_regex(self): """ Test block a request to the detail endpoint for the desired view if the object_id passed Using the url doesnt match url regex. Expected behavior: - Return expected content type. - Status code 404. """ raw_url_endpoint = f"{reverse(self.reverse_viewname_list)}wrong-regex" response = self.client.get(raw_url_endpoint) self.assertIn(response.headers["Content-Type"], ["text/html; charset=utf-8"]) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) class UnitExperienceTestMixin(ExperienceTestMixin): """ A mixin class with test to use for testing UnitExperience Views. """ def test_get_specific_item_id(self): """ Test a get request to the detail endpoint for the desired view. Expected behavior: - Return expected content type. - Status code 200. - Match the response of item_id is eqal to the expected. """ url_endpoint = reverse(self.reverse_viewname_detail, kwargs=self.object_url_kwarg) response = self.client.get(url_endpoint) self.assertIn(response.headers["Content-Type"], RESPONSE_CONTENT_TYPES) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.json(), self.base_data) def test_get_wrong_item_id(self): """ Test a get request to the detail endpoint for units views. Expected behavior: - Return expected content type. - Status code 404. """ url_endpoint = reverse(self.reverse_viewname_detail, kwargs={self.object_key: "block-v1:wrong_shape"}) response = self.client.get(url_endpoint) self.assertIn(response.headers["Content-Type"], RESPONSE_CONTENT_TYPES) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) def test_get_not_found_item_id(self): """ Test a get request with good shape but the item doesnt exist. Using the detail endpoint for units views. Expected behavior: - Return expected content type. - Status code 404. - Return expected msg of Not found in content. """ url_endpoint = reverse( self.reverse_viewname_detail, kwargs={ self.object_key: "block-v1:edX+cd1011+2020t1+type@vertical+block@not_exist" }, ) response = self.client.get(url_endpoint) self.assertIn(response.headers["Content-Type"], RESPONSE_CONTENT_TYPES) self.assertContains( response, f"No {self.base_data.get('data').get('type')} matches the given query.", status_code=status.HTTP_404_NOT_FOUND, ) def test_post_item_id(self): """ Test a post request to the list endpoint for the desired view. Expected behavior: - Return expected content type. - Status code 201. - Check the response object item_id has the expected attributes field. - Check the response object item_id has the expected relationships field. """ url_endpoint = reverse(self.reverse_viewname_list) expected_data = self.base_data.copy() expected_data["data"]["attributes"].update( {key: value for key, value in self.post_data.items() if key != "course_id"} ) response = self.client.post(url_endpoint, self.post_data, format="json", contentType="application/json") self.assertIn(response.headers["Content-Type"], RESPONSE_CONTENT_TYPES) self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.json()["data"]["attributes"], expected_data["data"]["attributes"]) self.assertEqual(response.json()["data"]["relationships"], expected_data["data"]["relationships"]) def test_post_wrong_course_id_object(self): """ Test a post request with wrong course_id object. Using the detail endpoint for units views. Expected behavior: - Return expected content type. - Return expected msg of Incorrect type for course_id object in content. - Status code 400. """ url_endpoint = reverse(self.reverse_viewname_list) wrong_data = self.post_data.copy() wrong_data["course_id"] = "wrong-course" response = self.client.post(url_endpoint, wrong_data, format="json", contentType="application/json") self.assertIn(response.headers["Content-Type"], RESPONSE_CONTENT_TYPES) self.assertContains( response, "Incorrect type. Expected resource identifier object, received str.", status_code=status.HTTP_400_BAD_REQUEST, ) def test_post_wrong_course_id(self): """ Test a post request with wrong course_id string but good shape object, Using the detail endpoint for units views. Expected behavior: - Return expected content type. - Return expected msg of wrong shape of `INVALID_KEY_ERROR`. - Status code 400. """ url_endpoint = reverse(self.reverse_viewname_list) wrong_data = self.post_data.copy() wrong_data["course_id"]["id"] = "wrong-course-id" response = self.client.post(url_endpoint, wrong_data, format="json", contentType="application/json") self.assertIn(response.headers["Content-Type"], RESPONSE_CONTENT_TYPES) self.assertEqual(response.data, INVALID_KEY_ERROR) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) def test_post_not_found_course_id(self): """ Test a post request with good course_id string, good shape object, but courseoverview not exist. Using the detail endpoint for units views. Expected behavior: - Return expected content type. - Return expected msg of oject does not exist in content. - Status code 400. """ url_endpoint = reverse(self.reverse_viewname_list) url_endpoint = reverse(self.reverse_viewname_list) wrong_data = self.post_data.copy() wrong_data["course_id"]["id"] = "course-v1:edX+cd101+2023-noexist" response = self.client.post(url_endpoint, wrong_data, format="json", contentType="application/json") self.assertIn(response.headers["Content-Type"], RESPONSE_CONTENT_TYPES) self.assertContains(response, "object does not exist.", status_code=status.HTTP_400_BAD_REQUEST) def test_patch_wrong_course_id(self): """ Test a patch request with wrong course_id string, good shape object. Using the detail endpoint for units views. Expected behavior: - Return expected content type. - Return expected INVALID_KEY_ERROR. - Status code 400. """ url_endpoint = reverse(self.reverse_viewname_detail, kwargs=self.object_url_kwarg) wrong_patch_data = self.patch_data.copy() wrong_patch_data["course_id"] = { "type": "CourseOverview", "id": "wrong-course-id", } response = self.client.patch(url_endpoint, wrong_patch_data, format="json", contentType="application/json") self.assertIn(response.headers["Content-Type"], RESPONSE_CONTENT_TYPES) self.assertEqual(response.data, INVALID_KEY_ERROR) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) class CourseExperienceTestMixin(ExperienceTestMixin): """ A mixin class with test to use for testing CourseExperience Views. """ def test_get_specific_course_id(self): """ Test a get request to the detail endpoint for the desired view. Expected behavior: - Return expected content type. - Status code 200. - Match the response object_key with the object_id passed Using the url. """ url_endpoint = reverse(self.reverse_viewname_detail, kwargs=self.object_url_kwarg) response = self.client.get(url_endpoint) self.assertIn(response.headers["Content-Type"], RESPONSE_CONTENT_TYPES) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.json(), self.base_data) def test_get_wrong_course_id(self): """ Test a get request to the detail endpoint for courses views. Expected behavior: - Return expected content type. - Status code 400. - Return expected msg of wrong course_key in content. """ url_endpoint = reverse(self.reverse_viewname_detail, kwargs={self.object_key: "course-v1:wrong_shape"}) response = self.client.get(url_endpoint) self.assertIn(response.headers["Content-Type"], RESPONSE_CONTENT_TYPES) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) def test_get_not_found_course_id(self): """ Test a get request to the detail endpoint for courses views. Expected behavior: - Return expected content type. - Status code 404. - Return expected msg of not found in content. """ url_endpoint = reverse( self.reverse_viewname_detail, kwargs={self.object_key: "course-v1:edX+cd101+2023-not_exist"}, ) response = self.client.get(url_endpoint) self.assertIn(response.headers["Content-Type"], RESPONSE_CONTENT_TYPES) self.assertContains( response, f"No {self.base_data.get('data').get('type')} matches the given query.", status_code=status.HTTP_404_NOT_FOUND, ) def test_post_course_id(self): """ Test a post request to the list endpoint for the desired view. Expected behavior: - Return expected content type. - Status code 201. - Check the response object of course_id has the expected attributes field. - Check the response object of course_id has the expected relationships field. """ url_endpoint = reverse(self.reverse_viewname_list) expected_data = self.base_data.copy() expected_data["data"]["attributes"].update( {key: value for key, value in self.post_data.items() if key != "course_id"} ) expected_data["data"]["relationships"]["course_id"]["data"]["id"] = self.post_data["course_id"]["id"] response = self.client.post(url_endpoint, self.post_data, format="json", contentType="application/json") self.assertIn(response.headers["Content-Type"], RESPONSE_CONTENT_TYPES) self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.json()["data"]["attributes"], expected_data["data"]["attributes"]) self.assertEqual(response.json()["data"]["relationships"], expected_data["data"]["relationships"]) def test_post_not_found_course_id(self): """ Test a post request to the detail endpoint for courses views. Expected behavior: - Return expected content type. - Status code 400. - Return expected msg of object associated doesnt exist in content. """ url_endpoint = reverse(self.reverse_viewname_list) data = self.post_data data["course_id"] = { "type": "CourseOverview", "id": "course-v1:edX+cd101+2023-not_exist" } response = self.client.post(url_endpoint, data, format="json", contentType="application/json") self.assertIn(response.headers["Content-Type"], RESPONSE_CONTENT_TYPES) self.assertContains(response, "object does not exist.", status_code=status.HTTP_400_BAD_REQUEST) class PublicExperienceTestMixin: """ A mixin class with base experience setup configuration for testing. """ def setUp(self): # pylint: disable=invalid-name """ Set base variables and objects across experience test cases. """ self.patchers = [ patch("eox_nelp.course_experience.api.v1.views.configuration_helpers"), ] self.configuration_helpers_mock = self.patchers[0].start() self.configuration_helpers_mock.get_current_site_orgs.return_value = ["org1"] self.client = APIClient() self.users = User.objects.bulk_create( [ User(username="luke", is_superuser=True, id=1000), User(username="han", id=1001), User(username="chewi", id=1002), User(username="citripio", id=1003), User(username="R2D2", id=1004), ] ) self.course_overviews = CourseOverview.objects.bulk_create( [ CourseOverview(id="course-v1:org1+multiple+2023-t1", org="org1"), CourseOverview(id="course-v1:org1+multiple+2023-t2", org="org1"), CourseOverview(id="course-v1:org2+multiple2+2023-t2", org="org2"), ] ) self.client.force_authenticate(self.users[0]) # superuser default def tearDown(self): # pylint: disable=invalid-name """Stop patching.""" for patcher in self.patchers: patcher.stop() def test_post_not_allowed(self): """ Test disallow by credentials the get request to the list endpoint for the desired view. Expected behavior: - Status code 405. """ url_endpoint = reverse(self.reverse_viewname_list) response = self.client.post(url_endpoint) self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) def test_get_specific_not_allowed(self): """ Test disallow by credentials the get request to the list endpoint for the desired view. Expected behavior: - Status code 404. """ url_endpoint = reverse(self.reverse_viewname_detail, kwargs=self.object_url_kwarg) response = self.client.get(url_endpoint) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) def test_patch_object_not_allowed(self): """ Test disallow patch. Expected behavior: - Status code 405. """ url_endpoint = reverse(self.reverse_viewname_detail, kwargs=self.object_url_kwarg) response = self.client.patch(url_endpoint, format="json") self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) def test_get_list_normal_user(self): """Test a get request to the list endpoint for the desired view. Expected behavior: - Return expected content type. - Status code 200. - Return a json list with at least one object. - Returned objects in data list are all public. """ self.client.force_authenticate(user=self.users[1]) url_endpoint = reverse(self.reverse_viewname_list) response = self.client.get(url_endpoint) self.assertIn(response.headers["Content-Type"], RESPONSE_CONTENT_TYPES) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertTrue(response.json()["data"]) for element in response.json()["data"]: self.assertEqual(element["attributes"]["public"], True) def test_get_list_admin_user(self): """Test a get request to the list endpoint for the desired view. Expected behavior: - Return expected content type. - Status code 200. - Return a json list with at least one object. - Return at least one object in data list with public=False due admin could look it. """ url_endpoint = reverse(self.reverse_viewname_list) response = self.client.get(url_endpoint) self.assertIn(response.headers["Content-Type"], RESPONSE_CONTENT_TYPES) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertTrue(response.json()["data"]) self.assertIn(False, [element["attributes"]["public"] for element in response.json()["data"]]) def test_not_authenticated_user(self): """ Test allow without user auth the get request to the list endpoint for the desired view. Expected behavior: - Return expected content type. - Status code 200. - Return a json list with at least one object. - Returned objects are all public. """ self.client.force_authenticate(user=None) url_endpoint = reverse(self.reverse_viewname_list) response = self.client.get(url_endpoint) self.assertIn(response.headers["Content-Type"], RESPONSE_CONTENT_TYPES) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertTrue(response.json()["data"]) for element in response.json()["data"]: self.assertEqual(element["attributes"]["public"], True) def test_tenant_org_aware(self): """ Test the objects returned are all belonging to course overview with org=org1 Expected behavior: - Status code 200. - Return a json list with at least one object. - Check returned course_id, are related to a model with only relation of org=org1. """ url_endpoint = reverse(self.reverse_viewname_list) response = self.client.get(url_endpoint) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertTrue(response.json()["data"]) course_overviews_related = [ CourseOverview.objects.get(id=element["relationships"]["course_id"]["data"]["id"]) for element in response.json()["data"] ] for course_overview in course_overviews_related: self.assertEqual(course_overview.org, "org1") class FeedbackPublicExperienceTestMixin(PublicExperienceTestMixin): """ A mixin class with test to use for Public Feedback CourseExperience Views. """ def test_filter_by_author(self): """ Test the objects returned are filtered by author relationship using username field. Expected behavior: - Status code 200. - Return a json list with at least one object. - Returned objects are related only to the relation username with test_author. """ test_author = "chewi" url_endpoint = reverse(self.reverse_viewname_list) + f"?filter[author.username]={test_author}" response = self.client.get(url_endpoint) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertTrue(response.json()["data"]) for element in response.json()["data"]: self.assertEqual(element["attributes"]["username"], test_author) def test_filter_by_course_id(self): """ Test the objects returned are filtered by course_id relationship using id field. Expected behavior: - Status code 200. - Return a json list with at least one object. - Returned objects are related only to the relation course_id with test_course_id. """ test_course_id = str(self.course_overviews[0].id) percent_encode_course_id = quote(test_course_id, safe="") # change `+` to `%2b` url_endpoint = reverse(self.reverse_viewname_list) + f"?filter[course_id.id]={percent_encode_course_id}" response = self.client.get(url_endpoint) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertTrue(response.json()["data"]) for element in response.json()["data"]: self.assertEqual(element["relationships"]["course_id"]["data"]["id"], test_course_id) def test_filter_by_rating_content(self): """ Test the objects returned are filtered by rating_content attribute. Expected behavior: - Status code 200. - Return a json list with at least one object. - Returned objects have attribute rating_content matched with test_rating_content. """ test_rating_content = 3 url_endpoint = reverse(self.reverse_viewname_list) + f"?filter[rating_content]={test_rating_content}" response = self.client.get(url_endpoint) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertTrue(response.json()["data"]) for element in response.json()["data"]: self.assertEqual(element["attributes"]["rating_content"], test_rating_content) def test_filter_by_rating_instructors(self): """ Test the objects returned are filtered by rating_instructors attribute. Expected behavior: - Status code 200. - Return a json list with at least one object. - Returned objects have attribute rating_instructors matched with test_rating_intructors. """ test_rating_instructors = 2 url_endpoint = reverse(self.reverse_viewname_list) + f"?filter[rating_instructors]={test_rating_instructors}" response = self.client.get(url_endpoint) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertTrue(response.json()["data"]) for element in response.json()["data"]: self.assertEqual(element["attributes"]["rating_instructors"], test_rating_instructors) def test_filter_by_recommended(self): """ Test the objects returned are filtered by recommended attribute. Expected behavior: - Status code 200. - Return a json list with at least one object. - Returned objects have attribute recommended matched with test_recommended. """ test_recommended = False url_endpoint = reverse(self.reverse_viewname_list) + f"?filter[recommended]={test_recommended}" response = self.client.get(url_endpoint) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertTrue(response.json()["data"]) for element in response.json()["data"]: self.assertEqual(element["attributes"]["recommended"], test_recommended) def test_filter_by_public_admin(self): """ Test the objects returned are filtered by public attribute only with superuser default user. Expected behavior: - Status code 200. - Return a json list with at least one object. - Returned objects have attribute public matched with test_public. """ test_public = False url_endpoint = reverse(self.reverse_viewname_list) + f"?filter[public]={test_public}" response = self.client.get(url_endpoint) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertTrue(response.json()["data"]) for element in response.json()["data"]: self.assertEqual(element["attributes"]["public"], test_public) def test_get_list_normal_user_filter_not_public(self): """Test a get request to the list endpoint with public false query param but is not superuser. Expected behavior: - Return expected content type. - Status code 200. - Return a json list with at least one object. - No Returned objects in data list due only admin could see public=false. """ self.client.force_authenticate(user=self.users[1]) test_public = False url_endpoint = reverse(self.reverse_viewname_list) + f"?filter[public]={test_public}" response = self.client.get(url_endpoint) self.assertIn(response.headers["Content-Type"], RESPONSE_CONTENT_TYPES) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertFalse(response.json()["data"]) def test_multiple_filter(self): """ Test the objects returned are filtered by mutiple attrs: rating_content and rating_instructors. Expected behavior: - Status code 200. - Return a json list with at least one object. - Returned objects are filtered by test_rating_content and test_rating_instructors. """ test_rating_content = 2 test_rating_instructors = test_rating_content + 1 url_endpoint = reverse(self.reverse_viewname_list) + ( f"?filter[rating_content]={test_rating_content}&filter[rating_instructors]={test_rating_instructors}" ) response = self.client.get(url_endpoint) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertTrue(response.json()["data"]) for element in response.json()["data"]: self.assertEqual(element["attributes"]["rating_content"], test_rating_content) self.assertEqual(element["attributes"]["rating_instructors"], test_rating_instructors) def test_sort_by_rating_content(self): """ Test the objects returned are sorted by rating_content attribute. Expected behavior: - Status code 200. - Return a json list with at least one object. - Returned objects are ordered in increase way by rating_content. """ url_endpoint = reverse(self.reverse_viewname_list) + "?sort=rating_content" response = self.client.get(url_endpoint) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertTrue(response.json()["data"]) rate = 0 for element in response.json()["data"]: self.assertTrue(element["attributes"]["rating_content"] >= rate) rate = element["attributes"]["rating_content"] ================================================ FILE: eox_nelp/course_experience/api/v1/tests/test_views.py ================================================ """This file contains all the test for the course_experience views.py file. Classes: LikeDislikeUnitExperienceTestCase: Test LikeDislikeUnitExperienceView. """ from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase from eox_nelp.course_experience.models import ( FeedbackCourse, LikeDislikeCourse, LikeDislikeUnit, ReportCourse, ReportUnit, ) from eox_nelp.edxapp_wrapper.course_overviews import CourseOverview from .mixins_helpers import ( BASE_COURSE_ID, BASE_ITEM_ID, CourseExperienceTestMixin, FeedbackPublicExperienceTestMixin, UnitExperienceTestMixin, ) class LikeDislikeUnitExperienceTestCase(UnitExperienceTestMixin, APITestCase): """ Test LikeDislikeUnitExperience view """ reverse_viewname_list = "course-experience-api:v1:like-units-list" reverse_viewname_detail = "course-experience-api:v1:like-units-detail" object_key = "item_id" patch_data = {"status": True} post_data = { "item_id": "block-v1:edX+cd1011+2020t1+type@vertical+block@new_item2", "status": False, "course_id": { "type": "CourseOverview", "id": f"{BASE_COURSE_ID}", } } def setUp(self): """ Set variables and objects that depends in other class vars. Using self(LikeDislikeUnitExperienceTestCase). """ super().setUp() self.my_unit_like, _ = LikeDislikeUnit.objects.get_or_create( # pylint: disable=no-member item_id=BASE_ITEM_ID, course_id=self.my_course, author_id=self.user.id, status=False, ) self.base_data = { "data": { "type": "LikeDislikeUnit", "id": f"{self.my_unit_like.id}", "attributes": { "username": f"{self.user.username}", "status": self.my_unit_like.status, "item_id": f"{self.my_unit_like.item_id}", }, "relationships": self.make_relationships_data() } } self.object_url_kwarg = {self.object_key: BASE_ITEM_ID} class ReportUnitExperienceTestCase(UnitExperienceTestMixin, APITestCase): """ Test ReportUnitExperience view """ reverse_viewname_list = "course-experience-api:v1:report-units-list" reverse_viewname_detail = "course-experience-api:v1:report-units-detail" object_key = "item_id" patch_data = {"reason": "HA"} post_data = { "item_id": "block-v1:edX+cd1011+2020t1+type@vertical+block@new_item", "reason": "OO", "course_id": { "type": "CourseOverview", "id": f"{BASE_COURSE_ID}" } } def setUp(self): """ Set variables and objects that depends in other class vars. Using self(ReportUnitExperienceTestCase). """ super().setUp() self.my_unit_report, _ = ReportUnit.objects.get_or_create( # pylint: disable=no-member item_id=BASE_ITEM_ID, course_id=self.my_course, author_id=self.user.id, reason="Sexual content", ) self.base_data = { "data": { "type": "ReportUnit", "id": f"{self.my_unit_report.id}", "attributes": { "username": f"{self.user.username}", "reason": f"{self.my_unit_report.reason}", "item_id": f"{self.my_unit_report.item_id}", }, "relationships": self.make_relationships_data() } } self.object_url_kwarg = {self.object_key: BASE_ITEM_ID} def test_post_wrong_reason(self): """ Test a post request sending a not valid choice reason. Expected behavior: - Return expected content not valid choice. - Status code 404. """ data = self.base_data url_endpoint = reverse(self.reverse_viewname_list) data["item_id"] = "block-v1:edX+cd1011+2020t1+type@vertical+block@new_item_reason" data["reason"] = "not valid choice reason" response = self.client.post(url_endpoint, data, format="json", contentType="application/json") self.assertContains(response, "is not a valid choice.", status_code=status.HTTP_400_BAD_REQUEST) class LikeDislikeCourseExperienceTestCase(CourseExperienceTestMixin, APITestCase): """ Test LikeDislikeCourseExperience view """ reverse_viewname_list = "course-experience-api:v1:like-courses-list" reverse_viewname_detail = "course-experience-api:v1:like-courses-detail" object_key = "course_id" new_object_id = "course-v1:edX+cd101+2023-new_course" patch_data = {"status": True} post_data = { "course_id": { "type": "CourseOverview", "id": f"{new_object_id}" }, "status": True } def setUp(self): """ Set variables and objects that depends in other class vars. Using self(LikeDislikeCourseExperienceTestCase). """ super().setUp() self.my_course_like, _ = LikeDislikeCourse.objects.get_or_create( # pylint: disable=no-member course_id=self.my_course, author_id=self.user.id, status=False, ) self.object_url_kwarg = {self.object_key: BASE_COURSE_ID} # add another course due the post doesnt work without existent courseoverview self.my_new_course, _ = CourseOverview.objects.get_or_create(id=self.new_object_id) self.base_data = { "data": { "type": "LikeDislikeCourse", "id": f"{self.my_course_like.id}", "attributes": { "username": f"{self.user.username}", "status": self.my_course_like.status, }, "relationships": self.make_relationships_data() } } class ReportCourseExperienceTestCase(CourseExperienceTestMixin, APITestCase): """ Test ReportCourseExperience view """ reverse_viewname_list = "course-experience-api:v1:report-courses-list" reverse_viewname_detail = "course-experience-api:v1:report-courses-detail" object_key = "course_id" new_object_id = "course-v1:edX+cd101+2023-new_course" patch_data = {"reason": "HA"} post_data = { "course_id": { "type": "CourseOverview", "id": f"{new_object_id}", }, "reason": "GV", } def setUp(self): """ Set variables and objects that depends in other class vars. Using self(ReportCourseExperienceTestCase). """ super().setUp() self.my_course_report, _ = ReportCourse.objects.get_or_create( # pylint: disable=no-member course_id=self.my_course, author_id=self.user.id, reason="Sexual content", ) self.object_url_kwarg = {self.object_key: BASE_COURSE_ID} self.my_new_course, _ = CourseOverview.objects.get_or_create(id=self.new_object_id) self.base_data = { "data": { "type": "ReportCourse", "id": f"{self.my_course_report.id}", "attributes": { "username": f"{self.user.username}", "reason": f"{self.my_course_report.reason}" }, "relationships": self.make_relationships_data() } } class FeedbackCourseExperienceTestCase(CourseExperienceTestMixin, APITestCase): """ Test FeedbackCourseExperience view """ reverse_viewname_list = "course-experience-api:v1:feedback-courses-list" reverse_viewname_detail = "course-experience-api:v1:feedback-courses-detail" object_key = "course_id" new_object_id = "course-v1:edX+cd101+2023-new_course" patch_data = { "rating_content": 0, "rating_instructors": 3, "public": True, "recommended": False } post_data = { "course_id": { "type": "CourseOverview", "id": f"{new_object_id}", }, "feedback": "this is new feedback", "rating_content": 4, "rating_instructors": 3, "public": True, "recommended": False, } def setUp(self): """ Set variables and objects that depends in other class vars. Using self(FeedbackCourseExperienceTestCase). """ super().setUp() self.my_course_feedback, _ = FeedbackCourse.objects.get_or_create( # pylint: disable=no-member course_id=self.my_course, author_id=self.user.id, feedback="legacy created feedback", rating_content=4, rating_instructors=3, public=True, recommended=False, ) self.object_url_kwarg = {self.object_key: BASE_COURSE_ID} self.my_new_course, _ = CourseOverview.objects.get_or_create(id=self.new_object_id) self.base_data = { "data": { "type": "FeedbackCourse", "id": f"{self.my_course_feedback.id}", "attributes": { "username": f"{self.user.username}", "feedback": f"{self.my_course_feedback.feedback}", "rating_content": self.my_course_feedback.rating_content, "rating_instructors": self.my_course_feedback.rating_instructors, "public": self.my_course_feedback.public, "recommended": self.my_course_feedback.recommended, }, "relationships": self.make_relationships_data() } } # -------------------------------------------TEST PUBLIC VIEWS---------------------------------------------------------- class FeedbackPublicCourseExperienceTestCase(FeedbackPublicExperienceTestMixin, APITestCase): """Test PublicFeedbackExperience view""" reverse_viewname_list = "course-experience-api:v1:feedback-public-courses-list" reverse_viewname_detail = "course-experience-api:v1:feedback-public-courses-detail" object_key = "course_id" def setUp(self): """ Set variables and objects that depends in other class vars. Using self(FeedbackCourseExperienceTestCase). """ super().setUp() self.my_course_feedbacks = FeedbackCourse.objects.bulk_create( # pylint: disable=no-member [ FeedbackCourse( course_id=course_overview_iter, author=user_iter, feedback=f"feedback {user_index} by user {user_iter.username} in course {course_overview_iter.id}", rating_content=user_index, rating_instructors=user_index + 1, public=bool(user_index > 3), recommended=bool(user_index >= 3), ) for course_overview_iter in self.course_overviews for user_index, user_iter in enumerate(self.users) ], # create 15 feedbackcourse: 3 course overview with 5 users. ) self.object_url_kwarg = {self.object_key: BASE_COURSE_ID} ================================================ FILE: eox_nelp/course_experience/frontend/__init__.py ================================================ ================================================ FILE: eox_nelp/course_experience/frontend/urls.py ================================================ """frontend templates urls for course_experience""" from django.urls import path from eox_nelp.course_experience.frontend import views app_name = "eox_nelp" # pylint: disable=invalid-name urlpatterns = [ path('feedback/courses/', views.FeedbackCoursesTemplate.as_view(), name='feedback-courses') ] ================================================ FILE: eox_nelp/course_experience/frontend/views.py ================================================ """Frontend Views file. Contains all the views for render react views using django templates. These views render frontend react components from npm like frontend essentials. classes: FeedbackCoursesTemplate: View template that render courses carousel. """ from django.views import View from eox_nelp.edxapp_wrapper.edxmako import edxmako class FeedbackCoursesTemplate(View): """Eoxnelp CoursesFeedbackTemplate view class. General feedback courses template. """ def get(self, request): """Render start html""" return edxmako.shortcuts.render_to_response("feedback_carousel/index.html", {}, "main", request) ================================================ FILE: eox_nelp/course_experience/frontend/src/components/FeedbackCarousel/index.jsx ================================================ import React, { useState} from 'react'; import ReactDOM from 'react-dom'; import { APP_INIT_ERROR, APP_READY, subscribe, initialize } from '@edx/frontend-platform'; import { FeedbackCarousel } from '@edunext/frontend-essentials' import { AppProvider } from '@edx/frontend-platform/react'; import messages from '../../../../../i18n'; import './index.scss'; function LaunchFeedbackCarousel() { return ( ); } subscribe(APP_READY, () => { ReactDOM.render(, document.getElementById('feedback-courses-carousel')); }); initialize({ messages }); ================================================ FILE: eox_nelp/course_experience/frontend/src/components/FeedbackCarousel/index.scss ================================================ @import "~@edx/brand/paragon/fonts"; @import "~@edx/brand/paragon/variables"; @import "~@openedx/paragon/styles/scss/core/core"; #feedback-courses-carousel .feedback-container .feedback-carousel-title { margin: 0 0 20px 0; color: var(--pgn-color-primary-base); } body { background-color: unset; } .feedback-card { background: white; hr { border-top: var(--pgn-size-hr-border-width) solid var(--pgn-color-hr-border, #0000001a); border-color: var(--pgn-color-primary-400); } } #feedback-courses-carousel .feedback-container { .carousel-item { margin-left: unset; } } ================================================ FILE: eox_nelp/course_experience/frontend/templates/__init__.py ================================================ ================================================ FILE: eox_nelp/course_experience/frontend/templates/feedback_courses.html ================================================ Feedback courses general ================================================ FILE: eox_nelp/course_experience/frontend/tests/__init__.py ================================================ ================================================ FILE: eox_nelp/course_experience/frontend/tests/test_views.py ================================================ """This file contains all the test for the course_experience views.py file. Classes: CourseExperienceFrontendTestCase: Test CourseExperienceFrontendView template. """ from django.test import TestCase from django.urls import reverse from rest_framework.test import APIClient class FrontendFeedbackCourseTestCase(TestCase): """ Test FeedbackCoursesTemplate view """ def setUp(self): """ Set base variables and objects across experience test cases. """ self.client = APIClient() self.url_endpoint = reverse("course-experience-frontend:feedback-courses") def test_feedback_course_template_behaviour(self): """ The correct rendering of the feedback courses template using the url_endpoint for frontend feedback courses. Expected behavior: - Status code 200. - Response has the correct title page. - Response has the main div for feedback courses carousel. - Response has the correct path to load styles with feedback carrousel css. - Response has the correct path to load script with feedback carrousel js. """ response = self.client.get(self.url_endpoint) self.assertContains(response, 'Feedback courses general', status_code=200) self.assertContains(response, '