├── .coveragerc ├── .env ├── .github ├── CODEOWNERS ├── issue_template.md ├── pull_request_template.md └── workflows │ ├── codeql-analysis.yml │ ├── python-publish.yml │ └── tests.yml ├── .gitignore ├── CHANGELOG.md ├── DEVELOPER-QUICKSTART.md ├── FAQ.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── bandit.yaml ├── devsite ├── .env.example ├── README.md ├── devsite │ ├── __init__.py │ ├── cans │ │ ├── __init__.py │ │ ├── course_daily_metrics.py │ │ ├── course_overviews.py │ │ ├── student_modules.py │ │ └── users.py │ ├── celery.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ ├── check_devsite.py │ │ │ └── seed_data.py │ ├── seed.py │ ├── settings.py │ ├── templates │ │ ├── homepage.html │ │ ├── main_django.html │ │ └── registration │ │ │ └── login.html │ ├── test_settings.py │ ├── urls.py │ └── wsgi.py ├── docker-compose.yml ├── manage.py ├── rabbitmq-init.sh ├── rabbitmq.env └── requirements │ ├── development_juniper.txt │ ├── ginkgo.txt │ ├── hawthorn.txt │ ├── hawthorn_base.txt │ ├── hawthorn_multisite.txt │ ├── juniper_base.txt │ ├── juniper_community.txt │ ├── juniper_multisite.txt │ └── test.txt ├── docs ├── Makefile ├── make.bat ├── readme-pypi.rst ├── requirements.txt └── source │ ├── _static │ └── .keep │ ├── _templates │ └── .keep │ ├── api.rst │ ├── conf.py │ ├── devstack.rst │ ├── index.rst │ ├── install-appsembler.rst │ └── install.rst ├── figures ├── __init__.py ├── admin.py ├── apps.py ├── compat.py ├── course.py ├── enrollment.py ├── filters.py ├── helpers.py ├── log.py ├── management │ ├── __init__.py │ ├── base.py │ └── commands │ │ ├── __init__.py │ │ ├── backfill_figures_daily_metrics.py │ │ ├── backfill_figures_enrollment_data.py │ │ ├── backfill_figures_metrics.py │ │ ├── backfill_figures_monthly_metrics.py │ │ ├── populate_figures_metrics.py │ │ ├── repair_figures_backfilled_progress.py │ │ ├── run_figures_mau_metrics.py │ │ └── run_figures_monthly_metrics.py ├── mau.py ├── metrics.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_course_daily_metrics.py │ ├── 0003_pipelineerror.py │ ├── 0004_learner_course_grade_metrics.py │ ├── 0005_add_site_to_models.py │ ├── 0006_remove_default_site_from_models.py │ ├── 0007_modify_course_daily_metrics.py │ ├── 0008_cdm_meta_update.py │ ├── 0009_mau_metrics.py │ ├── 0010_site_monthly_metrics.py │ ├── 0011_add_mau_to_site_daily_metrics.py │ ├── 0012_alter_pipelineerror_field.py │ ├── 0013_add_indexes_to_lcgm_date_for_and_course_id.py │ ├── 0014_add_indexes_to_daily_metrics.py │ ├── 0015_add_enrollment_data_model.py │ ├── 0016_add_collect_elapsed_to_ed_and_lcgm.py │ ├── 0017_add_monthly_active_enrollment_model.py │ └── __init__.py ├── models.py ├── pagination.py ├── permissions.py ├── pipeline │ ├── __init__.py │ ├── backfill.py │ ├── course_daily_metrics.py │ ├── enrollment_metrics.py │ ├── enrollment_metrics_next.py │ ├── helpers.py │ ├── logger.py │ ├── mau_pipeline.py │ ├── site_daily_metrics.py │ └── site_monthly_metrics.py ├── progress.py ├── query.py ├── serializers.py ├── settings │ ├── __init__.py │ └── lms_production.py ├── sites.py ├── static │ └── README ├── tasks.py ├── templates │ └── figures │ │ ├── admin_dropdown_filter.html │ │ └── index.html ├── urls.py └── views.py ├── frontend ├── .gitignore ├── README.md ├── config │ ├── env.js │ ├── jest │ │ ├── cssTransform.js │ │ └── fileTransform.js │ ├── paths.js │ ├── polyfills.js │ ├── webpack.config.dev.js │ ├── webpack.config.prod.js │ └── webpackDevServer.config.js ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json ├── scripts │ ├── build.js │ ├── start.js │ └── test.js ├── src │ ├── App.css │ ├── App.js │ ├── App.test.js │ ├── apiConfig.js │ ├── apiServices │ │ ├── courseMonthlyMetrics.js │ │ ├── handleErrors.js │ │ └── siteMonthlyMetrics.js │ ├── components │ │ ├── course-learners-list │ │ │ ├── CourseLearnersList.js │ │ │ └── _course-learners-list.scss │ │ ├── courses-list │ │ │ ├── CoursesList.js │ │ │ ├── CoursesListItem.js │ │ │ ├── _courses-list-item.scss │ │ │ └── _courses-list.scss │ │ ├── header-views │ │ │ ├── header-content-course │ │ │ │ ├── HeaderContentCourse.js │ │ │ │ └── _header-content-course.scss │ │ │ ├── header-content-csv-reports │ │ │ │ ├── HeaderContentCsvReports.js │ │ │ │ └── _header-content-csv-reports.scss │ │ │ ├── header-content-maus │ │ │ │ ├── HeaderContentMaus.js │ │ │ │ └── _header-content-maus.scss │ │ │ ├── header-content-reports-list │ │ │ │ ├── HeaderContentReportsList.js │ │ │ │ └── _header-content-reports-list.scss │ │ │ ├── header-content-static │ │ │ │ ├── HeaderContentStatic.js │ │ │ │ └── _header-content-static.scss │ │ │ ├── header-content-user │ │ │ │ ├── HeaderContentUser.js │ │ │ │ └── _header-content-user.scss │ │ │ └── header-report │ │ │ │ ├── HeaderReport.js │ │ │ │ └── _header-report.scss │ │ ├── inputs │ │ │ ├── AutoCompleteCourseSelect.js │ │ │ ├── AutoCompleteUserSelect.js │ │ │ ├── ContentEditable.js │ │ │ ├── ListSearch.js │ │ │ ├── _autocomplete-course-select.scss │ │ │ ├── _autocomplete-user-select.scss │ │ │ └── _list-search.scss │ │ ├── layout │ │ │ ├── FiguresLogo.js │ │ │ ├── HeaderAreaLayout.js │ │ │ ├── HeaderNav.js │ │ │ ├── Paginator.jsx │ │ │ ├── _header-area-layout.scss │ │ │ ├── _header-nav.scss │ │ │ └── _paginator.scss │ │ ├── learner-statistics │ │ │ ├── LearnerStatistics.js │ │ │ └── _learner-statistics.scss │ │ ├── logos │ │ │ └── FiguresLogos.js │ │ ├── stat-cards │ │ │ ├── BaseStatCard.js │ │ │ └── _base-stat-card.scss │ │ ├── stat-graphs │ │ │ └── stat-bar-graph │ │ │ │ ├── StatBarGraph.js │ │ │ │ ├── StatHorizontalBarGraph.js │ │ │ │ ├── _stat-bar-graph.scss │ │ │ │ └── _stat-horizontal-bar-graph.scss │ │ └── user-courses-list │ │ │ ├── UserCoursesList.js │ │ │ └── _user-courses-list.scss │ ├── containers │ │ └── loading-spinner │ │ │ ├── LoadingSpinner.js │ │ │ └── _loading-spinner.scss │ ├── data │ │ └── countriesData.js │ ├── images │ │ └── logo │ │ │ ├── figures--logo--icon--negative.svg │ │ │ ├── figures--logo--negative.svg │ │ │ └── sample-svg.svg │ ├── index.css │ ├── index.js │ ├── logo.svg │ ├── postcss.config.js │ ├── redux │ │ ├── actions │ │ │ ├── ActionTypes.js │ │ │ └── Actions.js │ │ ├── reducers │ │ │ ├── csvReportsIndexReducers.js │ │ │ ├── generalDataReducers.js │ │ │ ├── index.js │ │ │ ├── reportReducers.js │ │ │ ├── reportsListReducers.js │ │ │ └── userDataReducers.js │ │ └── store.js │ ├── registerServiceWorker.js │ ├── sass │ │ ├── base │ │ │ ├── _base-overrides.scss │ │ │ ├── _functions.scss │ │ │ ├── _grid.scss │ │ │ ├── _mixins.scss │ │ │ ├── _stat-cards.scss │ │ │ └── _variables.scss │ │ └── header-views │ │ │ └── _header-common.scss │ ├── tempData │ │ ├── report.js │ │ └── reportsList.js │ └── views │ │ ├── CoursesList.js │ │ ├── CsvReports.js │ │ ├── DashboardContent.js │ │ ├── MauDetailsContent.js │ │ ├── ProgressOverview.js │ │ ├── ReportsList.js │ │ ├── SingleCourseContent.js │ │ ├── SingleReportContent.js │ │ ├── SingleUserContent.js │ │ ├── UsersList.js │ │ ├── _courses-list-content.scss │ │ ├── _csv-reports-content.scss │ │ ├── _dashboard-content.scss │ │ ├── _mau-details-content.scss │ │ ├── _progress-overview-content.scss │ │ ├── _reports-list-content.scss │ │ ├── _single-course-content.scss │ │ ├── _single-report-content.scss │ │ ├── _single-user-content.scss │ │ └── _users-list-content.scss └── yarn.lock ├── ginkgo-env ├── mocks ├── ginkgo │ ├── __init__.py │ ├── certificates │ │ ├── __init__.py │ │ └── models.py │ ├── course_modes │ │ ├── __init__.py │ │ └── models.py │ ├── courseware │ │ ├── __init__.py │ │ ├── courses.py │ │ └── models.py │ ├── lms │ │ ├── __init__.py │ │ └── djangoapps │ │ │ ├── __init__.py │ │ │ ├── grades │ │ │ ├── __init__.py │ │ │ └── new │ │ │ │ ├── __init__.py │ │ │ │ ├── course_grade.py │ │ │ │ └── course_grade_factory.py │ │ │ └── teams │ │ │ ├── __init__.py │ │ │ └── models.py │ ├── openedx │ │ ├── __init__.py │ │ └── core │ │ │ ├── __init__.py │ │ │ ├── djangoapps │ │ │ ├── __init__.py │ │ │ ├── content │ │ │ │ ├── __init__.py │ │ │ │ └── course_overviews │ │ │ │ │ ├── __init__.py │ │ │ │ │ └── models.py │ │ │ ├── course_groups │ │ │ │ ├── __init__.py │ │ │ │ ├── migrations │ │ │ │ │ ├── 0001_initial.py │ │ │ │ │ └── __init__.py │ │ │ │ └── models.py │ │ │ ├── user_api │ │ │ │ ├── __init__.py │ │ │ │ └── accounts │ │ │ │ │ ├── __init__.py │ │ │ │ │ └── serializers.py │ │ │ └── xmodule_django │ │ │ │ ├── __init__.py │ │ │ │ └── models.py │ │ │ └── release.py │ ├── student │ │ ├── __init__.py │ │ ├── migrations │ │ │ ├── 0001_initial.py │ │ │ ├── 0002_auto_20191231_1006.py │ │ │ └── __init__.py │ │ ├── models.py │ │ └── roles.py │ └── xmodule │ │ ├── __init__.py │ │ └── modulestore │ │ ├── __init__.py │ │ └── django.py ├── hawthorn │ ├── __init__.py │ ├── course_modes │ │ ├── __init__.py │ │ └── models.py │ ├── courseware │ │ ├── __init__.py │ │ ├── courses.py │ │ ├── migrations │ │ │ ├── 0001_initial.py │ │ │ └── __init__.py │ │ └── models.py │ ├── lms │ │ ├── __init__.py │ │ └── djangoapps │ │ │ ├── __init__.py │ │ │ ├── certificates │ │ │ ├── __init__.py │ │ │ ├── migrations │ │ │ │ ├── 0001_initial.py │ │ │ │ └── __init__.py │ │ │ └── models.py │ │ │ ├── grades │ │ │ ├── __init__.py │ │ │ ├── course_grade.py │ │ │ └── course_grade_factory.py │ │ │ └── teams │ │ │ ├── __init__.py │ │ │ └── models.py │ ├── openedx │ │ ├── __init__.py │ │ └── core │ │ │ ├── __init__.py │ │ │ ├── djangoapps │ │ │ ├── __init__.py │ │ │ ├── content │ │ │ │ ├── __init__.py │ │ │ │ └── course_overviews │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── migrations │ │ │ │ │ ├── 0001_initial.py │ │ │ │ │ └── __init__.py │ │ │ │ │ └── models.py │ │ │ ├── course_groups │ │ │ │ ├── __init__.py │ │ │ │ ├── migrations │ │ │ │ │ ├── 0001_initial.py │ │ │ │ │ └── __init__.py │ │ │ │ └── models.py │ │ │ ├── plugins │ │ │ │ ├── __init__.py │ │ │ │ └── constants.py │ │ │ ├── user_api │ │ │ │ ├── __init__.py │ │ │ │ └── accounts │ │ │ │ │ ├── __init__.py │ │ │ │ │ └── serializers.py │ │ │ └── xmodule_django │ │ │ │ ├── __init__.py │ │ │ │ └── models.py │ │ │ └── release.py │ ├── student │ │ ├── __init__.py │ │ ├── migrations │ │ │ ├── 0001_initial.py │ │ │ └── __init__.py │ │ ├── models.py │ │ └── roles.py │ └── xmodule │ │ ├── __init__.py │ │ └── modulestore │ │ ├── __init__.py │ │ ├── django.py │ │ └── exceptions.py └── juniper │ ├── __init__.py │ ├── course_modes │ ├── __init__.py │ └── models.py │ ├── lms │ ├── __init__.py │ └── djangoapps │ │ ├── __init__.py │ │ ├── certificates │ │ ├── __init__.py │ │ ├── migrations │ │ │ ├── 0001_initial.py │ │ │ └── __init__.py │ │ └── models.py │ │ ├── courseware │ │ ├── __init__.py │ │ ├── courses.py │ │ ├── migrations │ │ │ ├── 0001_initial.py │ │ │ └── __init__.py │ │ └── models.py │ │ ├── grades │ │ ├── __init__.py │ │ ├── course_grade.py │ │ └── course_grade_factory.py │ │ └── teams │ │ ├── __init__.py │ │ └── models.py │ ├── openedx │ ├── __init__.py │ └── core │ │ ├── __init__.py │ │ ├── djangoapps │ │ ├── __init__.py │ │ ├── content │ │ │ ├── __init__.py │ │ │ └── course_overviews │ │ │ │ ├── __init__.py │ │ │ │ ├── migrations │ │ │ │ ├── 0001_initial.py │ │ │ │ └── __init__.py │ │ │ │ └── models.py │ │ ├── course_groups │ │ │ ├── __init__.py │ │ │ ├── migrations │ │ │ │ ├── 0001_initial.py │ │ │ │ └── __init__.py │ │ │ └── models.py │ │ ├── plugins │ │ │ ├── __init__.py │ │ │ └── constants.py │ │ ├── user_api │ │ │ ├── __init__.py │ │ │ └── accounts │ │ │ │ ├── __init__.py │ │ │ │ └── serializers.py │ │ └── xmodule_django │ │ │ ├── __init__.py │ │ │ └── models.py │ │ └── release.py │ ├── student │ ├── __init__.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ └── roles.py │ └── xmodule │ ├── __init__.py │ └── modulestore │ ├── __init__.py │ ├── django.py │ └── exceptions.py ├── old-docs └── api-reference.md ├── pylintrc ├── pylintrc_tweaks ├── pytest-ginkgo.ini ├── pytest-hawthorn.ini ├── pytest-juniper.ini ├── pytest.ini ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── commands │ ├── __init__.py │ ├── test_backfill_figures_daily_metrics_command.py │ └── test_backfill_figures_enrollment_data_command.py ├── conftest.py ├── factories.py ├── helpers.py ├── metrics │ ├── __init__.py │ ├── test_certificate_metrics.py │ ├── test_course_monthly_metrics.py │ ├── test_learner_course_grades.py │ ├── test_metrics.py │ ├── test_registered_users.py │ └── test_site_monthly_metrics.py ├── models │ ├── __init__.py │ ├── test_course_daily_metrics_model.py │ ├── test_enrollment_data_model.py │ ├── test_enrollment_data_update_metrics.py │ ├── test_learner_course_grade_metrics_model.py │ ├── test_mau_models.py │ ├── test_monthly_active_enrollment_model.py │ ├── test_pipeline_errors_model.py │ ├── test_site_daily_metrics_model.py │ └── test_site_monthly_metrics_model.py ├── pipeline │ ├── __init__.py │ ├── test_backfill.py │ ├── test_backfill_daily_metrics.py │ ├── test_course_daily_metrics.py │ ├── test_course_mau.py │ ├── test_enrollment_metrics.py │ ├── test_enrollment_metrics_next.py │ ├── test_helpers.py │ ├── test_logger.py │ ├── test_site_daily_metrics.py │ ├── test_site_daily_metrics_functions.py │ └── test_site_monthly_metrics.py ├── tasks │ ├── __init__.py │ ├── test_backfill_tasks.py │ ├── test_daily_tasks.py │ ├── test_mau_tasks.py │ └── test_monthly_tasks.py ├── test-webpack-stats.json ├── test_admin.py ├── test_apps.py ├── test_commands.py ├── test_compat.py ├── test_course.py ├── test_enrollment.py ├── test_filters.py ├── test_helpers.py ├── test_log.py ├── test_mau.py ├── test_mocks.py ├── test_pagination.py ├── test_permissions.py ├── test_serializers.py ├── test_settings.py ├── test_sites.py └── views │ ├── __init__.py │ ├── base.py │ ├── helpers.py │ ├── test_course_daily_metrics_view.py │ ├── test_course_enrollment_view.py │ ├── test_course_monthly_metrics_viewset.py │ ├── test_courses_index_view.py │ ├── test_enrollment_metrics_viewset.py │ ├── test_figures_home_view.py │ ├── test_general_course_data_view.py │ ├── test_general_site_metrics_view.py │ ├── test_general_user_data_view.py │ ├── test_learner_details_view.py │ ├── test_learner_metrics_viewset_v1.py │ ├── test_learner_metrics_viewset_v2.py │ ├── test_mau_views.py │ ├── test_site_daily_metrics_view.py │ ├── test_site_monthly_metrics_viewset.py │ ├── test_sites_view.py │ └── test_user_index_view.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | setup.py 4 | tests/* 5 | mocks/* 6 | devsite/devsite/celery.py 7 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # This file provides default environemnt settings 2 | # override by creating a '.env.' file for the specific settings 3 | # you wish to override 4 | 5 | # Set the edx-platform named release. The default release is "hawthorn" 6 | OPENEDX_RELEASE=juniper 7 | 8 | # Env settings TODO 9 | # - PyPI authentication for Twine 10 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # see: https://appsembler.atlassian.net/wiki/spaces/ED/pages/2227503161/CODEOWNERS 2 | 3 | 4 | # Code owner 5 | * @bryanlandia 6 | 7 | 8 | # Default reviewers 9 | * @amirtds @jfaMan 10 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | ## Expected Behavior 2 | 3 | 4 | ## Actual Behavior 5 | 6 | 7 | ## Steps to Reproduce the Problem 8 | 9 | 1. 10 | 1. 11 | 1. 12 | 13 | ## Specifications 14 | 15 | - Version: 16 | - Platform: 17 | - Subsystem: 18 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Change description 2 | 3 | > Description here 4 | 5 | ## Type of change 6 | - [ ] Bug fix (fixes an issue) 7 | - [ ] New feature (adds functionality) 8 | 9 | ## Related issues 10 | 11 | > Fix [#1]() 12 | 13 | ## Checklists 14 | 15 | ### Development 16 | 17 | - [ ] Lint rules pass locally 18 | - [ ] Application changes have been tested thoroughly 19 | - [ ] Automated tests covering modified code pass 20 | 21 | ### Security 22 | 23 | - [ ] Security impact of change has been considered 24 | - [ ] Code follows company security practices and guidelines 25 | 26 | ### Code review 27 | 28 | - [ ] Pull request has a descriptive title and context useful to a reviewer. Screenshots or screencasts are attached as necessary 29 | - [ ] "Ready for review" label attached and reviewers assigned 30 | - [ ] Changes have been reviewed by at least one other contributor 31 | - [ ] Pull request linked to task tracker where applicable 32 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [main] 9 | schedule: 10 | - cron: '0 12 * * 3' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | # Override automatic language detection by changing the below list 21 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 22 | language: ['python', 'javascript'] 23 | # Learn more... 24 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v2 29 | with: 30 | # We must fetch at least the immediate parents so that if this is 31 | # a pull request then we can checkout the head. 32 | fetch-depth: 2 33 | 34 | # If this run was triggered by a pull request event, then checkout 35 | # the head of the pull request instead of the merge commit. 36 | - run: git checkout HEAD^2 37 | if: ${{ github.event_name == 'pull_request' }} 38 | 39 | # Initializes the CodeQL tools for scanning. 40 | - name: Initialize CodeQL 41 | uses: github/codeql-action/init@v1 42 | with: 43 | languages: ${{ matrix.language }} 44 | 45 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 46 | # If this step fails, then you should remove it and run the build manually (see below) 47 | - name: Autobuild 48 | uses: github/codeql-action/autobuild@v1 49 | 50 | # ℹ️ Command-line programs to run using the OS shell. 51 | # 📚 https://git.io/JvXDl 52 | 53 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 54 | # and modify them (or add more) to build your code if your project 55 | # uses a compiled language 56 | 57 | #- run: | 58 | # make bootstrap 59 | # make release 60 | 61 | - name: Perform CodeQL Analysis 62 | uses: github/codeql-action/analyze@v1 63 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Build and upload Python package 5 | 6 | on: 7 | release: 8 | types: [published] 9 | pull_request: 10 | 11 | jobs: 12 | deploy: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Use Node.js 12.x 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: 12.x 21 | - name: Build frontend 22 | run: | 23 | cd frontend 24 | npm install -g 'yarn@<2' 25 | yarn 26 | yarn build 27 | - name: Set up Python 28 | uses: actions/setup-python@v2 29 | with: 30 | python-version: '3.x' 31 | - name: Install dependencies 32 | run: | 33 | python -m pip install --upgrade pip 34 | pip install build 35 | - name: Build package 36 | run: python -m build 37 | - name: 'Publish package' 38 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 39 | with: 40 | user: __token__ 41 | password: ${{ secrets.PYPI_API_TOKEN }} 42 | if: ${{ startsWith(github.ref, 'refs/tags') }} 43 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: figures tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - develop/maple 8 | pull_request: 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-20.04 13 | strategy: 14 | matrix: 15 | include: 16 | - python: 2.7 17 | tox-env: py27-ginkgo 18 | - python: 2.7 19 | tox-env: py27-hawthorn 20 | - python: 2.7 21 | tox-env: py27-hawthorn_multisite 22 | - python: 3.5 23 | tox-env: py35-juniper_community 24 | - python: 3.5 25 | tox-env: py35-juniper_multisite 26 | - python: 3.8 27 | tox-env: lint 28 | - python: 3.8 29 | tox-env: edx_lint_check 30 | steps: 31 | - uses: actions/checkout@v2 32 | - name: Setup Python 33 | uses: actions/setup-python@v4 34 | with: 35 | python-version: ${{ matrix.python }} 36 | - name: install dependencies 37 | run: | 38 | pip install tox==3.9 flake8 39 | - name: Uses Node.js 40 | uses: actions/setup-node@v1 41 | with: 42 | node-version: 12 43 | - run: npm install -g yarn 44 | - run: | 45 | cd frontend && yarn install && cd .. 46 | - name: flake8 47 | run: | 48 | flake8 figures 49 | - name: run tox 50 | run: | 51 | tox -e ${{ matrix.tox-env }} 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | devsite/.env 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # virtualenv 85 | .venv 86 | venv/ 87 | ENV/ 88 | /ve 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # Mac junk 98 | .DS_Store 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | node_modules 107 | 108 | # PyCharm 109 | .idea 110 | 111 | frontend/webpack-stats.json 112 | 113 | # Sublime Text 114 | 115 | *.sublime-project 116 | *.sublime-workspace 117 | 118 | # SQLite 119 | devsite/*.sqlite3 120 | 121 | # Vi, Vim, swap 122 | *.swp 123 | 124 | # misc folders and files used in the workspace 125 | scratch 126 | sandbox 127 | /scratch.py 128 | /scratch.txt 129 | /notes 130 | *.bak 131 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Release History 2 | =============== 3 | 4 | ## 0.3.0 Releases 5 | 6 | ### 0.3.0-prerelease 7 | 8 | * Adds Hawthorn support to Figures 9 | * Uses the new edx-platform plugin capability 10 | * Standalone developer mode with mock data generation 11 | * Test coverage improvements 12 | * Bug fixes 13 | 14 | ## 0.1 and 0.2 Releases 15 | 16 | * 0.1 (0.1.x/ficus-ginkgo branch) - Initial pip installable to run on Ginkgo 17 | * 0.2 (0.2.x/ficus-ginkgo-multisite) - Adds multisite support, bug fixes 18 | 19 | ## 0.2 Releases 20 | 21 | ### 0.2.0 - Initial production release 22 | 23 | Latest: 0.2.0rc6 24 | 25 | * Adds multisite support 26 | * Admin interface improvements: Added filtering for sites, course_ids, and users 27 | ** Added html template from: [django-admin-list-filter-dropdown](https://github.com/mrts/django-admin-list-filter-dropdown) 28 | * Fixed pipeline bugs - active users date->datetime bug 29 | 30 | ## 0.1 Releases 31 | 32 | ### 0.1.6 33 | 34 | Fixed UI issue (fonts too small) by enabling SASS options to be passed as part of the yarn build. 35 | For more details, see [Figures PR #66](https://github.com/appsembler/figures/pull/66) 36 | 37 | ### 0.1.5 38 | 39 | Ficus now supported, performance improvements, bug fixes. 40 | 41 | ### 0.1.4 42 | 43 | Metrics and pipeline bug fixes, UI cleanup, documentation updates. 44 | 45 | ### 0.1.3 46 | 47 | Updated how settings integrate with the Open edX LMS and updated related documentation. 48 | 49 | ### 0.1.2 50 | 51 | Packaging fixes and README update. 52 | 53 | ### 0.1.1 54 | 55 | Packaging fix. 56 | 57 | ### 0.1.0 58 | 59 | Initial release for open testing. 60 | -------------------------------------------------------------------------------- /FAQ.md: -------------------------------------------------------------------------------- 1 | FAQ 2 | === 3 | 4 | 5 | #### In the architecture doc it states that it will have 'daily data collection'. Is it real-time or up to one day stale? 6 | 7 | 8 | Figures collects metrics data once a day. The default time is 2:00 am UTC. This can be configured to be a different time of day by adding settings in `lms.env.json`: 9 | 10 | ``` 11 | { 12 | 13 | ... 14 | 15 | "FIGURES": { 16 | "DAILY_METRICS_IMPORT_HOUR": , 17 | "DAILY_METRICS_IMPORT_MINUTE": 18 | }, 19 | 20 | ... 21 | 22 | } 23 | ``` 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Noderabbit Inc., d.b.a. Appsembler 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | include Makefile 4 | include figures/webpack-stats.json 5 | recursive-include figures/static * 6 | recursive-include figures/templates * 7 | recursive-include docs * 8 | include tests/test-webpack-stats.json 9 | recursive-include frontend/ * 10 | prune frontend/node_modules 11 | prune frontend/build 12 | prune frontend/webpack-stats.json 13 | -------------------------------------------------------------------------------- /bandit.yaml: -------------------------------------------------------------------------------- 1 | exclude_dirs: 2 | - '/tests/' 3 | -------------------------------------------------------------------------------- /devsite/.env.example: -------------------------------------------------------------------------------- 1 | # 2 | # Figures devsite environment settings example file 3 | # 4 | 5 | # Django development debug mode 6 | DEBUG=true 7 | 8 | # Set a database to use with devsite 9 | # The default database is SQLite and assigned in devsite/settings.py 10 | # Use this specific string if you want to run Figures with MySQL Docker 11 | # For more details, read the django-environ README 12 | # https://github.com/joke2k/django-environ/blob/v0.4.5/README.rst 13 | DATABASE_URL=mysql://figures_user:drowssap-ekaf@127.0.0.1:3306/figures-db 14 | 15 | # Set which expected Open edX release mocks for devsite to use. 16 | # Valid options are: "GINKGO", "HAWTHORN", "JUNIPER" 17 | # 18 | # If not specified here, then "JUNIPER" is used 19 | # OPENEDX_RELEASE=JUNIPER 20 | 21 | # Enable/disable Figures multisite mode in devsite 22 | # This also requires 23 | FIGURES_IS_MULTISITE=true 24 | 25 | # Core Django setting to set which allowed hosts/domain names that devsite can serve. 26 | # See: https://docs.djangoproject.com/en/2.2/ref/settings/#allowed-hosts 27 | # The primary purpose of this setting to help with multi-site development. 28 | # Example 29 | # ALLOWED_HOSTS=*,alpha.localhost, bravo.localhost 30 | ALLOWED_HOSTS=* 31 | 32 | # Set the log level that devsite uses 33 | # Log levels: Critical=50, Error=40, Warning=30, Info=20, Debug=10, Notset=0 34 | LOG_LEVEL=10 35 | 36 | # Enable the OpenAPI docs feature 37 | ENABLE_OPENAPI_DOCS=true 38 | 39 | # Set synthetic data seed options 40 | SEED_DAYS_BACK=60 41 | SEED_NUM_LEARNERS_PER_COURSE=25 42 | -------------------------------------------------------------------------------- /devsite/devsite/__init__.py: -------------------------------------------------------------------------------- 1 | """Site init file 2 | """ 3 | 4 | from __future__ import absolute_import, unicode_literals 5 | 6 | # This will make sure the app is always imported when 7 | # Django starts so that shared_task will use this app. 8 | from .celery import app as celery_app 9 | 10 | __all__ = ('celery_app',) 11 | -------------------------------------------------------------------------------- /devsite/devsite/cans/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | devsite.cans contains canned mock data to demo Figures 3 | 4 | BE CAREFUL to avoid cyclical dependencies 5 | 6 | """ 7 | 8 | from .users import USER_DATA # noqa 9 | from .course_overviews import COURSE_OVERVIEW_DATA # noqa 10 | from .course_daily_metrics import COURSE_DAILY_METRICS_DATA # noqa 11 | 12 | 13 | COURSE_ACCESS_ROLE_DATA = [ 14 | dict( 15 | username='wanda', 16 | org='StarFleetAcademy', 17 | course_id='course-v1:StarFleetAcademy+SFA01+2161', 18 | role='instructor'), 19 | ] 20 | -------------------------------------------------------------------------------- /devsite/devsite/cans/course_overviews.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | This module provides canned courses to test specific criteria 4 | TODO: 5 | * Add self-paced flag 6 | """ 7 | 8 | COURSE_OVERVIEW_DATA = [ 9 | dict( 10 | id='course-v1:StarFleetAcademy+SFA01+2161', 11 | display_name='Intro to Astronomy', 12 | org='StarFleetAcademy', 13 | display_org_with_default='StarFleetAcademy', 14 | number='SFA01', 15 | created='2018-07-01', 16 | enrollment_start='2018-08-01', 17 | enrollment_end='2018-12-31', 18 | ), 19 | # The following provide two identical courses with different runs 20 | dict( 21 | id='course-v1:StarFleetAcademy+SFA02+2161', 22 | display_name='Intro to Xenology', 23 | org='StarFleetAcademy', 24 | display_org_with_default='StarFleetAcademy', 25 | number='SFA02', 26 | created='2018-09-01', 27 | enrollment_start='2018-10-05', 28 | enrollment_end='2019-02-02', 29 | ), 30 | dict( 31 | id='course-v1:StarFleetAcademy+SFA02+2162', 32 | display_name='Intro to Xenology', 33 | org='StarFleetAcademy', 34 | display_org_with_default='StarFleetAcademy', 35 | number='SFA03', 36 | created='2019-09-01', 37 | enrollment_start='2019-10-05', 38 | enrollment_end='2020-02-02', 39 | ), 40 | ] 41 | -------------------------------------------------------------------------------- /devsite/devsite/cans/student_modules.py: -------------------------------------------------------------------------------- 1 | 2 | from __future__ import absolute_import 3 | from django.contrib.auth import get_user_model 4 | 5 | from figures.compat import StudentModule 6 | 7 | from figures.helpers import as_course_key, as_datetime 8 | 9 | # Yes, this is empty. Need to decide if we're going to do hardcoded canned data 10 | STUDENT_MODULE_DATA = [ 11 | 12 | ] 13 | 14 | 15 | def seed_student_modules_fixed(data=None): 16 | ''' 17 | ''' 18 | if not data: 19 | data = STUDENT_MODULE_DATA 20 | for rec in data: 21 | StudentModule.objects.update_or_create( 22 | student=get_user_model().objects.get(username=rec['username']), 23 | course_id=as_course_key(rec['course_id']), 24 | create=as_datetime(rec['created']), 25 | modified=as_datetime(rec['modified']), 26 | ) 27 | -------------------------------------------------------------------------------- /devsite/devsite/celery.py: -------------------------------------------------------------------------------- 1 | """Site Celery setup 2 | """ 3 | 4 | from __future__ import absolute_import, unicode_literals 5 | from __future__ import print_function 6 | import os 7 | from celery import Celery 8 | from django.conf import settings 9 | 10 | 11 | CELERY_CHECK_MSG_PREFIX = 'figures-devsite-celery-check' 12 | 13 | 14 | # set the default Django settings module for the 'celery' program. 15 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'devsite.settings') 16 | 17 | app = Celery('devsite') 18 | 19 | # For Celery 4.0+ 20 | # 21 | # Using a string here means the worker doesn't have to serialize 22 | # the configuration object to child processes. 23 | # - namespace='CELERY' means all celery-related configuration keys 24 | # should have a `CELERY_` prefix. 25 | # See: https://docs.celeryproject.org/en/4.0/whatsnew-4.0.html 26 | # `app.config_from_object('django.conf:settings', namespace='CELERY')` 27 | 28 | app.config_from_object('django.conf:settings') 29 | 30 | 31 | # Load task modules from all registered Django app configs. 32 | app.autodiscover_tasks(lambda: settings.INSTALLED_APPS) 33 | 34 | 35 | app.conf.update( 36 | CELERY_RESULT_BACKEND='djcelery.backends.database:DatabaseBackend', 37 | ) 38 | 39 | 40 | @app.task(bind=True) 41 | def debug_task(self): 42 | print(('Request: {0!r}'.format(self.request))) 43 | 44 | 45 | @app.task(bind=True) 46 | def celery_check(self, msg): 47 | """Basic system check to check Celery results in devsite 48 | 49 | Returns a value so that we can test Celery results backend configuration 50 | """ 51 | print(('Called devsite.celery.celery.check with message "{}"'.format(msg))) 52 | return '{prefix}:{msg}'.format(prefix=CELERY_CHECK_MSG_PREFIX, msg=msg) 53 | -------------------------------------------------------------------------------- /devsite/devsite/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/devsite/devsite/management/__init__.py -------------------------------------------------------------------------------- /devsite/devsite/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/devsite/devsite/management/commands/__init__.py -------------------------------------------------------------------------------- /devsite/devsite/management/commands/check_devsite.py: -------------------------------------------------------------------------------- 1 | """This command serves to run system checks for Figures devsite 2 | 3 | Initially, this is a convenience command to check that Celery on devsite works 4 | properly 5 | 6 | It calls the task `devsite.celery.run_celery_check` 7 | 8 | """ 9 | 10 | from __future__ import absolute_import 11 | from __future__ import print_function 12 | from django.core.management.base import BaseCommand 13 | 14 | from devsite.celery import celery_check 15 | 16 | 17 | class Command(BaseCommand): 18 | 19 | def run_devsite_celery_task(self): 20 | """Perform basic Celery checking 21 | 22 | In production, we typically don't want to call `.get()`, but trying it 23 | here just to see if the results backend is configured and working 24 | 25 | See the `get` method here: 26 | https://docs.celeryproject.org/en/stable/reference/celery.result.html 27 | """ 28 | print('Checking Celery...') 29 | msg = 'run_devsite_check management command' 30 | result = celery_check.delay(msg) 31 | print(('Task called. task_id={}'.format(result.task_id))) 32 | 33 | try: 34 | print(('result={}'.format(result.get()))) 35 | except NotImplementedError as e: 36 | print(('Error: {}'.format(e))) 37 | 38 | print('Done checking Celery') 39 | 40 | def add_arguments(self, parser): 41 | """Stub""" 42 | pass 43 | 44 | def handle(self, *args, **options): 45 | print('Figures devsite system check.') 46 | self.run_devsite_celery_task() 47 | print('Done.') 48 | -------------------------------------------------------------------------------- /devsite/devsite/management/commands/seed_data.py: -------------------------------------------------------------------------------- 1 | """ 2 | This command writes synthetic data to the metrics models. Any existing data are deleted 3 | """ 4 | 5 | from __future__ import absolute_import 6 | from __future__ import print_function 7 | from django.core.management.base import BaseCommand 8 | 9 | from devsite import seed 10 | 11 | 12 | class Command(BaseCommand): 13 | 14 | def add_arguments(self, parser): 15 | pass 16 | 17 | def handle(self, *args, **options): 18 | print('Seeding mock data for Figures demo') 19 | seed.wipe() 20 | seed.seed_all() 21 | print('Done.') 22 | -------------------------------------------------------------------------------- /devsite/devsite/templates/homepage.html: -------------------------------------------------------------------------------- 1 | {% extends "main_django.html" %} 2 | 3 | {% block title %} 4 | {% block pagetitle %}{% endblock %} | Figures Dev Site 5 | {% endblock %} 6 | 7 | {% block body %} 8 |

Welcome to the Figures development web site!

9 |

These are the links you are looking for:

10 |
    11 | {% if user.is_authenticated %} 12 |
  • log out 13 |
  • Figures UI
  • 14 | {% else %} 15 |
  • log in 16 | {% endif %} 17 |
18 | {% endblock %} 19 | -------------------------------------------------------------------------------- /devsite/devsite/templates/main_django.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block title %}Figures Dev Server{% endblock %} 6 | 7 | 8 |
9 | {% block body %}{% endblock %} 10 |
11 | 12 | 13 | {% comment %} 14 | This is the django template for developing Figures. 15 | {% endcomment %} 16 | -------------------------------------------------------------------------------- /devsite/devsite/templates/registration/login.html: -------------------------------------------------------------------------------- 1 | 2 |

Login

3 |
4 | {% csrf_token %} 5 | {{ form.as_p }} 6 | 7 |
8 | -------------------------------------------------------------------------------- /devsite/devsite/urls.py: -------------------------------------------------------------------------------- 1 | """URL patterns includes for Figures development website 2 | """ 3 | 4 | from __future__ import absolute_import 5 | from django.conf.urls import include, url 6 | from django.contrib import admin 7 | from django.conf import settings 8 | from django.views.generic import TemplateView 9 | 10 | urlpatterns = [ 11 | url(r'^$', TemplateView.as_view(template_name='homepage.html'), name='homepage'), 12 | url(r'^admin/', admin.site.urls), 13 | url(r'^accounts/', include('django.contrib.auth.urls')), 14 | url(r'^figures/', include(('figures.urls', 'figures'), namespace='figures')), 15 | ] 16 | 17 | if settings.ENABLE_OPENAPI_DOCS: 18 | from rest_framework import permissions 19 | from drf_yasg2.views import get_schema_view 20 | from drf_yasg2 import openapi 21 | schema_view = get_schema_view( 22 | openapi.Info( 23 | title="Figures API", 24 | default_version='v1', 25 | description="Figures devsite API", 26 | terms_of_service="https://www.google.com/policies/terms/", 27 | contact=openapi.Contact(email="contact@snippets.local"), 28 | license=openapi.License(name="BSD License"), 29 | ), 30 | public=True, 31 | permission_classes=[permissions.AllowAny], 32 | ) 33 | urlpatterns += [ 34 | url(r'^api-docs(?P\.json|\.yaml)$', 35 | schema_view.without_ui(cache_timeout=0), 36 | name='schema-json'), 37 | url(r'^api-docs/$', 38 | schema_view.with_ui('swagger', cache_timeout=0), 39 | name='schema-swagger-ui'), 40 | url(r'^redoc/$', 41 | schema_view.with_ui('redoc', cache_timeout=0), 42 | name='schema-redoc'), 43 | ] 44 | 45 | 46 | if settings.DEBUG: 47 | import debug_toolbar 48 | urlpatterns += [ 49 | url(r'^__debug__/', include(debug_toolbar.urls)), 50 | ] 51 | -------------------------------------------------------------------------------- /devsite/devsite/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for devsite project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.8/howto/deployment/wsgi/ 8 | """ 9 | 10 | from __future__ import absolute_import 11 | import os 12 | 13 | from django.core.wsgi import get_wsgi_application 14 | 15 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "devsite.settings") 16 | 17 | application = get_wsgi_application() 18 | -------------------------------------------------------------------------------- /devsite/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.5' 2 | services: 3 | 4 | rabbitmq_figures: 5 | # build: ./rabbitmq 6 | image: rabbitmq:3 7 | env_file: rabbitmq.env 8 | ports: 9 | - 5672:5672 10 | - 15672:15672 11 | -------------------------------------------------------------------------------- /devsite/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import absolute_import 3 | import os 4 | import sys 5 | 6 | if __name__ == "__main__": 7 | 8 | project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 9 | 10 | # Insert the project root dir to find our reusable app 11 | sys.path.insert(0, project_root) 12 | 13 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "devsite.settings") 14 | 15 | from django.core.management import execute_from_command_line 16 | 17 | # for path in sys.path: 18 | # print(path) 19 | 20 | execute_from_command_line(sys.argv) 21 | -------------------------------------------------------------------------------- /devsite/rabbitmq-init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Run this script from within the RabbitMQ docker container 4 | 5 | rabbitmqctl add_user figures_user figures_pwd 6 | rabbitmqctl add_vhost figures_vhost 7 | rabbitmqctl set_user_tags figures_user figures_tag 8 | rabbitmqctl set_permissions -p figures_vhost figures_user ".*" ".*" ".*" 9 | -------------------------------------------------------------------------------- /devsite/rabbitmq.env: -------------------------------------------------------------------------------- 1 | # RabbitMQ environment settings for Figures devsite 2 | 3 | RABBITMQ_DEFAULT_USER=rabbitadmin 4 | RABBITMQ_DEFAULT_PASS=gr0kBand 5 | -------------------------------------------------------------------------------- /devsite/requirements/development_juniper.txt: -------------------------------------------------------------------------------- 1 | # Requirements for developer environment with Python 3.8+ 2 | 3 | -r juniper_community.txt 4 | 5 | # Used to serve OpenAPI docs 6 | # See Figures docs/source/api.rst 7 | drf-yasg2==1.19.4 8 | -------------------------------------------------------------------------------- /devsite/requirements/hawthorn.txt: -------------------------------------------------------------------------------- 1 | # Python packages needed for Hawthorn community (single site operation) 2 | 3 | -r hawthorn_base.txt 4 | 5 | # Use the community version of edx-organizations 6 | edx-organizations==0.4.10 7 | -------------------------------------------------------------------------------- /devsite/requirements/hawthorn_multisite.txt: -------------------------------------------------------------------------------- 1 | # Requirements needed for Hawthorn multisite environment 2 | 3 | -r hawthorn_base.txt 4 | 5 | # Organization/site mapping requires Appsembler's fork 6 | git+https://github.com/appsembler/edx-organizations.git@0.4.12-appsembler4 7 | -------------------------------------------------------------------------------- /devsite/requirements/juniper_base.txt: -------------------------------------------------------------------------------- 1 | # Requirements needed by the devsite app server and test suite 2 | # For initial development, we're just importing all the packages needed 3 | # for both running the devsite server and for the pytest dependencies 4 | # 5 | 6 | # Versions should match those used in Open edX Juniper 7 | 8 | ## 9 | ## General Python package dependencies 10 | ### 11 | 12 | celery==3.1.26.post2 13 | django-celery==3.3.1 14 | six==1.15.0 15 | 16 | # Faker is used to seed mock data in devsite 17 | Faker==4.1.0 18 | python-dateutil==2.7.3 19 | path.py==12.4.0 20 | 21 | pytz==2020.1 22 | 23 | ## 24 | ## Django package dependencies 25 | ## 26 | 27 | Django==2.2.28 28 | 29 | djangorestframework==3.9.4 30 | django-countries==5.5 31 | django-webpack-loader==0.7.0 32 | django-model-utils==4.0.0 33 | django-filter==2.3.0 34 | django-environ==0.4.5 35 | django-waffle==0.18.0 36 | 37 | jsonfield==2.1.1 38 | 39 | # For 40 | 41 | 42 | ## 43 | ## Documentation (Sphinx) dependencies 44 | ## 45 | 46 | Sphinx==3.1.2 47 | #recommonmark==0.6.0 #! 0.4.0 #caniusepython3 flagged 48 | 49 | ## 50 | ## Open edX package dependencies 51 | ## 52 | 53 | edx-opaque-keys[django]==2.1.0 54 | #edx-drf-extensions==6.0.0 55 | 56 | 57 | ## 58 | ## Devsite 59 | ## 60 | 61 | django-debug-toolbar==2.2 62 | 63 | 64 | ## 65 | ## Test dependencies 66 | ## 67 | 68 | coverage==5.1 69 | factory-boy==2.8.1 70 | flake8==3.8.1 71 | pylint==2.4.2 72 | pylint-django==2.0.11 73 | pytest==5.3.5 74 | pytest-django==3.8.0 75 | pytest-mock==3.2.0 76 | pytest-pythonpath==0.7.3 77 | pytest-cov==2.8.1 78 | tox==3.15.0 79 | freezegun==0.3.12 80 | edx-lint==1.4.1 81 | mock==3.0.5 82 | -------------------------------------------------------------------------------- /devsite/requirements/juniper_community.txt: -------------------------------------------------------------------------------- 1 | # Requirements needed for Juniper community environment 2 | 3 | -r juniper_base.txt 4 | 5 | edx-organizations==5.2.0 6 | -------------------------------------------------------------------------------- /devsite/requirements/juniper_multisite.txt: -------------------------------------------------------------------------------- 1 | # Requirements needed for Juniper multisite environment 2 | 3 | -r juniper_base.txt 4 | 5 | # Organization/site mapping requires Appsembler's fork 6 | git+https://github.com/appsembler/edx-organizations.git@5.2.0-appsembler13 7 | -------------------------------------------------------------------------------- /devsite/requirements/test.txt: -------------------------------------------------------------------------------- 1 | # Shared testing requirements 2 | pytest-freezegun==0.4.2 3 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = source 8 | BUILDDIR = build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/readme-pypi.rst: -------------------------------------------------------------------------------- 1 | Figures is a Django reusable app for `Open edX `__ to provide site-wide and cross-course analytics to compliment Open edX's traditional course-centric analytics. 2 | 3 | Please see the project `README `__ on Github for more information. 4 | 5 | 6 | ## Requirements 7 | 8 | 9 | ### Figures 0.3.x 10 | 11 | 12 | * Python (2.7) 13 | * Django (1.11) 14 | * Open edX Hawthorn 15 | 16 | ### Figures 0.2.x 17 | 18 | * Python (2.7) 19 | * Django (1.8) 20 | * Open edX Ficus or Ginkgo 21 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | # Package dependencies for Figures docs 2 | 3 | Sphinx==1.8.1 4 | recommonmark==0.4.0 5 | 6 | -------------------------------------------------------------------------------- /docs/source/_static/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/docs/source/_static/.keep -------------------------------------------------------------------------------- /docs/source/_templates/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/docs/source/_templates/.keep -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. Figures documentation master file, created by 2 | sphinx-quickstart on Sun Oct 7 15:22:59 2018. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Figures's documentation! 7 | =================================== 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | install 14 | devstack 15 | install-appsembler 16 | 17 | 18 | Indices and tables 19 | ================== 20 | 21 | * :ref:`genindex` 22 | * :ref:`modindex` 23 | * :ref:`search` 24 | -------------------------------------------------------------------------------- /docs/source/install-appsembler.rst: -------------------------------------------------------------------------------- 1 | .. _appsembler_install: 2 | 3 | ================================= 4 | Installing Figures for Appsembler 5 | ================================= 6 | 7 | This document describes how to install and configure Figures to run in Appsembler's fork of Open edX. 8 | 9 | 10 | ----------- 11 | Placeholder 12 | ----------- 13 | 14 | This document is currently a placeholder. Stay tuned! 15 | -------------------------------------------------------------------------------- /figures/__init__.py: -------------------------------------------------------------------------------- 1 | ''' 2 | ''' 3 | -------------------------------------------------------------------------------- /figures/apps.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides application configuration for Figures. 3 | 4 | As well as default values for running Figures along with functions to 5 | add entries to the Django conf settings needed to run Figures. 6 | """ 7 | 8 | from __future__ import absolute_import 9 | from django.apps import AppConfig 10 | 11 | try: 12 | from openedx.core.djangoapps.plugins.constants import ( 13 | ProjectType, SettingsType, PluginURLs, PluginSettings 14 | ) 15 | PLATFORM_PLUGIN_SUPPORT = True 16 | except ImportError: 17 | # pre-hawthorn 18 | PLATFORM_PLUGIN_SUPPORT = False 19 | 20 | 21 | if PLATFORM_PLUGIN_SUPPORT: 22 | def production_settings_name(): 23 | """ 24 | Helper for Hawthorn and Ironwood+ compatibility. 25 | 26 | This helper will explicitly break if something have changed in `SettingsType`. 27 | """ 28 | if hasattr(SettingsType, 'AWS'): 29 | # Hawthorn and Ironwood 30 | return getattr(SettingsType, 'AWS') 31 | else: 32 | # Juniper and beyond. 33 | return getattr(SettingsType, 'PRODUCTION') 34 | 35 | 36 | class FiguresConfig(AppConfig): 37 | """ 38 | Provides application configuration for Figures. 39 | """ 40 | 41 | name = 'figures' 42 | verbose_name = 'Figures' 43 | 44 | if PLATFORM_PLUGIN_SUPPORT: 45 | plugin_app = { 46 | PluginURLs.CONFIG: { 47 | ProjectType.LMS: { 48 | PluginURLs.NAMESPACE: u'figures', 49 | PluginURLs.REGEX: u'^figures/', 50 | } 51 | }, 52 | 53 | PluginSettings.CONFIG: { 54 | ProjectType.LMS: { 55 | production_settings_name(): { 56 | PluginSettings.RELATIVE_PATH: u'settings.lms_production', 57 | }, 58 | } 59 | }, 60 | } 61 | -------------------------------------------------------------------------------- /figures/log.py: -------------------------------------------------------------------------------- 1 | """Provides logging and instrumentation functionality for Figures 2 | 3 | """ 4 | 5 | from contextlib import contextmanager 6 | import logging 7 | import timeit 8 | 9 | 10 | default_logger = logging.getLogger(__name__) 11 | 12 | 13 | @contextmanager 14 | def log_exec_time(description, logger=None): 15 | """Context handler to log execution time info for a block 16 | 17 | Parameters: 18 | description : The text to add to the log statement 19 | logger : The logger to receive the log statement 20 | 21 | If `logger' is not provided, then the default logger is used, 22 | 23 | `logging.getLogger(__name__)` 24 | 25 | Example: 26 | 27 | ``` 28 | with log_exec_time('Collect grades for courses in site',logger=my_logger): 29 | do_grades_collection(site=my_site) 30 | ``` 31 | """ 32 | logger = logger if logger else default_logger 33 | start_time = timeit.default_timer() 34 | yield 35 | elapsed = timeit.default_timer() - start_time 36 | msg = '{}: {} s'.format(description, elapsed) 37 | 38 | logger.info(msg) 39 | -------------------------------------------------------------------------------- /figures/management/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | """ 3 | -------------------------------------------------------------------------------- /figures/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | """Django management commands for Figures. 2 | """ 3 | -------------------------------------------------------------------------------- /figures/management/commands/backfill_figures_metrics.py: -------------------------------------------------------------------------------- 1 | """Deprecated: 2 | Please call instead one of: 3 | backfill_figures_daily_metrics, backfill_figures_monthly_metrics, or 4 | backfill_figures_enrollment_data 5 | 6 | Backfills Figures historical metrics 7 | 8 | """ 9 | 10 | from __future__ import print_function 11 | 12 | from __future__ import absolute_import 13 | from textwrap import dedent 14 | import warnings 15 | 16 | from django.core.management import call_command 17 | from django.core.management.base import BaseCommand 18 | 19 | 20 | class Command(BaseCommand): 21 | """Pending Deprecation: Populate Figures metrics models 22 | """ 23 | help = dedent(__doc__).strip() 24 | 25 | def add_arguments(self, parser): 26 | parser.add_argument('--overwrite', 27 | action='store_true', 28 | default=False, 29 | help='overwrite existing data in SiteMonthlyMetrics') 30 | parser.add_argument('--site', 31 | help='backfill a specific site. provide id or domain name') 32 | 33 | def handle(self, *args, **options): 34 | ''' 35 | Pending deprecation. Passes handling off to new commands. 36 | ''' 37 | warnings.warn( 38 | "backfill_figures_metrics is pending deprecation and will be removed in " 39 | "Figures 1.0. Please use one of backfill_figures_daily_metrics, " 40 | "backfill_figures_monthly_metrics, or backfill_figures_enrollment_data, " 41 | "instead.", 42 | PendingDeprecationWarning 43 | ) 44 | print('BEGIN: Backfill Figures Metrics') 45 | 46 | call_command( 47 | 'backfill_figures_monthly_metrics', 48 | overwrite=options['overwrite'], 49 | site=options['site'] 50 | ) 51 | call_command( 52 | 'backfill_figures_daily_metrics', 53 | overwrite=options['overwrite'], 54 | site=options['site'] 55 | ) 56 | 57 | print('DONE: Backfill Figures Metrics') 58 | -------------------------------------------------------------------------------- /figures/management/commands/run_figures_mau_metrics.py: -------------------------------------------------------------------------------- 1 | """Figures management command to run course MAU metrics for all courses, all Sites. 2 | """ 3 | 4 | from __future__ import print_function 5 | 6 | from __future__ import absolute_import 7 | 8 | from textwrap import dedent 9 | 10 | from django.core.management.base import BaseCommand 11 | 12 | from figures.tasks import ( 13 | populate_all_mau 14 | ) 15 | 16 | 17 | class Command(BaseCommand): 18 | """Task runner to kick off Figures celery tasks 19 | """ 20 | help = dedent(__doc__).strip() 21 | 22 | def add_arguments(self, parser): 23 | parser.add_argument('--no-delay', 24 | action='store_true', 25 | default=False, 26 | help='Disable the celery "delay" directive') 27 | 28 | def handle(self, *args, **options): 29 | print('Starting Figures MAU metrics for all Sites...') 30 | 31 | if options['no_delay']: 32 | populate_all_mau() 33 | else: 34 | populate_all_mau.delay() 35 | 36 | print('Done.') 37 | -------------------------------------------------------------------------------- /figures/management/commands/run_figures_monthly_metrics.py: -------------------------------------------------------------------------------- 1 | """Figures management command to manually start Celery tasks from the shell 2 | 3 | We're starting with the monthly metrics 4 | """ 5 | 6 | from __future__ import print_function 7 | 8 | from __future__ import absolute_import 9 | 10 | from textwrap import dedent 11 | 12 | from django.core.management.base import BaseCommand 13 | 14 | from figures.tasks import ( 15 | run_figures_monthly_metrics 16 | ) 17 | 18 | 19 | class Command(BaseCommand): 20 | """Task runner to kick off Figures celery tasks 21 | """ 22 | help = dedent(__doc__).strip() 23 | 24 | def add_arguments(self, parser): 25 | parser.add_argument('--no-delay', 26 | action='store_true', 27 | default=False, 28 | help='Disable the celery "delay" directive') 29 | 30 | def handle(self, *args, **options): 31 | print('Starting Figures monthly metrics...') 32 | 33 | if options['no_delay']: 34 | run_figures_monthly_metrics() 35 | else: 36 | run_figures_monthly_metrics.delay() 37 | 38 | print('Done.') 39 | -------------------------------------------------------------------------------- /figures/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from __future__ import absolute_import 5 | from django.db import migrations, models 6 | import django.utils.timezone 7 | import model_utils.fields 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='SiteDailyMetrics', 18 | fields=[ 19 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 20 | ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, verbose_name='created', editable=False)), 21 | ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, verbose_name='modified', editable=False)), 22 | ('date_for', models.DateField(unique=True)), 23 | ('cumulative_active_user_count', models.IntegerField(null=True, blank=True)), 24 | ('todays_active_user_count', models.IntegerField(null=True, blank=True)), 25 | ('total_user_count', models.IntegerField()), 26 | ('course_count', models.IntegerField()), 27 | ('total_enrollment_count', models.IntegerField()), 28 | ], 29 | options={ 30 | 'ordering': ['-date_for'], 31 | }, 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /figures/migrations/0002_course_daily_metrics.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from __future__ import absolute_import 5 | from django.db import migrations, models 6 | import django.utils.timezone 7 | import model_utils.fields 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ('figures', '0001_initial'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='CourseDailyMetrics', 19 | fields=[ 20 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 21 | ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, verbose_name='created', editable=False)), 22 | ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, verbose_name='modified', editable=False)), 23 | ('date_for', models.DateField()), 24 | ('course_id', models.CharField(max_length=255)), 25 | ('enrollment_count', models.IntegerField()), 26 | ('active_learners_today', models.IntegerField()), 27 | ('average_progress', models.DecimalField(null=True, max_digits=2, decimal_places=2, blank=True)), 28 | ('average_days_to_complete', models.IntegerField(null=True, blank=True)), 29 | ('num_learners_completed', models.IntegerField()), 30 | ], 31 | options={ 32 | 'ordering': ('date_for', 'course_id'), 33 | }, 34 | ), 35 | migrations.AlterUniqueTogether( 36 | name='coursedailymetrics', 37 | unique_together=set([('course_id', 'date_for')]), 38 | ), 39 | ] 40 | -------------------------------------------------------------------------------- /figures/migrations/0003_pipelineerror.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from __future__ import absolute_import 5 | from django.db import migrations, models 6 | import django.utils.timezone 7 | import jsonfield.fields 8 | import model_utils.fields 9 | from django.conf import settings 10 | 11 | 12 | class Migration(migrations.Migration): 13 | 14 | dependencies = [ 15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 16 | ('figures', '0002_course_daily_metrics'), 17 | ] 18 | # TODO Review on_delete behavious 19 | operations = [ 20 | migrations.CreateModel( 21 | name='PipelineError', 22 | fields=[ 23 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 24 | ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, verbose_name='created', editable=False)), 25 | ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, verbose_name='modified', editable=False)), 26 | ('error_type', models.CharField(default=b'UNSPECIFIED', max_length=255, choices=[(b'UNSPECIFIED', b'Unspecified data error'), (b'GRADES', b'Grades data error'), (b'COURSE', b'Course data error'), (b'SITE', b'Site data error')])), 27 | ('error_data', jsonfield.fields.JSONField()), 28 | ('course_id', models.CharField(max_length=255, blank=True)), 29 | ('user', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)), 30 | ], 31 | options={ 32 | 'abstract': False, 33 | }, 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /figures/migrations/0004_learner_course_grade_metrics.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from __future__ import absolute_import 5 | from django.db import migrations, models 6 | import django.utils.timezone 7 | from django.conf import settings 8 | import model_utils.fields 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | dependencies = [ 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ('figures', '0003_pipelineerror'), 16 | ] 17 | # TODO Review on_delete bahviour 18 | operations = [ 19 | migrations.CreateModel( 20 | name='LearnerCourseGradeMetrics', 21 | fields=[ 22 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 23 | ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, verbose_name='created', editable=False)), 24 | ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, verbose_name='modified', editable=False)), 25 | ('date_for', models.DateField()), 26 | ('course_id', models.CharField(max_length=255, blank=True)), 27 | ('points_possible', models.FloatField()), 28 | ('points_earned', models.FloatField()), 29 | ('sections_worked', models.IntegerField()), 30 | ('sections_possible', models.IntegerField()), 31 | ('user', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)), 32 | ], 33 | options={ 34 | 'ordering': ('date_for', 'user__username', 'course_id'), 35 | }, 36 | ), 37 | migrations.AlterModelOptions( 38 | name='pipelineerror', 39 | options={'ordering': ['-created']}, 40 | ), 41 | migrations.AlterUniqueTogether( 42 | name='learnercoursegrademetrics', 43 | unique_together=set([('user', 'course_id', 'date_for')]), 44 | ), 45 | ] 46 | -------------------------------------------------------------------------------- /figures/migrations/0005_add_site_to_models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from __future__ import absolute_import 5 | from django.db import migrations, models 6 | 7 | # TODO Review on_delete behaviour 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('sites', '0001_initial'), 12 | ('figures', '0004_learner_course_grade_metrics'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterModelOptions( 17 | name='sitedailymetrics', 18 | options={'ordering': ['-date_for', 'site']}, 19 | ), 20 | migrations.AddField( 21 | model_name='coursedailymetrics', 22 | name='site', 23 | field=models.ForeignKey(default=1, to='sites.Site', on_delete=models.CASCADE), 24 | ), 25 | migrations.AddField( 26 | model_name='learnercoursegrademetrics', 27 | name='site', 28 | field=models.ForeignKey(default=1, to='sites.Site', on_delete=models.CASCADE), 29 | ), 30 | migrations.AddField( 31 | model_name='pipelineerror', 32 | name='site', 33 | field=models.ForeignKey(blank=True, to='sites.Site', null=True, on_delete=models.CASCADE), 34 | ), 35 | migrations.AddField( 36 | model_name='sitedailymetrics', 37 | name='site', 38 | field=models.ForeignKey(default=1, to='sites.Site', on_delete=models.CASCADE), 39 | ), 40 | migrations.AlterField( 41 | model_name='sitedailymetrics', 42 | name='date_for', 43 | field=models.DateField(), 44 | ), 45 | ] 46 | -------------------------------------------------------------------------------- /figures/migrations/0006_remove_default_site_from_models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from __future__ import absolute_import 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('figures', '0005_add_site_to_models'), 12 | ] 13 | #TODO Review on_delete behaviour 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='coursedailymetrics', 17 | name='site', 18 | field=models.ForeignKey(to='sites.Site', on_delete=models.CASCADE), 19 | ), 20 | migrations.AlterField( 21 | model_name='learnercoursegrademetrics', 22 | name='site', 23 | field=models.ForeignKey(to='sites.Site', on_delete=models.CASCADE), 24 | ), 25 | migrations.AlterField( 26 | model_name='sitedailymetrics', 27 | name='site', 28 | field=models.ForeignKey(to='sites.Site', on_delete=models.CASCADE), 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /figures/migrations/0007_modify_course_daily_metrics.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from __future__ import absolute_import 5 | from django.db import migrations, models 6 | import django.core.validators 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('figures', '0006_remove_default_site_from_models'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='coursedailymetrics', 18 | name='average_progress', 19 | field=models.DecimalField(blank=True, null=True, max_digits=3, decimal_places=2, validators=[django.core.validators.MaxValueValidator(1.0), django.core.validators.MinValueValidator(0.0)]), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /figures/migrations/0008_cdm_meta_update.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.15 on 2019-11-13 15:10 3 | from __future__ import unicode_literals 4 | 5 | from __future__ import absolute_import 6 | from django.db import migrations 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('figures', '0007_modify_course_daily_metrics'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterModelOptions( 17 | name='coursedailymetrics', 18 | options={'ordering': ('-date_for', 'course_id')}, 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /figures/migrations/0010_site_monthly_metrics.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.15 on 2020-04-08 21:46 3 | # Manually updated to support Django 1.8 as well as 1.11 4 | 5 | from __future__ import unicode_literals 6 | 7 | from __future__ import absolute_import 8 | from django import VERSION as DJANGO_VERSION 9 | from django.db import migrations, models 10 | import django.db.models.deletion 11 | import django.utils.timezone 12 | import model_utils.fields 13 | 14 | 15 | class Migration(migrations.Migration): 16 | if DJANGO_VERSION[0:2] == (1,8): 17 | dependencies = [ 18 | ('sites', '0001_initial'), 19 | ('figures', '0009_mau_metrics'), 20 | ] 21 | else: # Assuming 1.11+ 22 | dependencies = [ 23 | ('sites', '0002_alter_domain_unique'), 24 | ('figures', '0009_mau_metrics'), 25 | ] 26 | 27 | operations = [ 28 | migrations.CreateModel( 29 | name='SiteMonthlyMetrics', 30 | fields=[ 31 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 32 | ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), 33 | ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), 34 | ('month_for', models.DateField()), 35 | ('active_user_count', models.IntegerField()), 36 | ('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sites.Site')), 37 | ], 38 | options={ 39 | 'ordering': ['-month_for', 'site'], 40 | }, 41 | ), 42 | migrations.AlterUniqueTogether( 43 | name='sitemonthlymetrics', 44 | unique_together=set([('month_for', 'site')]), 45 | ), 46 | ] 47 | -------------------------------------------------------------------------------- /figures/migrations/0011_add_mau_to_site_daily_metrics.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.15 on 2020-07-10 19:06 3 | from __future__ import unicode_literals 4 | 5 | from __future__ import absolute_import 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('figures', '0010_site_monthly_metrics'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name='sitedailymetrics', 18 | name='mau', 19 | field=models.IntegerField(blank=True, null=True), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /figures/migrations/0012_alter_pipelineerror_field.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.15 on 2020-10-04 15:12 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('figures', '0011_add_mau_to_site_daily_metrics'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='pipelineerror', 15 | name='error_type', 16 | field=models.CharField(choices=[('UNSPECIFIED', 'Unspecified data error'), ('GRADES', 'Grades data error'), ('COURSE', 'Course data error'), ('SITE', 'Site data error')], default='UNSPECIFIED', max_length=255), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /figures/migrations/0016_add_collect_elapsed_to_ed_and_lcgm.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('figures', '0015_add_enrollment_data_model'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='enrollmentdata', 16 | name='collect_elapsed', 17 | field=models.FloatField(null=True), 18 | ), 19 | migrations.AddField( 20 | model_name='learnercoursegrademetrics', 21 | name='collect_elapsed', 22 | field=models.FloatField(null=True), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /figures/migrations/0017_add_monthly_active_enrollment_model.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django import VERSION as DJANGO_VERSION 5 | 6 | from django.conf import settings 7 | from django.db import migrations, models 8 | import django.db.models.deletion 9 | import django.utils.timezone 10 | import model_utils.fields 11 | 12 | 13 | class Migration(migrations.Migration): 14 | if DJANGO_VERSION[0:2] == (1,8): 15 | dependencies = [ 16 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 17 | ('sites', '0001_initial'), 18 | ('figures', '0016_add_collect_elapsed_to_ed_and_lcgm'), 19 | ] 20 | else: # Assuming 1.11+ 21 | dependencies = [ 22 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 23 | ('sites', '0002_alter_domain_unique'), 24 | ('figures', '0016_add_collect_elapsed_to_ed_and_lcgm'), 25 | ] 26 | 27 | operations = [ 28 | migrations.CreateModel( 29 | name='MonthlyActiveEnrollment', 30 | fields=[ 31 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 32 | ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, verbose_name='created', editable=False)), 33 | ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, verbose_name='modified', editable=False)), 34 | ('course_id', models.CharField(max_length=255, db_index=True)), 35 | ('month_for', models.DateField(db_index=True)), 36 | ('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sites.Site')), 37 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 38 | ], 39 | options={ 40 | 'ordering': ['-month_for', 'site', 'course_id'], 41 | }, 42 | ), 43 | migrations.AlterUniqueTogether( 44 | name='monthlyactiveenrollment', 45 | unique_together=set([('site', 'course_id', 'user', 'month_for')]), 46 | ), 47 | ] 48 | -------------------------------------------------------------------------------- /figures/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/figures/migrations/__init__.py -------------------------------------------------------------------------------- /figures/pagination.py: -------------------------------------------------------------------------------- 1 | '''Paginatiors for Figures 2 | 3 | ''' 4 | 5 | from __future__ import absolute_import 6 | from rest_framework.pagination import LimitOffsetPagination 7 | 8 | 9 | class FiguresLimitOffsetPagination(LimitOffsetPagination): 10 | '''Custom Figures paginator to make the number of records returned consistent 11 | ''' 12 | default_limit = 20 13 | 14 | 15 | class FiguresKiloPagination(LimitOffsetPagination): 16 | '''Custom Figures paginator to make the number of records returned consistent 17 | ''' 18 | default_limit = 1000 19 | -------------------------------------------------------------------------------- /figures/pipeline/__init__.py: -------------------------------------------------------------------------------- 1 | ''' 2 | 3 | ''' 4 | -------------------------------------------------------------------------------- /figures/pipeline/logger.py: -------------------------------------------------------------------------------- 1 | '''This module provides baseline logging for the Figures pipeline 2 | 3 | Initial focus is on tracking exceptions for Course 4 | 5 | ''' 6 | 7 | from __future__ import absolute_import 8 | import logging 9 | import json 10 | 11 | from django.core.serializers.json import DjangoJSONEncoder 12 | 13 | from figures.models import PipelineError 14 | from figures import helpers as figure_helpers 15 | 16 | default_logger = logging.getLogger(__name__) 17 | 18 | 19 | def log_error_to_db(error_data, error_type, **kwargs): 20 | data = dict( 21 | error_data=error_data, 22 | error_type=error_type or PipelineError.UNSPECIFIED_DATA, 23 | ) 24 | if 'user' in kwargs: 25 | data.update(user=kwargs['user']) 26 | if 'course_id' in kwargs: 27 | data.update(course_id=str(kwargs['course_id'])) 28 | if 'site' in kwargs: 29 | data.update(site=kwargs['site']) 30 | PipelineError.objects.create(**data) 31 | 32 | 33 | def log_error(error_data, error_type=None, **kwargs): 34 | kwargs.get('logger', default_logger).error(json.dumps( 35 | error_data, 36 | sort_keys=True, 37 | indent=1, 38 | cls=DjangoJSONEncoder)) 39 | 40 | if figure_helpers.log_pipeline_errors_to_db() or kwargs.get('log_pipeline_errors_to_db', False): 41 | log_error_to_db(error_data, error_type, **kwargs) 42 | -------------------------------------------------------------------------------- /figures/query.py: -------------------------------------------------------------------------------- 1 | """Figures query interface 2 | 3 | This module was created to simplify testing and debugging queries used in 4 | Figures API views. That is not its only goal, just where we're starting 5 | 6 | """ 7 | from django.contrib.auth import get_user_model 8 | from django.db.models import Q 9 | 10 | 11 | def site_users_enrollment_data(site, course_ids=None, user_term=None): 12 | """Retrieve queryset for users with enrollments 13 | 14 | This queryset runs 'select_related' to include the UserProfile records 15 | and runs 'prefetch_related' to include the EnrollmentData records. 16 | 17 | This queryset will return all site users and enrollments unless one or both 18 | of the 'course_ids' and 'user_filter' parameters are set. 19 | 20 | If 'course_ids' is None or an empty list then the queryset will return only 21 | users who are enrolled in those courses 22 | If 'user_filer' is not null then a search is made on the username, email 23 | and profile name fields to match the search term as a substring 24 | 25 | If both 'course_ids' and 'user_filter' are used then a queryset matching 26 | the intersection will be returned 27 | """ 28 | qs = get_user_model().objects.filter( 29 | enrollmentdata__site_id=site.id).select_related( 30 | 'profile').prefetch_related('enrollmentdata_set') 31 | 32 | if course_ids: 33 | qs = qs.filter(enrollmentdata__course_id__in=course_ids) 34 | 35 | if user_term: 36 | qs = qs.filter(Q(username__contains=user_term) | 37 | Q(email__contains=user_term) | 38 | Q(profile__name__contains=user_term)) 39 | 40 | return qs.distinct() 41 | -------------------------------------------------------------------------------- /figures/settings/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Settings plugins for Figures. 3 | """ 4 | -------------------------------------------------------------------------------- /figures/static/README: -------------------------------------------------------------------------------- 1 | Bundled static assets will go in a subdirectory here 2 | 3 | -------------------------------------------------------------------------------- /figures/templates/figures/admin_dropdown_filter.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | {# Attribution: Original code here: https://github.com/mrts/django-admin-list-filter-dropdown #} 4 | 5 | 8 |

{% blocktrans with title as filter_title %} By {{ filter_title }} {% endblocktrans %}

9 |
    10 | {# Set the number after slice to the number of choices less one that trigger the selection list #} 11 | {% if choices|slice:"4:" %} 12 |
  • 13 | 20 |
  • 21 | {% else %} 22 |

    Default

    23 | {% for choice in choices %} 24 |
  • 25 | {{ choice.display }}
  • 26 | {% endfor %} 27 | {% endif %} 28 |
-------------------------------------------------------------------------------- /figures/templates/figures/index.html: -------------------------------------------------------------------------------- 1 | {% extends "main_django.html" %} 2 | 3 | {% block title %} 4 | {% block pagetitle %}{% endblock %} | Figures 5 | {% endblock %} 6 | 7 | {% load render_bundle from webpack_loader %} 8 | 9 | {% block body %} 10 | 16 |
17 | 18 | {% render_bundle 'main' config='FIGURES_APP' %} 19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /frontend/config/jest/cssTransform.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // This is a custom Jest transformer turning style imports into empty objects. 4 | // http://facebook.github.io/jest/docs/en/webpack.html 5 | 6 | module.exports = { 7 | process() { 8 | return 'module.exports = {};'; 9 | }, 10 | getCacheKey() { 11 | // The output is always the same. 12 | return 'cssTransform'; 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /frontend/config/jest/fileTransform.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | 5 | // This is a custom Jest transformer turning file imports into filenames. 6 | // http://facebook.github.io/jest/docs/en/webpack.html 7 | 8 | module.exports = { 9 | process(src, filename) { 10 | return `module.exports = ${JSON.stringify(path.basename(filename))};`; 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /frontend/config/polyfills.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | if (typeof Promise === 'undefined') { 4 | // Rejection tracking prevents a common issue where React gets into an 5 | // inconsistent state due to an error, but it gets swallowed by a Promise, 6 | // and the user has no idea what causes React's erratic future behavior. 7 | require('promise/lib/rejection-tracking').enable(); 8 | window.Promise = require('promise/lib/es6-extensions.js'); 9 | } 10 | 11 | // fetch() polyfill for making API calls. 12 | require('whatwg-fetch'); 13 | 14 | // Object.assign() is commonly used with React. 15 | // It will use the native implementation if it's present and isn't buggy. 16 | Object.assign = require('object-assign'); 17 | 18 | // In tests, polyfill requestAnimationFrame since jsdom doesn't provide it yet. 19 | // We don't polyfill it in the browser--this is user's responsibility. 20 | if (process.env.NODE_ENV === 'test') { 21 | require('raf').polyfill(global); 22 | } 23 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 22 | React App 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /frontend/scripts/test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Do this as the first thing so that any code reading it knows the right env. 4 | process.env.BABEL_ENV = 'test'; 5 | process.env.NODE_ENV = 'test'; 6 | process.env.PUBLIC_URL = ''; 7 | 8 | // Makes the script crash on unhandled rejections instead of silently 9 | // ignoring them. In the future, promise rejections that are not handled will 10 | // terminate the Node.js process with a non-zero exit code. 11 | process.on('unhandledRejection', err => { 12 | throw err; 13 | }); 14 | 15 | // Ensure environment variables are read. 16 | require('../config/env'); 17 | 18 | const jest = require('jest'); 19 | const argv = process.argv.slice(2); 20 | 21 | // Watch unless on CI or in coverage mode 22 | if (!process.env.CI && argv.indexOf('--coverage') < 0) { 23 | argv.push('--watch'); 24 | } 25 | 26 | 27 | jest.run(argv); 28 | -------------------------------------------------------------------------------- /frontend/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | animation: App-logo-spin infinite 20s linear; 7 | height: 80px; 8 | } 9 | 10 | .App-header { 11 | background-color: #222; 12 | height: 150px; 13 | padding: 20px; 14 | color: white; 15 | } 16 | 17 | .App-title { 18 | font-size: 1.5em; 19 | } 20 | 21 | .App-intro { 22 | font-size: large; 23 | } 24 | 25 | @keyframes App-logo-spin { 26 | from { transform: rotate(0deg); } 27 | to { transform: rotate(360deg); } 28 | } 29 | -------------------------------------------------------------------------------- /frontend/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /frontend/src/apiConfig.js: -------------------------------------------------------------------------------- 1 | const apiConfig = { 2 | figuresUsersIndexApi: '/figures/api/user-index/', 3 | edxCourseInfoApi: '/api/courses/v1/courses/', 4 | edxUserInfoApi: '/api/user/v1/accounts/', 5 | courseEnrollmentsApi: '/figures/api/course-enrollments/', 6 | generalSiteMetrics: '/figures/api/general-site-metrics/', 7 | coursesGeneral: '/figures/api/courses-general/', 8 | coursesDetailed: '/figures/api/courses-detail/', 9 | learnersGeneral: '/figures/api/users-general/', 10 | learnersDetailed: '/figures/api/users-detail/', 11 | reportingCsvReportsApi: '/reporting/api/csv-reports/', 12 | coursesIndex: '/figures/api/courses-index/', 13 | learnerMetrics: '/figures/api/learner-metrics/', 14 | } 15 | 16 | export default apiConfig; 17 | -------------------------------------------------------------------------------- /frontend/src/apiServices/courseMonthlyMetrics.js: -------------------------------------------------------------------------------- 1 | import { trackPromise } from 'react-promise-tracker'; 2 | import handleErrors from './handleErrors'; 3 | 4 | const apiUrl = '/figures/api/course-monthly-metrics'; 5 | 6 | export default { 7 | getAllCoursesGeneral: ( parameters = '' ) => { 8 | return trackPromise( 9 | fetch((`${apiUrl}/${parameters}`), { credentials: "same-origin" }) 10 | .then(handleErrors) 11 | .then(response => response.error ? response : response.json()) 12 | ) 13 | }, 14 | getSingleCourseGeneral: ( courseId, parameters = '' ) => { 15 | return trackPromise( 16 | fetch((`${apiUrl}/${courseId}/${parameters}`), { credentials: "same-origin" }) 17 | .then(handleErrors) 18 | .then(response => response.error ? response : response.json()) 19 | ) 20 | }, 21 | getSpecificWithHistory: ( courseId, dataEndpoint, parameters = '' ) => { 22 | return trackPromise( 23 | fetch((`${apiUrl}/${courseId}/${dataEndpoint}/${parameters}`), { credentials: "same-origin" }) 24 | .then(handleErrors) 25 | .then(response => response.error ? response : response.json()) 26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /frontend/src/apiServices/handleErrors.js: -------------------------------------------------------------------------------- 1 | export default function handleErrors(response) { 2 | const contentType = response.headers.get("content-type"); 3 | 4 | if (!response.ok) { 5 | return { 6 | error: response.text() 7 | } 8 | } else if (contentType && contentType.indexOf("application/json") === -1) { 9 | return { 10 | error: 'The response is not valid JSON' 11 | }; 12 | } 13 | return response; 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/apiServices/siteMonthlyMetrics.js: -------------------------------------------------------------------------------- 1 | import { trackPromise } from 'react-promise-tracker'; 2 | import handleErrors from './handleErrors'; 3 | 4 | const apiUrl = '/figures/api/site-monthly-metrics'; 5 | 6 | export default { 7 | getGeneral: ( parameters = '' ) => { 8 | return trackPromise( 9 | fetch((`${apiUrl}/${parameters}`), { credentials: "same-origin" }) 10 | .then(handleErrors) 11 | .then(response => response.error ? response : response.json()) 12 | ) 13 | }, 14 | getSpecificWithHistory: ( dataEndpoint, parameters = '' ) => { 15 | return trackPromise( 16 | fetch((`${apiUrl}/${dataEndpoint}/${parameters}`), { credentials: "same-origin" }) 17 | .then(handleErrors) 18 | .then(response => response.error ? response : response.json()) 19 | ) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/components/courses-list/_courses-list.scss: -------------------------------------------------------------------------------- 1 | @import '~base/sass/base/variables'; 2 | @import '~base/sass/base/functions'; 3 | @import '~base/sass/base/grid'; 4 | 5 | .courses-list { 6 | margin: calcRem(20) 0; 7 | grid-column-end: span 4; 8 | 9 | @media (max-width: $tablet-breakpoint) { 10 | grid-column-end: span 2; 11 | } 12 | 13 | .header { 14 | display: flex; 15 | margin-bottom: calcRem(20); 16 | } 17 | 18 | .header-title { 19 | font-weight: bold; 20 | font-size: calcRem(24); 21 | padding-left: calcRem(20); 22 | line-height: 100%; 23 | } 24 | 25 | .sort-container { 26 | display: flex; 27 | margin-left: auto; 28 | padding-right: calcRem(10); 29 | font-size: calcRem(12); 30 | color: lighten($base-text-color, 30%); 31 | 32 | span { 33 | margin: calcRem(12) calcRem(10) 0 calcRem(10); 34 | } 35 | 36 | ul { 37 | padding: 0; 38 | margin: 0; 39 | display: flex; 40 | } 41 | 42 | .sort-item { 43 | list-style-type: none; 44 | font-size: calcRem(14); 45 | margin: calcRem(10) calcRem(10) 0 calcRem(10); 46 | color: $base-text-color; 47 | transition: 0.2s ease-in-out; 48 | cursor: pointer; 49 | 50 | &.active, &:hover { 51 | color: $primary-color; 52 | } 53 | 54 | &.active { 55 | font-weight: bold; 56 | } 57 | } 58 | } 59 | 60 | .items-container { 61 | display: grid; 62 | grid-gap: calcRem(20); 63 | grid-auto-flow: dense; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /frontend/src/components/header-views/header-content-csv-reports/HeaderContentCsvReports.js: -------------------------------------------------------------------------------- 1 | 2 | import React, { Component } from 'react'; 3 | import classNames from 'classnames/bind'; 4 | import styles from './_header-content-csv-reports.scss'; 5 | 6 | let cx = classNames.bind(styles); 7 | 8 | class HeaderContentCsvReports extends Component { 9 | render() { 10 | 11 | return ( 12 |
13 |
14 |
CSV Downloadable Reports
15 |
Download sets of your site data in CSV format.
16 | 17 |
18 |
19 | ); 20 | } 21 | } 22 | 23 | HeaderContentCsvReports.defaultProps = { 24 | 25 | } 26 | 27 | export default HeaderContentCsvReports; 28 | -------------------------------------------------------------------------------- /frontend/src/components/header-views/header-content-csv-reports/_header-content-csv-reports.scss: -------------------------------------------------------------------------------- 1 | @import '~base/sass/base/variables'; 2 | @import '~base/sass/base/functions'; 3 | @import '~base/sass/base/grid'; 4 | @import '~base/sass/header-views/header-common'; 5 | 6 | .header-content-reports-list { 7 | display: flex; 8 | align-items: flex-end; 9 | padding: calcRem(50) 0 calcRem(30); 10 | 11 | .title-container { 12 | flex-shrink: 1; 13 | flex-grow: 1; 14 | 15 | .title-text { 16 | font-weight: 900; 17 | color: pick-visible-color($primary-color, #fff, $base-text-color); 18 | font-size: calcRem(48); 19 | line-height: 120%; 20 | } 21 | 22 | .subtitle-text { 23 | font-weight: normal; 24 | color: pick-visible-color($primary-color, #fff, $base-text-color); 25 | font-size: calcRem(18); 26 | line-height: 150%; 27 | margin-top: calcRem(10); 28 | } 29 | 30 | .title-decoration { 31 | display: block; 32 | width: calcRem(100); 33 | height: calcRem(2); 34 | background-color: pick-visible-color($primary-color, #fff, $base-text-color); 35 | margin: calcRem(20) 0 0; 36 | } 37 | } 38 | 39 | .options-container { 40 | flex-shrink: 0; 41 | flex-grow: 0; 42 | margin-left: auto; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /frontend/src/components/header-views/header-content-reports-list/HeaderContentReportsList.js: -------------------------------------------------------------------------------- 1 | 2 | import React, { Component } from 'react'; 3 | import classNames from 'classnames/bind'; 4 | import styles from './_header-content-reports-list.scss'; 5 | 6 | let cx = classNames.bind(styles); 7 | 8 | class HeaderContentReportsList extends Component { 9 | render() { 10 | 11 | return ( 12 |
13 |
14 |
Reports list
15 | 16 |
17 |
18 | 19 |
20 |
21 | ); 22 | } 23 | } 24 | 25 | HeaderContentReportsList.defaultProps = { 26 | 27 | } 28 | 29 | export default HeaderContentReportsList; 30 | -------------------------------------------------------------------------------- /frontend/src/components/header-views/header-content-reports-list/_header-content-reports-list.scss: -------------------------------------------------------------------------------- 1 | @import '~base/sass/base/variables'; 2 | @import '~base/sass/base/functions'; 3 | @import '~base/sass/base/grid'; 4 | @import '~base/sass/header-views/header-common'; 5 | 6 | .header-content-reports-list { 7 | display: flex; 8 | align-items: flex-end; 9 | padding: calcRem(50) 0 calcRem(30); 10 | 11 | .title-container { 12 | flex-shrink: 1; 13 | flex-grow: 1; 14 | 15 | .title-text { 16 | font-weight: 900; 17 | color: pick-visible-color($primary-color, #fff, $base-text-color); 18 | font-size: calcRem(48); 19 | line-height: 120%; 20 | } 21 | 22 | .title-decoration { 23 | display: block; 24 | width: calcRem(100); 25 | height: calcRem(2); 26 | background-color: pick-visible-color($primary-color, #fff, $base-text-color); 27 | margin: calcRem(20) 0 0; 28 | } 29 | } 30 | 31 | .options-container { 32 | flex-shrink: 0; 33 | flex-grow: 0; 34 | margin-left: auto; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /frontend/src/components/header-views/header-content-static/HeaderContentStatic.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import classNames from 'classnames/bind'; 3 | import PropTypes from 'prop-types'; 4 | import styles from './_header-content-static.scss'; 5 | 6 | let cx = classNames.bind(styles); 7 | 8 | class HeaderContentStatic extends Component { 9 | render() { 10 | 11 | return ( 12 |
13 |

{this.props.title}

14 |

{this.props.subtitle}

15 |
16 | ); 17 | } 18 | } 19 | 20 | HeaderContentStatic.defaultProps = { 21 | title: 'Static page header', 22 | subtitle: 'Static page header subtitle.' 23 | } 24 | 25 | HeaderContentStatic.propTypes = { 26 | title: PropTypes.string, 27 | subtitle: PropTypes.string 28 | }; 29 | 30 | export default HeaderContentStatic 31 | -------------------------------------------------------------------------------- /frontend/src/components/header-views/header-content-static/_header-content-static.scss: -------------------------------------------------------------------------------- 1 | @import '~base/sass/base/variables'; 2 | @import '~base/sass/base/functions'; 3 | @import '~base/sass/base/grid'; 4 | 5 | .header-content-static { 6 | margin-bottom: calcRem(60); 7 | position: relative; 8 | padding-bottom: calcRem(40); 9 | 10 | .title { 11 | font-weight: 900; 12 | color: pick-visible-color($primary-color, #fff, $base-text-color); 13 | font-size: calcRem(48); 14 | line-height: 120%; 15 | margin: calcRem(20) 0 calcRem(10); 16 | } 17 | 18 | .subtitle { 19 | font-weight: normal; 20 | color: pick-visible-color($primary-color, #fff, $base-text-color); 21 | font-size: calcRem(18); 22 | line-height: 150%; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/components/header-views/header-content-user/HeaderContentUser.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import styles from './_header-content-user.scss'; 3 | 4 | class HeaderContentUser extends Component { 5 | render() { 6 | 7 | return ( 8 |
9 | {this.props.name} 10 |
11 | ); 12 | } 13 | } 14 | 15 | export default HeaderContentUser 16 | -------------------------------------------------------------------------------- /frontend/src/components/header-views/header-content-user/_header-content-user.scss: -------------------------------------------------------------------------------- 1 | @import '~base/sass/base/variables'; 2 | @import '~base/sass/base/functions'; 3 | 4 | .header-content-user { 5 | margin-bottom: calcRem(60); 6 | position: relative; 7 | height: calcRem(80); 8 | 9 | .user-image { 10 | width: calcRem(120); 11 | height: calcRem(120); 12 | display: block; 13 | border-radius: 100%; 14 | overflow: hidden; 15 | position: absolute; 16 | top: calcRem(10); 17 | left: 50%; 18 | margin-left: calcRem(-60); 19 | border: calcRem(8) solid rgba($base-text-color, 0.1); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/components/inputs/_list-search.scss: -------------------------------------------------------------------------------- 1 | @import '~base/sass/base/variables'; 2 | @import '~base/sass/base/functions'; 3 | @import '~base/sass/base/mixins'; 4 | 5 | .list-search { 6 | display: block; 7 | padding: calcRem(15) 0; 8 | 9 | .inner-container { 10 | padding: calcRem(15) 0; 11 | width: 100%; 12 | max-width: calcRem(420); 13 | border-bottom: 1px solid #ccc; 14 | display: flex; 15 | align-items: center; 16 | 17 | &.active { 18 | border-bottom: 2px solid $primary-color; 19 | } 20 | } 21 | 22 | .search-icon { 23 | font-size: calcRem(14); 24 | margin-right: calcRem(10); 25 | color: #ccc; 26 | flex-grow: 0; 27 | flex-shrink: 0; 28 | } 29 | 30 | .list-search-input { 31 | border: none; 32 | font-size: calcRem(14); 33 | flex: 1 1 100%; 34 | outline: none; 35 | 36 | &:focus { 37 | outline: none; 38 | } 39 | } 40 | 41 | .clear-button { 42 | border: none; 43 | flex-grow: 0; 44 | flex-shrink: 0; 45 | 46 | &:hover { 47 | cursor: pointer; 48 | } 49 | } 50 | 51 | .clear-icon { 52 | font-size: calcRem(14); 53 | border: none; 54 | color: $primary-color; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /frontend/src/components/layout/HeaderAreaLayout.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import classNames from 'classnames/bind'; 3 | import PropTypes from 'prop-types'; 4 | import styles from './_header-area-layout.scss'; 5 | import HeaderNav from 'base/components/layout/HeaderNav'; 6 | import { NavLink } from 'react-router-dom'; 7 | import FiguresLogo from './FiguresLogo'; 8 | 9 | let cx = classNames.bind(styles); 10 | 11 | class HeaderAreaLayout extends Component { 12 | 13 | render() { 14 | 15 | return ( 16 |
17 |
18 | 22 | 23 | 24 | 25 |
26 | {this.props.children} 27 |
28 | ); 29 | } 30 | } 31 | 32 | HeaderAreaLayout.propTypes = { 33 | children: PropTypes.node, 34 | } 35 | 36 | export default HeaderAreaLayout; 37 | -------------------------------------------------------------------------------- /frontend/src/components/layout/HeaderNav.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { NavLink } from 'react-router-dom'; 3 | import styles from './_header-nav.scss'; 4 | import AutoCompleteCourseSelect from 'base/components/inputs/AutoCompleteCourseSelect'; 5 | import AutoCompleteUserSelect from 'base/components/inputs/AutoCompleteUserSelect'; 6 | 7 | class HeaderNav extends Component { 8 | 9 | render() { 10 | return ( 11 |
12 | 16 | Overview 17 | 18 | 22 | MAU History 23 | 24 | 28 | Users 29 | 30 | 34 | Courses 35 | 36 | 40 | Learners Progress Overview 41 | 42 | {(process.env.ENABLE_CSV_REPORTS === "enabled") && ( 43 | 47 | CSV Reports 48 | 49 | )} 50 | 54 | 58 |
59 | ); 60 | } 61 | } 62 | 63 | export default HeaderNav 64 | -------------------------------------------------------------------------------- /frontend/src/components/layout/_header-area-layout.scss: -------------------------------------------------------------------------------- 1 | @import '~base/sass/base/variables'; 2 | @import '~base/sass/base/functions'; 3 | @import '~base/sass/base/grid'; 4 | 5 | 6 | .header-area { 7 | background-color: $primary-color; 8 | color: pick-visible-color($primary-color, #fff, $base-text-color); 9 | position: relative; 10 | padding: 0 calcRem(20); 11 | 12 | &:before { 13 | background: linear-gradient(to bottom, rgba(0,0,0,0.4) 0%, rgba(0,0,0,0) 100%); 14 | width: 100%; 15 | left: 0; 16 | top: 0; 17 | height: calcRem(15); 18 | display: block; 19 | opacity: 0.2; 20 | content: ''; 21 | } 22 | } 23 | 24 | .header-top { 25 | display: flex; 26 | align-items: center; 27 | padding: calcRem(5) 0 calcRem(20); 28 | } 29 | 30 | .header-logo-container { 31 | 32 | img { 33 | height: calcRem(60); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/components/layout/_header-nav.scss: -------------------------------------------------------------------------------- 1 | @import '~base/sass/base/variables'; 2 | @import '~base/sass/base/functions'; 3 | 4 | .header-nav { 5 | margin-left: auto; 6 | display: flex; 7 | align-items: center; 8 | } 9 | 10 | a.header-nav__link, a.header-nav__link:not(.btn) { 11 | margin-right: calcRem(30); 12 | font-size: $base-font-size; 13 | font-weight: 600; 14 | color: pick-visible-color($primary-color, #fff, $base-text-color) !important; 15 | text-decoration: none; 16 | position: relative; 17 | 18 | &:after { 19 | position: absolute; 20 | bottom: calcRem(-10); 21 | height: calcRem(2); 22 | width: 0; 23 | left: 0; 24 | background-color: pick-visible-color($primary-color, #fff, $base-text-color); 25 | content: ''; 26 | transition: all 0.2s ease-in-out; 27 | } 28 | 29 | &:hover { 30 | color: pick-visible-color($primary-color, #fff, $base-text-color); 31 | 32 | &:after { 33 | width: 100%; 34 | } 35 | } 36 | 37 | &:focus { 38 | color: pick-visible-color($primary-color, #fff, $base-text-color); 39 | } 40 | 41 | &.active { 42 | font-weight: 900; 43 | opacity: 0.6; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /frontend/src/components/layout/_paginator.scss: -------------------------------------------------------------------------------- 1 | @import '~base/sass/base/variables'; 2 | @import '~base/sass/base/functions'; 3 | @import '~base/sass/base/grid'; 4 | 5 | 6 | .paginator { 7 | padding-top: calcRem(15); 8 | padding-bottom: calcRem(15); 9 | display: flex; 10 | align-items: center; 11 | width: 100%; 12 | 13 | .dropdown-controls-container { 14 | display: flex; 15 | align-items: center; 16 | margin-left: auto; 17 | 18 | .paginator-dropdown-label { 19 | flex-shrink: 0; 20 | flex-grow: 0; 21 | display: inline-block; 22 | margin-right: calcRem(10); 23 | font-size: calcRem(12); 24 | color: #666; 25 | } 26 | } 27 | 28 | .dropdown-inner-container { 29 | width: calcRem(100); 30 | } 31 | 32 | .number-controls-container { 33 | display: flex; 34 | align-items: center; 35 | } 36 | 37 | .text-item { 38 | display: block; 39 | padding: 1rem; 40 | font-size: calcRem(14); 41 | color: $primary-color; 42 | margin-right: calcRem(5); 43 | border: none; 44 | background: none; 45 | outline: none; 46 | 47 | &:hover { 48 | opacity: 0.6; 49 | cursor: pointer; 50 | } 51 | } 52 | 53 | .text-item-dummy { 54 | display: block; 55 | padding: calcRem(10); 56 | font-size: calcRem(14); 57 | color: #ccc; 58 | pointer-events: none; 59 | margin-right: calcRem(5); 60 | } 61 | 62 | .ellipsis-item { 63 | display: block; 64 | padding: calcRem(10); 65 | margin-right: calcRem(5); 66 | } 67 | 68 | .number-item { 69 | display: block; 70 | padding: calcRem(10); 71 | font-size: calcRem(14); 72 | color: $primary-color; 73 | margin-right: calcRem(5); 74 | border: none; 75 | background: none; 76 | outline: none; 77 | 78 | &:hover { 79 | opacity: 0.6; 80 | cursor: pointer; 81 | } 82 | } 83 | 84 | .number-item-active { 85 | display: block; 86 | padding: calcRem(10); 87 | font-size: calcRem(14); 88 | color: #ccc; 89 | pointer-events: none; 90 | margin-right: calcRem(5); 91 | border: none; 92 | background: none; 93 | outline: none; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /frontend/src/components/learner-statistics/_learner-statistics.scss: -------------------------------------------------------------------------------- 1 | @import '~base/sass/base/variables'; 2 | @import '~base/sass/base/functions'; 3 | @import '~base/sass/base/grid'; 4 | @import '~base/sass/base/stat-cards'; 5 | 6 | .courses-list { 7 | margin: calcRem(20) 0; 8 | grid-column-end: span 4; 9 | 10 | @media (max-width: $tablet-breakpoint) { 11 | grid-column-end: span 2; 12 | } 13 | 14 | .header { 15 | display: flex; 16 | margin-bottom: calcRem(20); 17 | } 18 | 19 | .header-title { 20 | font-weight: bold; 21 | font-size: calcRem(24); 22 | padding-left: calcRem(20); 23 | line-height: 100%; 24 | } 25 | 26 | .dropdown-container { 27 | display: flex; 28 | align-items: center; 29 | margin-left: auto; 30 | padding-right: calcRem(10); 31 | font-size: calcRem(14); 32 | 33 | &>span { 34 | font-size: calcRem(12); 35 | color: lighten($base-text-color, 30%); 36 | margin: 0 calcRem(10) 0 0; 37 | } 38 | 39 | &>div { 40 | width: calcRem(160); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /frontend/src/components/stat-cards/_base-stat-card.scss: -------------------------------------------------------------------------------- 1 | @import '~base/sass/base/variables'; 2 | @import '~base/sass/base/functions'; 3 | @import '~base/sass/base/grid'; 4 | @import '~base/sass/base/stat-cards'; 5 | 6 | .stat-card { 7 | 8 | .main-content { 9 | 10 | .main-data-container { 11 | display: flex; 12 | align-items: center; 13 | color: $primary-color; 14 | padding: calcRem(20) 0; 15 | margin-top: auto; 16 | min-height: calcRem(90); 17 | box-sizing: border-box; 18 | 19 | .current-data { 20 | font-weight: 900; 21 | font-size: calcRem(32); 22 | line-height: 100%; 23 | height: 100%; 24 | } 25 | 26 | .previous-comparison { 27 | font-weight: normal; 28 | margin-left: calcRem(15); 29 | padding: calcRem(5) 0 calcRem(5) calcRem(15); 30 | border-left: calcRem(1) solid $primary-color; 31 | } 32 | 33 | .comparison-value { 34 | font-weight: bold; 35 | font-size: calcRem(18); 36 | display: block; 37 | } 38 | 39 | .comparison-text { 40 | font-size: calcRem(12); 41 | display: block; 42 | } 43 | } 44 | 45 | .history-toggle { 46 | // override those pesky LMS styles 47 | background: none; 48 | text-shadow: none; 49 | font: inherit; 50 | text-transform: none; 51 | letter-spacing: normal; 52 | // 53 | color: lighten($base-text-color, 50%); 54 | font-size: calcRem(14); 55 | font-weight: bold; 56 | border: none; 57 | outline: none; 58 | box-shadow: none; 59 | cursor: pointer; 60 | padding: 0; 61 | text-align: left; 62 | 63 | &:hover { 64 | color: $primary-color; 65 | } 66 | } 67 | 68 | .history-toggle-faux { 69 | height: calcRem(19); 70 | } 71 | } 72 | 73 | .history-content { 74 | flex-shrink: 1; 75 | flex-grow: 1; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /frontend/src/components/stat-graphs/stat-bar-graph/StatBarGraph.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import styles from './_stat-bar-graph.scss'; 3 | import { ResponsiveContainer, BarChart, Bar, Tooltip, CartesianGrid, YAxis, XAxis } from 'recharts'; 4 | 5 | 6 | class CustomTooltip extends Component { 7 | 8 | render() { 9 | const { active } = this.props; 10 | 11 | if (active) { 12 | const { payload } = this.props; 13 | return ( 14 |
15 | 16 | {(this.props.dataType === 'percentage') ? (payload[0].value)*100 : payload[0].value} 17 | {(this.props.dataType === 'percentage') && '%'} 18 | 19 |
20 | ); 21 | } 22 | 23 | return null; 24 | } 25 | } 26 | 27 | class StatBarGraph extends Component { 28 | render() { 29 | const yAxisTickFormatter = (value) => { 30 | if (this.props.dataType === 'percentage') { 31 | return `${value*100}%`; 32 | } else { 33 | return `${value}`; 34 | } 35 | } 36 | 37 | const xAxisTickFormatter = (value) => { 38 | return `${value}`; 39 | } 40 | 41 | return ( 42 | 43 | 48 | 51 | } 53 | cursor={{ fill: 'rgba(255, 255, 255, 0.15)'}} 54 | offset={0} 55 | /> 56 | 57 | 58 | 59 | 60 | 61 | ); 62 | } 63 | } 64 | 65 | StatBarGraph.defaultProps = { 66 | graphHeight: 140, 67 | dataType: 'number', 68 | } 69 | 70 | export default StatBarGraph; 71 | -------------------------------------------------------------------------------- /frontend/src/components/stat-graphs/stat-bar-graph/StatHorizontalBarGraph.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import styles from './_stat-horizontal-bar-graph.scss'; 3 | 4 | class StatHorizontalBarGraph extends Component { 5 | 6 | render() { 7 | let maxValue = 0; 8 | 9 | if (this.props.dataType !== 'percentage') { 10 | this.props.data.forEach((dataSingle, index) => { 11 | if (dataSingle[this.props.valueLabel] > maxValue) { 12 | maxValue = dataSingle[this.props.valueLabel]; 13 | } 14 | }); 15 | }; 16 | 17 | const graphRender = this.props.data.map((dataSingle, index) => { 18 | const barWidth = (this.props.dataType === 'percentage') ? (dataSingle[this.props.valueLabel]*100 + '%') : (dataSingle[this.props.valueLabel]/maxValue*100 + '%'); 19 | 20 | return ( 21 |
22 |
23 | {dataSingle[this.props.labelLabel]} 24 | {(this.props.dataType === 'percentage') ? (dataSingle[this.props.valueLabel]*100 + '%') : dataSingle[this.props.valueLabel]} 25 |
26 |
27 | 28 |
29 |
30 | ); 31 | }); 32 | 33 | return ( 34 |
35 | {graphRender} 36 |
37 | ); 38 | } 39 | } 40 | 41 | StatHorizontalBarGraph.defaultProps = { 42 | graphHeight: 140, 43 | dataType: 'number', 44 | valueLabel: 'value', 45 | labelLabel: 'label', 46 | } 47 | 48 | export default StatHorizontalBarGraph; 49 | -------------------------------------------------------------------------------- /frontend/src/components/stat-graphs/stat-bar-graph/_stat-bar-graph.scss: -------------------------------------------------------------------------------- 1 | @import '~base/sass/base/variables'; 2 | @import '~base/sass/base/functions'; 3 | @import '~base/sass/base/grid'; 4 | 5 | .stat-bar-graph { 6 | 7 | .stat-bar { 8 | fill: $primary-color; 9 | } 10 | 11 | :global(.recharts-cartesian-axis-tick) { 12 | font-size: calcRem(10); 13 | fill: lighten($base-text-color, 30%); 14 | } 15 | } 16 | 17 | .bar-tooltip { 18 | position: relative; 19 | left: calcRem(-40); 20 | background-color: $base-text-color; 21 | color: #fff; 22 | width: calcRem(60); 23 | border-radius: calcRem(5); 24 | padding: calcRem(10); 25 | text-align: center; 26 | 27 | &:after { 28 | top: 100%; 29 | left: 50%; 30 | border: solid transparent; 31 | content: " "; 32 | height: 0; 33 | width: 0; 34 | position: absolute; 35 | pointer-events: none; 36 | border-color: rgba(136, 183, 213, 0); 37 | border-top-color: $base-text-color; 38 | border-width: calcRem(10); 39 | margin-left: calcRem(-10); 40 | } 41 | 42 | .tooltip-value { 43 | font-size: calcRem(14); 44 | font-weight: bold; 45 | } 46 | 47 | p { 48 | margin: 0; 49 | font-size: calcRem(10); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /frontend/src/components/stat-graphs/stat-bar-graph/_stat-horizontal-bar-graph.scss: -------------------------------------------------------------------------------- 1 | @import '~base/sass/base/variables'; 2 | @import '~base/sass/base/functions'; 3 | @import '~base/sass/base/grid'; 4 | 5 | .horizontal-bar-chart { 6 | width: 100%; 7 | } 8 | 9 | .horizontal-bar-wrapper { 10 | display: flex; 11 | align-items: center; 12 | width: 100%; 13 | margin: calcRem(10) 0; 14 | 15 | .label-container { 16 | margin-right: calcRem(10); 17 | display: flex; 18 | align-items: center; 19 | width: calcRem(260); 20 | flex-shrink: 0; 21 | font-size: calcRem(14); 22 | color: $base-text-color; 23 | 24 | .label-text { 25 | width: calcRem(200); 26 | margin-right: calcRem(10); 27 | text-align: left; 28 | } 29 | 30 | .label-value { 31 | width: calcRem(50); 32 | text-align: center; 33 | font-weight: bold; 34 | } 35 | } 36 | 37 | .bar-container { 38 | display: flex; 39 | height: calcRem(6); 40 | border-radius: calcRem(6);; 41 | width: 100%; 42 | background-color: #D8D8D8; 43 | 44 | .bar-line { 45 | background-color: $primary-color; 46 | border-radius: calcRem(6); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /frontend/src/containers/loading-spinner/LoadingSpinner.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styles from './_loading-spinner.scss'; 3 | import { HashLoader } from 'react-spinners'; 4 | import { usePromiseTracker } from "react-promise-tracker"; 5 | import classNames from 'classnames/bind'; 6 | 7 | let cx = classNames.bind(styles); 8 | 9 | const appsemblerBlue = '#0090c1'; 10 | 11 | const LoadingSpinner = props => { 12 | 13 | const { promiseInProgress } = usePromiseTracker(); 14 | 15 | return ( 16 |
17 | {promiseInProgress && ( 18 |
19 |
20 | 23 | Loading your data... 24 |
25 |
26 | )} 27 |
28 | {props.children} 29 |
30 |
31 | ) 32 | } 33 | 34 | export default LoadingSpinner; 35 | -------------------------------------------------------------------------------- /frontend/src/containers/loading-spinner/_loading-spinner.scss: -------------------------------------------------------------------------------- 1 | @import '~base/sass/base/variables'; 2 | @import '~base/sass/base/functions'; 3 | 4 | .loading-spinner-root-container { 5 | 6 | .spinner-container { 7 | position: fixed; 8 | top: 0; 9 | bottom: 0; 10 | left: 0; 11 | right: 0; 12 | display: flex; 13 | align-items: center; 14 | justify-content: center; 15 | background-color: rgba(#fff, 0.7); 16 | z-index: 100; 17 | 18 | &__content { 19 | display: flex; 20 | flex-wrap: wrap; 21 | align-items: center; 22 | justify-content: center; 23 | 24 | span { 25 | display: block; 26 | margin-top: calcRem(50); 27 | font-size: calcRem(14); 28 | text-align: center; 29 | width: 100%; 30 | } 31 | } 32 | } 33 | 34 | .main-content { 35 | 36 | &.blurred { 37 | overflow: hidden; 38 | filter: blur(5px); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /frontend/src/images/logo/figures--logo--icon--negative.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 10 | 12 | 13 | 14 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | import { ConnectedRouter } from 'react-router-redux'; 5 | import store, { history } from './redux/store'; 6 | import App from './App'; 7 | import './index.css'; 8 | 9 | ReactDOM.render( 10 | 11 | 12 | 13 | 14 | , 15 | document.getElementById('edx-figures-app') 16 | ) 17 | -------------------------------------------------------------------------------- /frontend/src/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { plugins: [require('autoprefixer')] }; 2 | -------------------------------------------------------------------------------- /frontend/src/redux/actions/ActionTypes.js: -------------------------------------------------------------------------------- 1 | export const SET_USER_ID = 'SET_USER_ID' 2 | export const UPDATE_REPORT_NAME = 'UPDATE_REPORT_NAME' 3 | export const UPDATE_REPORT_DESCRIPTION = 'UPDATE_REPORT_DESCRIPTION' 4 | export const UPDATE_REPORT_CARDS = 'UPDATE_REPORT_CARDS' 5 | export const SAVE_REPORT = 'SAVE_REPORT' 6 | export const REQUEST_REPORT = 'REQUEST_REPORT' 7 | export const FETCH_REPORT = 'FETCH_REPORT' 8 | export const LOAD_REPORT = 'LOAD_REPORT' 9 | export const REQUEST_REPORTS_LIST = 'REQUEST_REPORTS_LIST' 10 | export const LOAD_REPORTS_LIST = 'LOAD_REPORTS_LIST' 11 | 12 | export const LOAD_ACTIVE_USERS_GENERAL_DATA = 'LOAD_ACTIVE_USERS_GENERAL_DATA' 13 | export const LOAD_SITE_COURSES_GENERAL_DATA = 'LOAD_SITE_COURSES_GENERAL_DATA' 14 | export const LOAD_COURSE_ENROLLMENTS_GENERAL_DATA = 'LOAD_COURSE_ENROLLMENTS_GENERAL_DATA' 15 | export const LOAD_REGISTERED_USERS_GENERAL_DATA = 'LOAD_REGISTERED_USERS_GENERAL_DATA' 16 | export const LOAD_NEW_USERS_GENERAL_DATA = 'LOAD_NEW_USERS_GENERAL_DATA' 17 | export const LOAD_COURSE_COMPLETIONS_GENERAL_DATA = 'LOAD_COURSE_COMPLETIONS_GENERAL_DATA' 18 | 19 | export const LOAD_CSV_REPORTS_DATA = 'LOAD_CSV_REPORTS_DATA' 20 | export const LOAD_CSV_USER_REPORTS_DATA = 'LOAD_CSV_USER_REPORTS_DATA' 21 | export const LOAD_CSV_GRADE_REPORTS_DATA = 'LOAD_CSV_GRADE_REPORTS_DATA' 22 | export const LOAD_CSV_COURSE_METRICS_REPORTS_DATA = 'LOAD_CSV_COURSE_METRICS_REPORTS_DATA' 23 | -------------------------------------------------------------------------------- /frontend/src/redux/reducers/csvReportsIndexReducers.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | import { LOAD_CSV_USER_REPORTS_DATA, LOAD_CSV_GRADE_REPORTS_DATA, LOAD_CSV_COURSE_METRICS_REPORTS_DATA } from '../actions/ActionTypes'; 3 | 4 | const initialState = { 5 | receivedAt: '', 6 | csvUserReports: Immutable.List(), 7 | csvGradeReports: Immutable.List(), 8 | csvCourseMetrics: Immutable.List(), 9 | } 10 | 11 | const cxvReportsIndex = (state = initialState, action) => { 12 | switch (action.type) { 13 | case LOAD_CSV_USER_REPORTS_DATA: 14 | return Object.assign({}, state, { 15 | receivedAt: action.receivedAt, 16 | csvUserReports: Immutable.fromJS(action.fetchedData) 17 | }) 18 | case LOAD_CSV_GRADE_REPORTS_DATA: 19 | return Object.assign({}, state, { 20 | receivedAt: action.receivedAt, 21 | csvGradeReports: Immutable.fromJS(action.fetchedData) 22 | }) 23 | case LOAD_CSV_COURSE_METRICS_REPORTS_DATA: 24 | return Object.assign({}, state, { 25 | receivedAt: action.receivedAt, 26 | csvCourseMetrics: Immutable.fromJS(action.fetchedData) 27 | }) 28 | default: 29 | return state 30 | } 31 | } 32 | 33 | export default cxvReportsIndex 34 | -------------------------------------------------------------------------------- /frontend/src/redux/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import { routerReducer } from 'react-router-redux'; 3 | import userData from './userDataReducers'; 4 | import report from './reportReducers'; 5 | import reportsList from './reportsListReducers'; 6 | import generalData from './generalDataReducers'; 7 | import csvReportsIndex from './csvReportsIndexReducers'; 8 | 9 | export default combineReducers({ 10 | userData, 11 | reportsList, 12 | report, 13 | generalData, 14 | csvReportsIndex, 15 | routing: routerReducer 16 | }) 17 | -------------------------------------------------------------------------------- /frontend/src/redux/reducers/reportReducers.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | import { UPDATE_REPORT_NAME, UPDATE_REPORT_DESCRIPTION, UPDATE_REPORT_CARDS, REQUEST_REPORT, LOAD_REPORT } from '../actions/ActionTypes'; 3 | 4 | const initialState = { 5 | isFetching: false, 6 | reportId: '', 7 | receivedAt: '', 8 | reportName: '', 9 | reportDescription: '', 10 | dateCreated: '', 11 | reportAuthor: '', 12 | dataStartDate: '', 13 | dataEndDate: '', 14 | reportCarts: Immutable.List(), 15 | } 16 | 17 | const report = (state = initialState, action) => { 18 | switch (action.type) { 19 | case UPDATE_REPORT_NAME: 20 | return Object.assign({}, state, { 21 | reportName: action.newName 22 | }) 23 | case UPDATE_REPORT_DESCRIPTION: 24 | return Object.assign({}, state, { 25 | reportDescription: action.newDescription 26 | }) 27 | case UPDATE_REPORT_CARDS: 28 | return Object.assign({}, state, { 29 | reportCarts: action.newCards 30 | }) 31 | case REQUEST_REPORT: 32 | return Object.assign({}, state, { 33 | isFetching: true 34 | }) 35 | case LOAD_REPORT: 36 | return Object.assign({}, state, { 37 | isFetching: false, 38 | receivedAt: action.receivedAt, 39 | reportName: action.reportData.reportName, 40 | reportDescription: action.reportData.reportDescription, 41 | dateCreated: action.reportData.dateCreated, 42 | reportAuthor: action.reportData.reportAuthor, 43 | dataStartDate: action.reportData.dataStartDate, 44 | dataEndDate: action.reportData.dataEndDate, 45 | reportCarts: Immutable.List(action.reportData.reportCarts), 46 | }) 47 | default: 48 | return state 49 | } 50 | } 51 | 52 | export default report 53 | -------------------------------------------------------------------------------- /frontend/src/redux/reducers/reportsListReducers.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | import { LOAD_REPORTS_LIST } from '../actions/ActionTypes'; 3 | 4 | const initialState = { 5 | reportsData: Immutable.List(), 6 | } 7 | 8 | const reportsList = (state = initialState, action) => { 9 | switch (action.type) { 10 | case LOAD_REPORTS_LIST: 11 | return Object.assign({}, state, { 12 | isFetching: false, 13 | reportsData: Immutable.List(action.reportsData), 14 | }) 15 | default: 16 | return state 17 | } 18 | } 19 | 20 | export default reportsList 21 | -------------------------------------------------------------------------------- /frontend/src/redux/reducers/userDataReducers.js: -------------------------------------------------------------------------------- 1 | import { SET_USER_ID } from '../actions/ActionTypes'; 2 | 3 | const initialState = { 4 | userId: '', 5 | } 6 | 7 | const userData = (state = initialState, action) => { 8 | switch (action.type) { 9 | case SET_USER_ID: 10 | return Object.assign({}, state, { 11 | userId: action.userId 12 | }) 13 | default: 14 | return state 15 | } 16 | } 17 | 18 | export default userData 19 | -------------------------------------------------------------------------------- /frontend/src/redux/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose } from 'redux'; 2 | import { routerMiddleware } from 'react-router-redux'; 3 | import thunk from 'redux-thunk'; 4 | import createHistory from 'history/createBrowserHistory'; 5 | import rootReducer from './reducers'; 6 | 7 | export const history = createHistory(); 8 | 9 | const initialState = {}; 10 | const enhancers = []; 11 | const middleware = [ 12 | thunk, 13 | routerMiddleware(history) 14 | ]; 15 | 16 | if (process.env.NODE_ENV === 'development') { 17 | const devToolsExtension = window.devToolsExtension; 18 | 19 | if (typeof devToolsExtension === 'function') { 20 | enhancers.push(devToolsExtension()); 21 | } 22 | } 23 | 24 | const composedEnhancers = compose( 25 | applyMiddleware(...middleware), 26 | ...enhancers 27 | ); 28 | 29 | const store = createStore( 30 | rootReducer, 31 | initialState, 32 | composedEnhancers 33 | ) 34 | 35 | export default store 36 | -------------------------------------------------------------------------------- /frontend/src/sass/base/_base-overrides.scss: -------------------------------------------------------------------------------- 1 | // Most of this is just to get the pesky default Open edX stylesheed with its superbroad element targeting out of the way. 2 | 3 | 4 | :global { 5 | 6 | .edx-figures-app { 7 | input { 8 | background: none; 9 | box-shadow: none; 10 | text-shadow: none; 11 | border: none; 12 | outline: none; 13 | letter-spacing: inherit; 14 | font: inherit; 15 | height: auto; 16 | padding: calcRem(5); 17 | 18 | &:focus { 19 | box-shadow: none; 20 | } 21 | } 22 | 23 | input[type="submit"]:hover:not(:disabled), input[type="button"]:hover:not(:disabled), button { 24 | background: none; 25 | box-shadow: none; 26 | text-shadow: none; 27 | border: none; 28 | outline: none; 29 | 30 | &:hover { 31 | background-image: none; 32 | background-color: unset; 33 | } 34 | } 35 | 36 | input[type="submit"]:active:not(:disabled), input[type="submit"]:focus:not(:disabled), input[type="button"]:active:not(:disabled), input[type="button"]:focus:not(:disabled), button:active:not(:disabled), button:focus:not(:disabled), .button:active:not(:disabled), button { 37 | box-shadow: none; 38 | letter-spacing: normal; 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /frontend/src/sass/base/_functions.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Picks a colour from two options based on which one is more visible 3 | * on the given background colour. 4 | * 5 | * Usage: pick-visible-color($bg-color, $color-1, $color-2) 6 | */ 7 | 8 | @function luma($c) { 9 | $-local-red: red(rgba($c, 1.0)); 10 | $-local-green: green(rgba($c, 1.0)); 11 | $-local-blue: blue(rgba($c, 1.0)); 12 | 13 | @return (0.2126 * $-local-red + 14 | 0.7152 * $-local-green + 15 | 0.0722 * $-local-blue) / 255; 16 | } 17 | 18 | @function pick-visible-color($bg, $c1, $c2) { 19 | $bg-luma: luma($bg); 20 | $c1-luma: luma($c1); 21 | $c2-luma: luma($c2); 22 | 23 | $c1-diff: abs($bg-luma - $c1-luma); 24 | $c2-diff: abs($bg-luma - $c2-luma); 25 | 26 | @if $c1-diff > $c2-diff { 27 | @return $c1; 28 | } @else { 29 | @return $c2; 30 | } 31 | } 32 | 33 | @function calcRem($value-px) { 34 | @return ($value-px / $rem-base-size) + 0rem; 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/sass/base/_grid.scss: -------------------------------------------------------------------------------- 1 | @import '~base/sass/base/variables'; 2 | 3 | .container { 4 | width: 100%; 5 | max-width: $max-grid-width; 6 | margin: 0 auto; 7 | padding: 0 calcRem(20); 8 | box-sizing: border-box; 9 | } 10 | 11 | .layout-root { 12 | width: 100%; 13 | padding: 0; 14 | font-size: $rem-base-size + 0px; 15 | font-family: $font-family; 16 | -webkit-font-smoothing: antialiased; 17 | } 18 | 19 | .base-grid-layout { 20 | display: grid; 21 | grid-template-columns: repeat(4, 1fr); 22 | grid-gap: calcRem(20); 23 | grid-auto-flow: dense; 24 | 25 | @media (max-width: $tablet-breakpoint) { 26 | grid-template-columns: repeat(2, 1fr); 27 | } 28 | } 29 | 30 | .grid-element { 31 | 32 | &--full-width { 33 | grid-column-end: span 4; 34 | } 35 | 36 | &--three-quarter { 37 | grid-column-end: span 3; 38 | 39 | @media (max-width: $tablet-breakpoint) { 40 | grid-column-end: span 4; 41 | } 42 | } 43 | 44 | &--one-half { 45 | grid-column-end: span 2; 46 | 47 | @media (max-width: $mobile-breakpoint) { 48 | grid-column-end: span 4; 49 | } 50 | } 51 | 52 | &--one-quarter { 53 | grid-column-end: span 1; 54 | 55 | @media (max-width: $tablet-breakpoint) { 56 | grid-column-end: span 2; 57 | } 58 | 59 | @media (max-width: $mobile-breakpoint) { 60 | grid-column-end: span 4; 61 | } 62 | } 63 | } 64 | 65 | // transitions styles 66 | :global(.page-leave) { 67 | opacity: 1; 68 | transition: all 0.4s ease-in-out; 69 | } 70 | :global(.page-leave.page-leave-active) { 71 | opacity: 0; 72 | transition: opacity .4s ease-in; 73 | } 74 | 75 | :global(.page-enter) { 76 | opacity: 0; 77 | } 78 | :global(.page-enter.page-enter-active) { 79 | opacity: 1; 80 | /* Delay the enter animation until the leave completes */ 81 | transition: opacity .4s ease-in .6s; 82 | } 83 | 84 | :global(.page-height) { 85 | transition: height .6s ease-in-out; 86 | } 87 | -------------------------------------------------------------------------------- /frontend/src/sass/base/_mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin override-edx-styles { 2 | box-shadow: none; 3 | outline: none; 4 | border: none; 5 | background: none; 6 | border-radius: inherit; 7 | font: inherit; 8 | height: auto; 9 | width: auto; 10 | padding: inherit; 11 | letter-spacing: 0; 12 | text-shadow: none; 13 | text-transform: none; 14 | 15 | &:hover, &:hover:not(:disabled) { 16 | box-shadow: none; 17 | outline: none; 18 | border: none; 19 | background: none; 20 | border-radius: inherit; 21 | font: inherit; 22 | height: auto; 23 | width: auto; 24 | padding: inherit; 25 | letter-spacing: 0; 26 | text-shadow: none; 27 | text-transform: none; 28 | } 29 | } 30 | 31 | @mixin editable-content-tooltip { 32 | position: relative; 33 | 34 | &:after { 35 | content: ''; 36 | font-size: calcRem(12); 37 | line-height: 100%; 38 | font-weight: normal; 39 | border-radius: calcRem(30); 40 | background-color: $base-text-color; 41 | color: pick-visible-color($base-text-color, #fff, $primary-color); 42 | position: absolute; 43 | top: calcRem(10); 44 | padding: calcRem(5) calcRem(10); 45 | left: 0; 46 | opacity: 0; 47 | transition: none; 48 | } 49 | 50 | &:hover:after { 51 | content: 'Click to edit'; 52 | top: calcRem(-20); 53 | opacity: 1; 54 | transition: all 0.3s ease-in-out; 55 | } 56 | 57 | &:focus:after { 58 | content: 'Click anywhere outside the field to save'; 59 | top: calcRem(-20); 60 | opacity: 1; 61 | transition: all 0.3s ease-in-out; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /frontend/src/sass/base/_stat-cards.scss: -------------------------------------------------------------------------------- 1 | @import '~base/sass/base/variables'; 2 | @import '~base/sass/base/functions'; 3 | @import '~base/sass/base/grid'; 4 | 5 | .stat-card { 6 | border: calcRem(1) solid #efefef; 7 | background-color: #fff; 8 | box-shadow: 0 calcRem(2) calcRem(4) 0 rgba(0,0,0,0.25); 9 | padding: calcRem(20); 10 | transition: all 0.3s ease-in-out; 11 | display: flex; 12 | 13 | &.span-2 { 14 | grid-column-end: span 2; 15 | 16 | .main-content { 17 | width: 50%; 18 | } 19 | } 20 | 21 | &.span-3 { 22 | grid-column-end: span 3; 23 | 24 | .main-content { 25 | width: 33%; 26 | } 27 | } 28 | 29 | &.span-4 { 30 | grid-column-end: span 4; 31 | 32 | .main-content { 33 | width: 25%; 34 | } 35 | } 36 | 37 | &:hover { 38 | box-shadow: 0 calcRem(6) calcRem(12) 0 rgba(0,0,0,0.25); 39 | } 40 | 41 | .main-content { 42 | display: flex; 43 | flex-direction: column; 44 | flex-shrink: 0; 45 | flex-grow: 0; 46 | max-width: 100%; 47 | 48 | .card-title { 49 | display: block; 50 | font-weight: bold; 51 | color: $base-text-color; 52 | font-size: calcRem(16); 53 | margin: 0 0 calcRem(10); 54 | } 55 | 56 | .card-description { 57 | display: block; 58 | color: lighten($base-text-color, 10%); 59 | font-size: calcRem(11); 60 | margin: 0 0 calcRem(10); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /frontend/src/sass/base/_variables.scss: -------------------------------------------------------------------------------- 1 | @import '~base/sass/base/functions'; 2 | 3 | $rem-base-size: 16 !default; 4 | $base-font-size: calcRem(14) !default; 5 | $max-grid-width: calcRem(1180); 6 | $mobile-breakpoint: calcRem(420); 7 | $half-screen-breakpoint: calcRem(639); 8 | $tablet-breakpoint: calcRem(1020); 9 | 10 | $font-family: 'Open Sans', sans-serif; 11 | 12 | $primary-color: #0090c1 !default; 13 | $base-text-color: #2C2A2F !default; 14 | 15 | $positive-color: #27ae60; 16 | $negative-color: #c0392b; 17 | 18 | $elements-border-radius: calcRem(10); 19 | -------------------------------------------------------------------------------- /frontend/src/tempData/report.js: -------------------------------------------------------------------------------- 1 | export const testReportData = { 2 | "reportId": "MQR-12-12-17", 3 | "reportName": "My quarterly report", 4 | "reportDescription": "My report description goes here and it is just perfect!", 5 | "dateCreated": "12/12/2017", 6 | "reportAuthor": "Marty McFly", 7 | "dataStartDate": "", 8 | "dataEndDate": "", 9 | "reportCards": [ 10 | { 11 | "cardName": "TEST" 12 | } 13 | ] 14 | } 15 | 16 | export default testReportData 17 | -------------------------------------------------------------------------------- /frontend/src/tempData/reportsList.js: -------------------------------------------------------------------------------- 1 | export const testReportData = [ 2 | { 3 | "reportId": "MQR-12-12-17", 4 | "reportName": "My quarterly report", 5 | "reportDescription": "My report description goes here and it is just perfect!", 6 | "dateCreated": "12/12/2017", 7 | }, 8 | { 9 | "reportId": "MQR-03-12-18", 10 | "reportName": "My quarterly report 02", 11 | "reportDescription": "My another report description goes here and it is just perfect!", 12 | "dateCreated": "03/12/2017", 13 | } 14 | ] 15 | 16 | export default testReportData 17 | -------------------------------------------------------------------------------- /frontend/src/views/_dashboard-content.scss: -------------------------------------------------------------------------------- 1 | @import '~base/sass/base/variables'; 2 | @import '~base/sass/base/functions'; 3 | @import '~base/sass/base/grid'; 4 | 5 | .dashboard-content { 6 | margin-top: calcRem(40); 7 | padding-bottom: calcRem(50); 8 | } 9 | 10 | .functionality-callout { 11 | text-align: center; 12 | border-top: 1px solid #ccc; 13 | margin-top: calcRem(30); 14 | padding-top: calcRem(60); 15 | 16 | h3 { 17 | font-size: calcRem(20); 18 | text-align: center; 19 | max-width: calcRem(800); 20 | margin: 0 auto; 21 | } 22 | 23 | .functionality-callout-cta { 24 | display: inline-block; 25 | font-size: calcRem(16); 26 | padding: calcRem(15) calcRem(25); 27 | margin: calcRem(30) auto calcRem(50); 28 | font-weight: bold; 29 | color: $primary-color !important; 30 | border: calcRem(2) solid $primary-color; 31 | background: transparent; 32 | cursor: pointer; 33 | border-radius: calcRem(30); 34 | transition: all 0.3s ease-in-out; 35 | text-decoration: none; 36 | 37 | &:hover { 38 | color: #fff !important; 39 | background: $primary-color; 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /frontend/src/views/_mau-details-content.scss: -------------------------------------------------------------------------------- 1 | @import '~base/sass/base/variables'; 2 | @import '~base/sass/base/functions'; 3 | @import '~base/sass/base/grid'; 4 | @import '~base/sass/base/stat-cards'; 5 | 6 | .mau-details-content { 7 | margin-top: calcRem(40); 8 | padding-bottom: calcRem(50); 9 | } 10 | 11 | .mau-history-list { 12 | margin: calcRem(20) 0; 13 | grid-column-end: span 4; 14 | 15 | .header { 16 | display: flex; 17 | margin-bottom: calcRem(20); 18 | } 19 | 20 | .header-title { 21 | font-weight: bold; 22 | font-size: calcRem(24); 23 | padding-left: calcRem(20); 24 | line-height: 100%; 25 | } 26 | 27 | .mau-table-container { 28 | display: block; 29 | } 30 | 31 | .mau-table { 32 | width: 100%; 33 | padding: 0; 34 | margin: 0; 35 | 36 | .header-row, .content-row { 37 | padding: calcRem(20) 0; 38 | display: flex; 39 | align-items: center; 40 | 41 | span { 42 | flex: 1 1 calcRem(100); 43 | } 44 | 45 | .period { 46 | flex-grow: 2; 47 | } 48 | 49 | .mau-count, .difference { 50 | flex-grow: 1; 51 | text-align: center; 52 | 53 | &.positive { 54 | color: $positive-color; 55 | } 56 | 57 | &.negative { 58 | color: $negative-color; 59 | } 60 | } 61 | } 62 | 63 | .header-row { 64 | font-size: calcRem(12); 65 | color: lighten($base-text-color, 15%); 66 | } 67 | 68 | .content-row { 69 | border-bottom: calcRem(1) solid lighten($base-text-color, 75%); 70 | font-size: calcRem(14); 71 | 72 | .mau-count { 73 | font-weight: bold; 74 | } 75 | 76 | &:last-child { 77 | border-bottom: none; 78 | } 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /frontend/src/views/_reports-list-content.scss: -------------------------------------------------------------------------------- 1 | @import '~base/sass/base/variables'; 2 | @import '~base/sass/base/functions'; 3 | @import '~base/sass/base/grid'; 4 | @import '~base/sass/base/stat-cards'; 5 | 6 | .reports-list { 7 | padding-top: calcRem(40); 8 | padding-bottom: calcRem(40); 9 | 10 | .report { 11 | padding: calcRem(30) 0; 12 | display: flex; 13 | align-items: center; 14 | border-bottom: 1px solid #ddd; 15 | 16 | .report-name { 17 | flex-grow: 1; 18 | flex-shrink: 1; 19 | width: 100%; 20 | margin-right: calcRem(30); 21 | font-size: $base-font-size * 1.15; 22 | font-weight: bold; 23 | 24 | a { 25 | color: $base-text-color; 26 | text-decoration: none; 27 | } 28 | } 29 | 30 | .report-description { 31 | flex: 0 0 calcRem(420); 32 | width: calcRem(420); 33 | margin-right: calcRem(30); 34 | font-size: $base-font-size; 35 | } 36 | 37 | .report-timestamp { 38 | flex: 0 0 calcRem(110); 39 | width: calcRem(110); 40 | margin-right: calcRem(30); 41 | font-size: $base-font-size; 42 | text-align: center; 43 | } 44 | 45 | .report-buttons { 46 | flex: 0 0 calcRem(140); 47 | width: calcRem(140); 48 | justify-content: flex-end; 49 | 50 | .view-report-button { 51 | font-size: calcRem(14); 52 | padding: calcRem(10) calcRem(20); 53 | font-weight: bold; 54 | color: $primary-color; 55 | border: calcRem(2) solid $primary-color; 56 | background: transparent; 57 | cursor: pointer; 58 | border-radius: calcRem(30); 59 | transition: all 0.3s ease-in-out; 60 | text-decoration: none; 61 | 62 | &:hover { 63 | color: #fff; 64 | background: $primary-color; 65 | } 66 | } 67 | } 68 | 69 | &.list-header { 70 | 71 | .report-name, .report-description, .report-timestamp, .report-buttons { 72 | font-size: $base-font-size * 0.85; 73 | font-weight: normal; 74 | color: lighten($base-text-color, 15%); 75 | } 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /frontend/src/views/_single-course-content.scss: -------------------------------------------------------------------------------- 1 | @import '~base/sass/base/variables'; 2 | @import '~base/sass/base/functions'; 3 | @import '~base/sass/base/grid'; 4 | @import '~base/sass/base/stat-cards'; 5 | 6 | .course-quick-links { 7 | display: flex; 8 | align-items: center; 9 | padding-bottom: calcRem(30); 10 | 11 | &__line { 12 | display: block; 13 | flex: 1 1 100%; 14 | height: 1px; 15 | background-color: #ccc; 16 | } 17 | 18 | &__link, &__link:not(.btn), &__link:visited:not(.btn) { 19 | display: inline-block; 20 | padding: calcRem(8) calcRem(12); 21 | font-size: $base-font-size; 22 | color: $primary-color; 23 | background: none; 24 | border: 2px solid $primary-color; 25 | text-decoration: none; 26 | transition: all 0.3s ease-in-out; 27 | border-radius: calcRem(50); 28 | font-weight: bold; 29 | margin-left: calcRem(20); 30 | flex-shrink: 0; 31 | flex-grow: 0; 32 | 33 | &:hover { 34 | background-color: $primary-color; 35 | color: #fff; 36 | cursor: pointer; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /frontend/src/views/_single-user-content.scss: -------------------------------------------------------------------------------- 1 | @import '~base/sass/base/variables'; 2 | @import '~base/sass/base/functions'; 3 | @import '~base/sass/base/grid'; 4 | @import '~base/sass/base/stat-cards'; 5 | 6 | .user-content { 7 | padding: calcRem(40) 0 calcRem(70); 8 | 9 | @media (max-width: $tablet-breakpoint) { 10 | grid-template-columns: 1fr; 11 | } 12 | 13 | .user-information { 14 | grid-column-end: span 1; 15 | 16 | @media (max-width: $tablet-breakpoint) { 17 | padding: 0 calcRem(30); 18 | } 19 | 20 | .name { 21 | font-weight: bold; 22 | font-size: calcRem(24); 23 | margin-bottom: calcRem(30); 24 | line-height: 100%; 25 | } 26 | 27 | .user-details { 28 | padding: 0; 29 | margin: 0; 30 | width: 100%; 31 | 32 | li { 33 | border-top: calcRem(1) solid lighten($base-text-color, 75%); 34 | list-style-type: none; 35 | padding: calcRem(15) 0; 36 | display: flex; 37 | align-items: baseline; 38 | width: 100%; 39 | 40 | .label { 41 | display: block; 42 | font-size: calcRem(11); 43 | color: lighten($base-text-color, 20%); 44 | flex-shrink: 0; 45 | flex-grow: 0; 46 | width: calcRem(80); 47 | padding-right: calcRem(20); 48 | } 49 | 50 | .value { 51 | font-size: calcRem(14); 52 | flex-shrink: 1; 53 | flex-grow: 1; 54 | width: 100%; 55 | } 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /ginkgo-env: -------------------------------------------------------------------------------- 1 | export OPENEDX_RELEASE='GINKGO' 2 | -------------------------------------------------------------------------------- /mocks/ginkgo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/ginkgo/__init__.py -------------------------------------------------------------------------------- /mocks/ginkgo/certificates/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/ginkgo/certificates/__init__.py -------------------------------------------------------------------------------- /mocks/ginkgo/certificates/models.py: -------------------------------------------------------------------------------- 1 | 2 | from __future__ import absolute_import 3 | from django.db import models 4 | from django.contrib.auth.models import User 5 | 6 | from openedx.core.djangoapps.xmodule_django.models import CourseKeyField 7 | 8 | class GeneratedCertificate(models.Model): 9 | user = models.ForeignKey(User) 10 | course_id = CourseKeyField(max_length=255, blank=True, default=None) 11 | created_date = models.DateTimeField() 12 | -------------------------------------------------------------------------------- /mocks/ginkgo/course_modes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/ginkgo/course_modes/__init__.py -------------------------------------------------------------------------------- /mocks/ginkgo/course_modes/models.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | class CourseMode(object): 5 | ''' 6 | In edx-platform, this is a Django Model. 7 | 8 | Currently we only need to mock getting the 9 | default mode slug 10 | ''' 11 | 12 | # 13 | DEFAULT_MODE_SLUG = 'audit' -------------------------------------------------------------------------------- /mocks/ginkgo/courseware/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/ginkgo/courseware/__init__.py -------------------------------------------------------------------------------- /mocks/ginkgo/courseware/courses.py: -------------------------------------------------------------------------------- 1 | ''' 2 | 3 | ./common/lib/xmodule/xmodule/modulestore 4 | ''' 5 | 6 | from __future__ import absolute_import 7 | from django.http import Http404 8 | from xmodule.modulestore.django import modulestore 9 | import six 10 | 11 | 12 | def get_course_by_id(course_key, depth=0): 13 | """ 14 | Given a course id, return the corresponding course descriptor. 15 | 16 | If such a course does not exist, raises a 404. 17 | 18 | depth: The number of levels of children for the modulestore to cache. None means infinite depth 19 | """ 20 | with modulestore().bulk_operations(course_key): 21 | course = modulestore().get_course(course_key, depth=depth) 22 | 23 | if course: 24 | return course 25 | else: 26 | raise Http404("Course not found: {}.".format(six.text_type(course_key))) 27 | -------------------------------------------------------------------------------- /mocks/ginkgo/lms/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/ginkgo/lms/__init__.py -------------------------------------------------------------------------------- /mocks/ginkgo/lms/djangoapps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/ginkgo/lms/djangoapps/__init__.py -------------------------------------------------------------------------------- /mocks/ginkgo/lms/djangoapps/grades/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/ginkgo/lms/djangoapps/grades/__init__.py -------------------------------------------------------------------------------- /mocks/ginkgo/lms/djangoapps/grades/new/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/ginkgo/lms/djangoapps/grades/new/__init__.py -------------------------------------------------------------------------------- /mocks/ginkgo/lms/djangoapps/grades/new/course_grade_factory.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | from .course_grade import CourseGrade 4 | 5 | 6 | class MockCourseData(object): 7 | 8 | def __init__(self, user, course=None, collected_block_structure=None, structure=None, course_key=None): 9 | if not any([course, collected_block_structure, structure, course_key]): 10 | raise ValueError( 11 | "You must specify one of course, collected_block_structure, structure, or course_key to this method." 12 | ) 13 | self.user = user 14 | self._collected_block_structure = collected_block_structure 15 | self._structure = structure 16 | self._course = course 17 | self._course_key = course_key 18 | self._location = None 19 | 20 | 21 | class CourseGradeFactory(object): 22 | 23 | def create(self, user, course=None, collected_block_structure=None, course_structure=None, course_key=None): 24 | course_data = MockCourseData(user, course, collected_block_structure, course_structure, course_key) 25 | return CourseGrade(user, course_data, force_update_subsections=False) 26 | -------------------------------------------------------------------------------- /mocks/ginkgo/lms/djangoapps/teams/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/ginkgo/lms/djangoapps/teams/__init__.py -------------------------------------------------------------------------------- /mocks/ginkgo/lms/djangoapps/teams/models.py: -------------------------------------------------------------------------------- 1 | 2 | from __future__ import absolute_import 3 | from django.db import models 4 | from django.contrib.auth.models import User 5 | 6 | class CourseTeam(models.Model): 7 | 8 | class Meta: 9 | app_label = 'teams' 10 | 11 | name = models.CharField(max_length=255, db_index=True) 12 | users = models.ManyToManyField(User, 13 | db_index=True, related_name='teams', through='CourseTeamMembership') 14 | 15 | class CourseTeamMembership(models.Model): 16 | 17 | class Meta: 18 | app_label = "teams" 19 | unique_together = (('user', 'team'),) 20 | 21 | user = models.ForeignKey(User) 22 | team = models.ForeignKey(CourseTeam, related_name='membership') 23 | 24 | -------------------------------------------------------------------------------- /mocks/ginkgo/openedx/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/ginkgo/openedx/__init__.py -------------------------------------------------------------------------------- /mocks/ginkgo/openedx/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/ginkgo/openedx/core/__init__.py -------------------------------------------------------------------------------- /mocks/ginkgo/openedx/core/djangoapps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/ginkgo/openedx/core/djangoapps/__init__.py -------------------------------------------------------------------------------- /mocks/ginkgo/openedx/core/djangoapps/content/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/ginkgo/openedx/core/djangoapps/content/__init__.py -------------------------------------------------------------------------------- /mocks/ginkgo/openedx/core/djangoapps/content/course_overviews/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/ginkgo/openedx/core/djangoapps/content/course_overviews/__init__.py -------------------------------------------------------------------------------- /mocks/ginkgo/openedx/core/djangoapps/course_groups/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/ginkgo/openedx/core/djangoapps/course_groups/__init__.py -------------------------------------------------------------------------------- /mocks/ginkgo/openedx/core/djangoapps/course_groups/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/ginkgo/openedx/core/djangoapps/course_groups/migrations/__init__.py -------------------------------------------------------------------------------- /mocks/ginkgo/openedx/core/djangoapps/user_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/ginkgo/openedx/core/djangoapps/user_api/__init__.py -------------------------------------------------------------------------------- /mocks/ginkgo/openedx/core/djangoapps/user_api/accounts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/ginkgo/openedx/core/djangoapps/user_api/accounts/__init__.py -------------------------------------------------------------------------------- /mocks/ginkgo/openedx/core/djangoapps/user_api/accounts/serializers.py: -------------------------------------------------------------------------------- 1 | 2 | from __future__ import absolute_import 3 | from rest_framework import serializers 4 | 5 | # ReadOnlyFieldSerialzerMixin is from 6 | # openedx.core.djangoapps.user_api.serializers 7 | 8 | class ReadOnlyFieldsSerializerMixin(object): 9 | """ 10 | Mixin for use with Serializers that provides a method 11 | `get_read_only_fields`, which returns a tuple of all read-only 12 | fields on the Serializer. 13 | """ 14 | @classmethod 15 | def get_read_only_fields(cls): 16 | """ 17 | Return all fields on this Serializer class which are read-only. 18 | Expects sub-classes implement Meta.explicit_read_only_fields, 19 | which is a tuple declaring read-only fields which were declared 20 | explicitly and thus could not be added to the usual 21 | cls.Meta.read_only_fields tuple. 22 | """ 23 | return getattr(cls.Meta, 'read_only_fields', '') + getattr(cls.Meta, 'explicit_read_only_fields', '') 24 | 25 | @classmethod 26 | def get_writeable_fields(cls): 27 | """ 28 | Return all fields on this serializer that are writeable. 29 | """ 30 | all_fields = getattr(cls.Meta, 'fields', tuple()) 31 | return tuple(set(all_fields) - set(cls.get_read_only_fields())) 32 | 33 | 34 | class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, ReadOnlyFieldsSerializerMixin): 35 | 36 | @staticmethod 37 | def get_profile_image(user_profile, user, request=None): 38 | ''' 39 | For the mock, we probably can get by with just returning dummy data 40 | ''' 41 | return dict( 42 | image_url_full= "http://localhost:8000/static/images/profiles/default_500.png", 43 | image_url_large="http://localhost:8000/static/images/profiles/default_120.png", 44 | image_url_medium="http://localhost:8000/static/images/profiles/default_50.png", 45 | image_url_small="http://localhost:8000/static/images/profiles/default_30.png", 46 | has_image=user_profile.has_profile_image, 47 | ) 48 | -------------------------------------------------------------------------------- /mocks/ginkgo/openedx/core/djangoapps/xmodule_django/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/ginkgo/openedx/core/djangoapps/xmodule_django/__init__.py -------------------------------------------------------------------------------- /mocks/ginkgo/openedx/core/release.py: -------------------------------------------------------------------------------- 1 | """ 2 | Identify the release of Open edX platform 3 | """ 4 | 5 | RELEASE_LINE = 'ginkgo' 6 | 7 | -------------------------------------------------------------------------------- /mocks/ginkgo/student/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/ginkgo/student/__init__.py -------------------------------------------------------------------------------- /mocks/ginkgo/student/migrations/0002_auto_20191231_1006.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from __future__ import absolute_import 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('student', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='userprofile', 17 | name='allow_certificate', 18 | field=models.BooleanField(default=1), 19 | ), 20 | migrations.AddField( 21 | model_name='userprofile', 22 | name='bio', 23 | field=models.CharField(max_length=3000, null=True, blank=True), 24 | ), 25 | migrations.AddField( 26 | model_name='userprofile', 27 | name='city', 28 | field=models.TextField(null=True, blank=True), 29 | ), 30 | migrations.AddField( 31 | model_name='userprofile', 32 | name='goals', 33 | field=models.TextField(null=True, blank=True), 34 | ), 35 | migrations.AddField( 36 | model_name='userprofile', 37 | name='mailing_address', 38 | field=models.TextField(null=True, blank=True), 39 | ), 40 | ] 41 | -------------------------------------------------------------------------------- /mocks/ginkgo/student/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/ginkgo/student/migrations/__init__.py -------------------------------------------------------------------------------- /mocks/ginkgo/student/roles.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Mocks role classes needed in Figures tests 3 | ''' 4 | 5 | from __future__ import absolute_import 6 | from django.contrib.auth.models import User 7 | 8 | from openedx.core.djangoapps.xmodule_django.models import CourseKeyField 9 | from student.models import CourseAccessRole 10 | 11 | class MockCourseRole(object): 12 | ''' 13 | Mock student.models.CourseRole and its parent classes 14 | 15 | Guideline: only implement the minimum needed to simulate edx-platform for 16 | the Figures unit tests 17 | ''' 18 | def __init__(self, role, course_key): 19 | 20 | # The following are declared in studen.roles.RoleBase 21 | self.org = '' 22 | self._role_name = role 23 | # The following are declared in student.roles.CourseRole 24 | self.role = role 25 | self.course_key = course_key 26 | 27 | def users_with_role(self): 28 | """ 29 | Return a django QuerySet for all of the users with this role 30 | """ 31 | # Org roles don't query by CourseKey, so use CourseKeyField.Empty for that query 32 | if self.course_key is None: 33 | self.course_key = CourseKeyField.Empty 34 | entries = User.objects.filter( 35 | courseaccessrole__role=self._role_name, 36 | courseaccessrole__org=self.org, 37 | courseaccessrole__course_id=self.course_key 38 | ) 39 | return entries 40 | 41 | 42 | class CourseCcxCoachRole(MockCourseRole): 43 | ROLE = 'ccx_coach' 44 | 45 | def __init__(self, *args, **kwargs): 46 | super(CourseCcxCoachRole, self).__init__(self.ROLE, *args, **kwargs) 47 | 48 | 49 | class CourseInstructorRole(MockCourseRole): 50 | ROLE = 'instructor' 51 | 52 | def __init__(self, *args, **kwargs): 53 | super(CourseInstructorRole, self).__init__(self.ROLE, *args, **kwargs) 54 | 55 | 56 | class CourseStaffRole(MockCourseRole): 57 | ROLE = 'staff' 58 | 59 | def __init__(self, *args, **kwargs): 60 | super(CourseStaffRole, self).__init__(self.ROLE, *args, **kwargs) 61 | -------------------------------------------------------------------------------- /mocks/ginkgo/xmodule/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/ginkgo/xmodule/__init__.py -------------------------------------------------------------------------------- /mocks/ginkgo/xmodule/modulestore/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/ginkgo/xmodule/modulestore/__init__.py -------------------------------------------------------------------------------- /mocks/hawthorn/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/hawthorn/__init__.py -------------------------------------------------------------------------------- /mocks/hawthorn/course_modes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/hawthorn/course_modes/__init__.py -------------------------------------------------------------------------------- /mocks/hawthorn/course_modes/models.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | class CourseMode(object): 5 | ''' 6 | In edx-platform, this is a Django Model. 7 | 8 | Currently we only need to mock getting the 9 | default mode slug 10 | ''' 11 | 12 | # 13 | DEFAULT_MODE_SLUG = 'audit' -------------------------------------------------------------------------------- /mocks/hawthorn/courseware/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/hawthorn/courseware/__init__.py -------------------------------------------------------------------------------- /mocks/hawthorn/courseware/courses.py: -------------------------------------------------------------------------------- 1 | ''' 2 | 3 | ./common/lib/xmodule/xmodule/modulestore 4 | ''' 5 | 6 | from __future__ import absolute_import 7 | from django.http import Http404 8 | from xmodule.modulestore.django import modulestore 9 | import six 10 | 11 | 12 | def get_course(course_id, depth=0): 13 | """ 14 | Given a course id, return the corresponding course descriptor. 15 | 16 | If the course does not exist, raises a ValueError. This is appropriate 17 | for internal use. 18 | 19 | depth: The number of levels of children for the modulestore to cache. 20 | None means infinite depth. Default is to fetch no children. 21 | """ 22 | course = modulestore().get_course(course_id, depth=depth) 23 | if course is None: 24 | raise ValueError(u"Course not found: {0}".format(course_id)) 25 | return course 26 | 27 | 28 | def get_course_by_id(course_key, depth=0): 29 | """ 30 | Given a course id, return the corresponding course descriptor. 31 | 32 | If such a course does not exist, raises a 404. 33 | 34 | depth: The number of levels of children for the modulestore to cache. None means infinite depth 35 | """ 36 | with modulestore().bulk_operations(course_key): 37 | course = modulestore().get_course(course_key, depth=depth) 38 | 39 | if course: 40 | return course 41 | else: 42 | raise Http404("Course not found: {}.".format(six.text_type(course_key))) 43 | -------------------------------------------------------------------------------- /mocks/hawthorn/courseware/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.15 on 2019-07-26 02:20 3 | from __future__ import unicode_literals 4 | 5 | from __future__ import absolute_import 6 | from django.conf import settings 7 | from django.db import migrations, models 8 | import django.db.models.deletion 9 | import openedx.core.djangoapps.xmodule_django.models 10 | 11 | 12 | class Migration(migrations.Migration): 13 | 14 | initial = True 15 | 16 | dependencies = [ 17 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 18 | ] 19 | 20 | operations = [ 21 | migrations.CreateModel( 22 | name='StudentModule', 23 | fields=[ 24 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 25 | ('course_id', openedx.core.djangoapps.xmodule_django.models.CourseKeyField(db_index=True, max_length=255)), 26 | ('created', models.DateTimeField()), 27 | ('modified', models.DateTimeField()), 28 | ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 29 | ], 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /mocks/hawthorn/courseware/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/hawthorn/courseware/migrations/__init__.py -------------------------------------------------------------------------------- /mocks/hawthorn/lms/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/hawthorn/lms/__init__.py -------------------------------------------------------------------------------- /mocks/hawthorn/lms/djangoapps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/hawthorn/lms/djangoapps/__init__.py -------------------------------------------------------------------------------- /mocks/hawthorn/lms/djangoapps/certificates/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/hawthorn/lms/djangoapps/certificates/__init__.py -------------------------------------------------------------------------------- /mocks/hawthorn/lms/djangoapps/certificates/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.15 on 2019-07-26 02:19 3 | from __future__ import unicode_literals 4 | 5 | from __future__ import absolute_import 6 | from django.conf import settings 7 | from django.db import migrations, models 8 | import django.db.models.deletion 9 | import openedx.core.djangoapps.xmodule_django.models 10 | 11 | 12 | class Migration(migrations.Migration): 13 | 14 | initial = True 15 | 16 | dependencies = [ 17 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 18 | ] 19 | 20 | operations = [ 21 | migrations.CreateModel( 22 | name='GeneratedCertificate', 23 | fields=[ 24 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 25 | ('course_id', openedx.core.djangoapps.xmodule_django.models.CourseKeyField(blank=True, default=None, max_length=255)), 26 | ('created_date', models.DateTimeField()), 27 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 28 | ], 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /mocks/hawthorn/lms/djangoapps/certificates/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/hawthorn/lms/djangoapps/certificates/migrations/__init__.py -------------------------------------------------------------------------------- /mocks/hawthorn/lms/djangoapps/certificates/models.py: -------------------------------------------------------------------------------- 1 | 2 | from __future__ import absolute_import 3 | from django.db import models 4 | from django.contrib.auth.models import User 5 | 6 | from openedx.core.djangoapps.xmodule_django.models import CourseKeyField 7 | 8 | class GeneratedCertificate(models.Model): 9 | user = models.ForeignKey(User, on_delete=models.CASCADE,) 10 | course_id = CourseKeyField(max_length=255, blank=True, default=None) 11 | created_date = models.DateTimeField() 12 | -------------------------------------------------------------------------------- /mocks/hawthorn/lms/djangoapps/grades/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/hawthorn/lms/djangoapps/grades/__init__.py -------------------------------------------------------------------------------- /mocks/hawthorn/lms/djangoapps/grades/course_grade_factory.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | from __future__ import absolute_import 4 | from lms.djangoapps.grades.course_grade import CourseGrade 5 | 6 | 7 | class MockCourseData(object): 8 | 9 | def __init__(self, user, course=None, collected_block_structure=None, structure=None, course_key=None): 10 | if not any([course, collected_block_structure, structure, course_key]): 11 | raise ValueError( 12 | "You must specify one of course, collected_block_structure, structure, or course_key to this method." 13 | ) 14 | self.user = user 15 | self._collected_block_structure = collected_block_structure 16 | self._structure = structure 17 | self._course = course 18 | self._course_key = course_key 19 | self._location = None 20 | 21 | 22 | class CourseGradeFactory(object): 23 | 24 | def read(self, user, course=None, collected_block_structure=None, course_structure=None, course_key=None): 25 | course_data = MockCourseData(user, course, collected_block_structure, course_structure, course_key) 26 | return CourseGrade(user, course_data, force_update_subsections=False) 27 | -------------------------------------------------------------------------------- /mocks/hawthorn/lms/djangoapps/teams/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/hawthorn/lms/djangoapps/teams/__init__.py -------------------------------------------------------------------------------- /mocks/hawthorn/lms/djangoapps/teams/models.py: -------------------------------------------------------------------------------- 1 | 2 | from __future__ import absolute_import 3 | from django.db import models 4 | from django.contrib.auth.models import User 5 | 6 | class CourseTeam(models.Model): 7 | 8 | class Meta: 9 | app_label = 'teams' 10 | 11 | name = models.CharField(max_length=255, db_index=True) 12 | users = models.ManyToManyField(User, 13 | db_index=True, related_name='teams', through='CourseTeamMembership') 14 | 15 | class CourseTeamMembership(models.Model): 16 | 17 | class Meta: 18 | app_label = "teams" 19 | unique_together = (('user', 'team'),) 20 | 21 | user = models.ForeignKey(User, on_delete=models.CASCADE,) 22 | team = models.ForeignKey(CourseTeam, related_name='membership',on_delete=models.CASCADE,) 23 | 24 | -------------------------------------------------------------------------------- /mocks/hawthorn/openedx/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/hawthorn/openedx/__init__.py -------------------------------------------------------------------------------- /mocks/hawthorn/openedx/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/hawthorn/openedx/core/__init__.py -------------------------------------------------------------------------------- /mocks/hawthorn/openedx/core/djangoapps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/hawthorn/openedx/core/djangoapps/__init__.py -------------------------------------------------------------------------------- /mocks/hawthorn/openedx/core/djangoapps/content/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/hawthorn/openedx/core/djangoapps/content/__init__.py -------------------------------------------------------------------------------- /mocks/hawthorn/openedx/core/djangoapps/content/course_overviews/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/hawthorn/openedx/core/djangoapps/content/course_overviews/__init__.py -------------------------------------------------------------------------------- /mocks/hawthorn/openedx/core/djangoapps/content/course_overviews/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.15 on 2019-07-26 02:20 3 | from __future__ import unicode_literals 4 | 5 | from __future__ import absolute_import 6 | from django.db import migrations, models 7 | import openedx.core.djangoapps.xmodule_django.models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='CourseOverview', 20 | fields=[ 21 | ('version', models.IntegerField()), 22 | ('id', openedx.core.djangoapps.xmodule_django.models.CourseKeyField(db_index=True, max_length=255, primary_key=True, serialize=False)), 23 | ('display_name', models.TextField(null=True)), 24 | ('org', models.TextField(default=b'outdated_entry', max_length=255)), 25 | ('display_org_with_default', models.TextField()), 26 | ('number', models.TextField()), 27 | ('created', models.DateTimeField(null=True)), 28 | ('start', models.DateTimeField(null=True)), 29 | ('end', models.DateTimeField(null=True)), 30 | ('enrollment_start', models.DateTimeField(null=True)), 31 | ('enrollment_end', models.DateTimeField(null=True)), 32 | ('self_paced', models.BooleanField(default=False)), 33 | ], 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /mocks/hawthorn/openedx/core/djangoapps/content/course_overviews/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/hawthorn/openedx/core/djangoapps/content/course_overviews/migrations/__init__.py -------------------------------------------------------------------------------- /mocks/hawthorn/openedx/core/djangoapps/course_groups/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/hawthorn/openedx/core/djangoapps/course_groups/__init__.py -------------------------------------------------------------------------------- /mocks/hawthorn/openedx/core/djangoapps/course_groups/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/hawthorn/openedx/core/djangoapps/course_groups/migrations/__init__.py -------------------------------------------------------------------------------- /mocks/hawthorn/openedx/core/djangoapps/plugins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/hawthorn/openedx/core/djangoapps/plugins/__init__.py -------------------------------------------------------------------------------- /mocks/hawthorn/openedx/core/djangoapps/user_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/hawthorn/openedx/core/djangoapps/user_api/__init__.py -------------------------------------------------------------------------------- /mocks/hawthorn/openedx/core/djangoapps/user_api/accounts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/hawthorn/openedx/core/djangoapps/user_api/accounts/__init__.py -------------------------------------------------------------------------------- /mocks/hawthorn/openedx/core/djangoapps/user_api/accounts/serializers.py: -------------------------------------------------------------------------------- 1 | 2 | from __future__ import absolute_import 3 | from rest_framework import serializers 4 | 5 | # ReadOnlyFieldSerialzerMixin is from 6 | # openedx.core.djangoapps.user_api.serializers 7 | 8 | class ReadOnlyFieldsSerializerMixin(object): 9 | """ 10 | Mixin for use with Serializers that provides a method 11 | `get_read_only_fields`, which returns a tuple of all read-only 12 | fields on the Serializer. 13 | """ 14 | @classmethod 15 | def get_read_only_fields(cls): 16 | """ 17 | Return all fields on this Serializer class which are read-only. 18 | Expects sub-classes implement Meta.explicit_read_only_fields, 19 | which is a tuple declaring read-only fields which were declared 20 | explicitly and thus could not be added to the usual 21 | cls.Meta.read_only_fields tuple. 22 | """ 23 | return getattr(cls.Meta, 'read_only_fields', '') + getattr(cls.Meta, 'explicit_read_only_fields', '') 24 | 25 | @classmethod 26 | def get_writeable_fields(cls): 27 | """ 28 | Return all fields on this serializer that are writeable. 29 | """ 30 | all_fields = getattr(cls.Meta, 'fields', tuple()) 31 | return tuple(set(all_fields) - set(cls.get_read_only_fields())) 32 | 33 | 34 | class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, ReadOnlyFieldsSerializerMixin): 35 | 36 | @staticmethod 37 | def get_profile_image(user_profile, user, request=None): 38 | ''' 39 | For the mock, we probably can get by with just returning dummy data 40 | ''' 41 | return dict( 42 | image_url_full= "http://localhost:8000/static/images/profiles/default_500.png", 43 | image_url_large="http://localhost:8000/static/images/profiles/default_120.png", 44 | image_url_medium="http://localhost:8000/static/images/profiles/default_50.png", 45 | image_url_small="http://localhost:8000/static/images/profiles/default_30.png", 46 | has_image=user_profile.has_profile_image, 47 | ) 48 | -------------------------------------------------------------------------------- /mocks/hawthorn/openedx/core/djangoapps/xmodule_django/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/hawthorn/openedx/core/djangoapps/xmodule_django/__init__.py -------------------------------------------------------------------------------- /mocks/hawthorn/openedx/core/release.py: -------------------------------------------------------------------------------- 1 | """ 2 | Identify the release of Open edX platform 3 | """ 4 | 5 | RELEASE_LINE = 'hawthorn' 6 | 7 | -------------------------------------------------------------------------------- /mocks/hawthorn/student/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/hawthorn/student/__init__.py -------------------------------------------------------------------------------- /mocks/hawthorn/student/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/hawthorn/student/migrations/__init__.py -------------------------------------------------------------------------------- /mocks/hawthorn/student/roles.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Mocks role classes needed in Figures tests 3 | ''' 4 | 5 | from __future__ import absolute_import 6 | from django.contrib.auth.models import User 7 | 8 | from openedx.core.djangoapps.xmodule_django.models import CourseKeyField 9 | from student.models import CourseAccessRole 10 | 11 | class MockCourseRole(object): 12 | ''' 13 | Mock student.models.CourseRole and its parent classes 14 | 15 | Guideline: only implement the minimum needed to simulate edx-platform for 16 | the Figures unit tests 17 | ''' 18 | def __init__(self, role, course_key): 19 | 20 | # The following are declared in studen.roles.RoleBase 21 | self.org = '' 22 | self._role_name = role 23 | # The following are declared in student.roles.CourseRole 24 | self.role = role 25 | self.course_key = course_key 26 | 27 | def users_with_role(self): 28 | """ 29 | Return a django QuerySet for all of the users with this role 30 | """ 31 | # Org roles don't query by CourseKey, so use CourseKeyField.Empty for that query 32 | if self.course_key is None: 33 | self.course_key = CourseKeyField.Empty 34 | entries = User.objects.filter( 35 | courseaccessrole__role=self._role_name, 36 | courseaccessrole__org=self.org, 37 | courseaccessrole__course_id=self.course_key 38 | ) 39 | return entries 40 | 41 | 42 | class CourseCcxCoachRole(MockCourseRole): 43 | ROLE = 'ccx_coach' 44 | 45 | def __init__(self, *args, **kwargs): 46 | super(CourseCcxCoachRole, self).__init__(self.ROLE, *args, **kwargs) 47 | 48 | 49 | class CourseInstructorRole(MockCourseRole): 50 | ROLE = 'instructor' 51 | 52 | def __init__(self, *args, **kwargs): 53 | super(CourseInstructorRole, self).__init__(self.ROLE, *args, **kwargs) 54 | 55 | 56 | class CourseStaffRole(MockCourseRole): 57 | ROLE = 'staff' 58 | 59 | def __init__(self, *args, **kwargs): 60 | super(CourseStaffRole, self).__init__(self.ROLE, *args, **kwargs) 61 | -------------------------------------------------------------------------------- /mocks/hawthorn/xmodule/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/hawthorn/xmodule/__init__.py -------------------------------------------------------------------------------- /mocks/hawthorn/xmodule/modulestore/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/hawthorn/xmodule/modulestore/__init__.py -------------------------------------------------------------------------------- /mocks/hawthorn/xmodule/modulestore/exceptions.py: -------------------------------------------------------------------------------- 1 | class ItemNotFoundError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /mocks/juniper/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/juniper/__init__.py -------------------------------------------------------------------------------- /mocks/juniper/course_modes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/juniper/course_modes/__init__.py -------------------------------------------------------------------------------- /mocks/juniper/course_modes/models.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | class CourseMode(object): 5 | ''' 6 | In edx-platform, this is a Django Model. 7 | 8 | Currently we only need to mock getting the 9 | default mode slug 10 | ''' 11 | 12 | # 13 | DEFAULT_MODE_SLUG = u'audit' -------------------------------------------------------------------------------- /mocks/juniper/lms/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/juniper/lms/__init__.py -------------------------------------------------------------------------------- /mocks/juniper/lms/djangoapps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/juniper/lms/djangoapps/__init__.py -------------------------------------------------------------------------------- /mocks/juniper/lms/djangoapps/certificates/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/juniper/lms/djangoapps/certificates/__init__.py -------------------------------------------------------------------------------- /mocks/juniper/lms/djangoapps/certificates/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.15 on 2019-07-26 02:19 3 | from __future__ import unicode_literals 4 | 5 | from __future__ import absolute_import 6 | from django.conf import settings 7 | from django.db import migrations, models 8 | import django.db.models.deletion 9 | import opaque_keys.edx.django.models 10 | 11 | 12 | class Migration(migrations.Migration): 13 | 14 | initial = True 15 | 16 | dependencies = [ 17 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 18 | ] 19 | 20 | operations = [ 21 | migrations.CreateModel( 22 | name='GeneratedCertificate', 23 | fields=[ 24 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 25 | ('course_id', opaque_keys.edx.django.models.CourseKeyField(blank=True, default=None, max_length=255)), 26 | ('created_date', models.DateTimeField()), 27 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 28 | ], 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /mocks/juniper/lms/djangoapps/certificates/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/juniper/lms/djangoapps/certificates/migrations/__init__.py -------------------------------------------------------------------------------- /mocks/juniper/lms/djangoapps/certificates/models.py: -------------------------------------------------------------------------------- 1 | 2 | from __future__ import absolute_import 3 | from django.db import models 4 | from django.contrib.auth.models import User 5 | 6 | from opaque_keys.edx.django.models import CourseKeyField 7 | 8 | 9 | class GeneratedCertificate(models.Model): 10 | 11 | user = models.ForeignKey(User, on_delete=models.CASCADE) 12 | course_id = CourseKeyField(max_length=255, blank=True, default=None) 13 | created_date = models.DateTimeField() 14 | -------------------------------------------------------------------------------- /mocks/juniper/lms/djangoapps/courseware/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/juniper/lms/djangoapps/courseware/__init__.py -------------------------------------------------------------------------------- /mocks/juniper/lms/djangoapps/courseware/courses.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | import six 3 | from django.http import Http404 4 | 5 | from xmodule.modulestore.django import modulestore 6 | 7 | 8 | def get_course(course_id, depth=0): 9 | """ 10 | Given a course id, return the corresponding course descriptor. 11 | 12 | If the course does not exist, raises a ValueError. This is appropriate 13 | for internal use. 14 | 15 | depth: The number of levels of children for the modulestore to cache. 16 | None means infinite depth. Default is to fetch no children. 17 | """ 18 | course = modulestore().get_course(course_id, depth=depth) 19 | if course is None: 20 | raise ValueError(u"Course not found: {0}".format(course_id)) 21 | return course 22 | 23 | 24 | def get_course_by_id(course_key, depth=0): 25 | """ 26 | Given a course id, return the corresponding course descriptor. 27 | 28 | If such a course does not exist, raises a 404. 29 | 30 | depth: The number of levels of children for the modulestore to cache. None means infinite depth 31 | """ 32 | with modulestore().bulk_operations(course_key): 33 | course = modulestore().get_course(course_key, depth=depth) 34 | 35 | if course: 36 | return course 37 | else: 38 | raise Http404(u"Course not found: {}.".format(six.text_type(course_key))) 39 | -------------------------------------------------------------------------------- /mocks/juniper/lms/djangoapps/courseware/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.15 on 2019-07-26 02:20 3 | from __future__ import unicode_literals 4 | 5 | from __future__ import absolute_import 6 | import django.db.models.deletion 7 | from django.conf import settings 8 | from django.db import migrations, models 9 | 10 | import opaque_keys.edx.django.models 11 | 12 | 13 | class Migration(migrations.Migration): 14 | 15 | initial = True 16 | 17 | dependencies = [ 18 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 19 | ] 20 | 21 | operations = [ 22 | migrations.CreateModel( 23 | name='StudentModule', 24 | fields=[ 25 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 26 | ('course_id', opaque_keys.edx.django.models.CourseKeyField(db_index=True, max_length=255)), 27 | ('created', models.DateTimeField()), 28 | ('modified', models.DateTimeField()), 29 | ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 30 | ], 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /mocks/juniper/lms/djangoapps/courseware/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/juniper/lms/djangoapps/courseware/migrations/__init__.py -------------------------------------------------------------------------------- /mocks/juniper/lms/djangoapps/grades/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/juniper/lms/djangoapps/grades/__init__.py -------------------------------------------------------------------------------- /mocks/juniper/lms/djangoapps/grades/course_grade_factory.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | from __future__ import absolute_import 4 | from lms.djangoapps.grades.course_grade import CourseGrade 5 | 6 | 7 | class MockCourseData(object): 8 | 9 | def __init__(self, user, course=None, collected_block_structure=None, structure=None, course_key=None): 10 | if not any([course, collected_block_structure, structure, course_key]): 11 | raise ValueError( 12 | "You must specify one of course, collected_block_structure, structure, or course_key to this method." 13 | ) 14 | self.user = user 15 | self._collected_block_structure = collected_block_structure 16 | self._structure = structure 17 | self._course = course 18 | self._course_key = course_key 19 | self._location = None 20 | 21 | 22 | class CourseGradeFactory(object): 23 | 24 | def read( 25 | self, 26 | user, 27 | course=None, 28 | collected_block_structure=None, 29 | course_structure=None, 30 | course_key=None 31 | ): 32 | course_data = MockCourseData( 33 | user, course, collected_block_structure, course_structure, course_key) 34 | return CourseGrade( 35 | user, 36 | course_data, 37 | force_update_subsections=False 38 | ) 39 | -------------------------------------------------------------------------------- /mocks/juniper/lms/djangoapps/teams/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/juniper/lms/djangoapps/teams/__init__.py -------------------------------------------------------------------------------- /mocks/juniper/lms/djangoapps/teams/models.py: -------------------------------------------------------------------------------- 1 | 2 | from __future__ import absolute_import 3 | from django.db import models 4 | from django.contrib.auth.models import User 5 | 6 | 7 | class CourseTeam(models.Model): 8 | 9 | class Meta: 10 | app_label = 'teams' 11 | 12 | name = models.CharField(max_length=255, db_index=True) 13 | users = models.ManyToManyField(User, 14 | db_index=True, related_name='teams', through='CourseTeamMembership') 15 | 16 | 17 | class CourseTeamMembership(models.Model): 18 | 19 | class Meta: 20 | app_label = "teams" 21 | unique_together = (('user', 'team'),) 22 | 23 | user = models.ForeignKey(User, on_delete=models.CASCADE) 24 | team = models.ForeignKey(CourseTeam, related_name='membership', on_delete=models.CASCADE) 25 | -------------------------------------------------------------------------------- /mocks/juniper/openedx/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/juniper/openedx/__init__.py -------------------------------------------------------------------------------- /mocks/juniper/openedx/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/juniper/openedx/core/__init__.py -------------------------------------------------------------------------------- /mocks/juniper/openedx/core/djangoapps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/juniper/openedx/core/djangoapps/__init__.py -------------------------------------------------------------------------------- /mocks/juniper/openedx/core/djangoapps/content/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/juniper/openedx/core/djangoapps/content/__init__.py -------------------------------------------------------------------------------- /mocks/juniper/openedx/core/djangoapps/content/course_overviews/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/juniper/openedx/core/djangoapps/content/course_overviews/__init__.py -------------------------------------------------------------------------------- /mocks/juniper/openedx/core/djangoapps/content/course_overviews/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.15 on 2019-07-26 02:20 3 | from __future__ import unicode_literals 4 | 5 | from __future__ import absolute_import 6 | from django.db import migrations, models 7 | import opaque_keys.edx.django.models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='CourseOverview', 20 | fields=[ 21 | ('version', models.IntegerField()), 22 | ('id', opaque_keys.edx.django.models.CourseKeyField(db_index=True, max_length=255, primary_key=True, serialize=False)), 23 | ('display_name', models.TextField(null=True)), 24 | ('org', models.TextField(default=b'outdated_entry', max_length=255)), 25 | ('display_org_with_default', models.TextField()), 26 | ('number', models.TextField()), 27 | ('created', models.DateTimeField(null=True)), 28 | ('start', models.DateTimeField(null=True)), 29 | ('end', models.DateTimeField(null=True)), 30 | ('enrollment_start', models.DateTimeField(null=True)), 31 | ('enrollment_end', models.DateTimeField(null=True)), 32 | ('self_paced', models.BooleanField(default=False)), 33 | ], 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /mocks/juniper/openedx/core/djangoapps/content/course_overviews/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/juniper/openedx/core/djangoapps/content/course_overviews/migrations/__init__.py -------------------------------------------------------------------------------- /mocks/juniper/openedx/core/djangoapps/course_groups/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/juniper/openedx/core/djangoapps/course_groups/__init__.py -------------------------------------------------------------------------------- /mocks/juniper/openedx/core/djangoapps/course_groups/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/juniper/openedx/core/djangoapps/course_groups/migrations/__init__.py -------------------------------------------------------------------------------- /mocks/juniper/openedx/core/djangoapps/plugins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/juniper/openedx/core/djangoapps/plugins/__init__.py -------------------------------------------------------------------------------- /mocks/juniper/openedx/core/djangoapps/user_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/juniper/openedx/core/djangoapps/user_api/__init__.py -------------------------------------------------------------------------------- /mocks/juniper/openedx/core/djangoapps/user_api/accounts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/juniper/openedx/core/djangoapps/user_api/accounts/__init__.py -------------------------------------------------------------------------------- /mocks/juniper/openedx/core/djangoapps/user_api/accounts/serializers.py: -------------------------------------------------------------------------------- 1 | 2 | from __future__ import absolute_import 3 | from rest_framework import serializers 4 | 5 | # ReadOnlyFieldSerialzerMixin is from 6 | # openedx.core.djangoapps.user_api.serializers 7 | 8 | class ReadOnlyFieldsSerializerMixin(object): 9 | """ 10 | Mixin for use with Serializers that provides a method 11 | `get_read_only_fields`, which returns a tuple of all read-only 12 | fields on the Serializer. 13 | """ 14 | @classmethod 15 | def get_read_only_fields(cls): 16 | """ 17 | Return all fields on this Serializer class which are read-only. 18 | Expects sub-classes implement Meta.explicit_read_only_fields, 19 | which is a tuple declaring read-only fields which were declared 20 | explicitly and thus could not be added to the usual 21 | cls.Meta.read_only_fields tuple. 22 | """ 23 | return getattr(cls.Meta, 'read_only_fields', '') + getattr(cls.Meta, 'explicit_read_only_fields', '') 24 | 25 | @classmethod 26 | def get_writeable_fields(cls): 27 | """ 28 | Return all fields on this serializer that are writeable. 29 | """ 30 | all_fields = getattr(cls.Meta, 'fields', tuple()) 31 | return tuple(set(all_fields) - set(cls.get_read_only_fields())) 32 | 33 | 34 | class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, ReadOnlyFieldsSerializerMixin): 35 | 36 | @staticmethod 37 | def get_profile_image(user_profile, user, request=None): 38 | ''' 39 | For the mock, we probably can get by with just returning dummy data 40 | ''' 41 | return dict( 42 | image_url_full= "http://localhost:8000/static/images/profiles/default_500.png", 43 | image_url_large="http://localhost:8000/static/images/profiles/default_120.png", 44 | image_url_medium="http://localhost:8000/static/images/profiles/default_50.png", 45 | image_url_small="http://localhost:8000/static/images/profiles/default_30.png", 46 | has_image=user_profile.has_profile_image, 47 | ) 48 | -------------------------------------------------------------------------------- /mocks/juniper/openedx/core/djangoapps/xmodule_django/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/juniper/openedx/core/djangoapps/xmodule_django/__init__.py -------------------------------------------------------------------------------- /mocks/juniper/openedx/core/release.py: -------------------------------------------------------------------------------- 1 | """ 2 | Identify the release of Open edX platform 3 | """ 4 | 5 | RELEASE_LINE = 'juniper' 6 | -------------------------------------------------------------------------------- /mocks/juniper/student/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/juniper/student/__init__.py -------------------------------------------------------------------------------- /mocks/juniper/student/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/juniper/student/migrations/__init__.py -------------------------------------------------------------------------------- /mocks/juniper/student/roles.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Mocks role classes needed in Figures tests 3 | ''' 4 | 5 | from __future__ import absolute_import 6 | from django.contrib.auth.models import User 7 | 8 | from opaque_keys.edx.django.models import CourseKeyField 9 | from student.models import CourseAccessRole 10 | 11 | class MockCourseRole(object): 12 | ''' 13 | Mock student.models.CourseRole and its parent classes 14 | 15 | Guideline: only implement the minimum needed to simulate edx-platform for 16 | the Figures unit tests 17 | ''' 18 | def __init__(self, role, course_key): 19 | 20 | # The following are declared in studen.roles.RoleBase 21 | self.org = '' 22 | self._role_name = role 23 | # The following are declared in student.roles.CourseRole 24 | self.role = role 25 | self.course_key = course_key 26 | 27 | def users_with_role(self): 28 | """ 29 | Return a django QuerySet for all of the users with this role 30 | """ 31 | # Org roles don't query by CourseKey, so use CourseKeyField.Empty for that query 32 | if self.course_key is None: 33 | self.course_key = CourseKeyField.Empty 34 | entries = User.objects.filter( 35 | courseaccessrole__role=self._role_name, 36 | courseaccessrole__org=self.org, 37 | courseaccessrole__course_id=self.course_key 38 | ) 39 | return entries 40 | 41 | 42 | class CourseCcxCoachRole(MockCourseRole): 43 | ROLE = 'ccx_coach' 44 | 45 | def __init__(self, *args, **kwargs): 46 | super(CourseCcxCoachRole, self).__init__(self.ROLE, *args, **kwargs) 47 | 48 | 49 | class CourseInstructorRole(MockCourseRole): 50 | ROLE = 'instructor' 51 | 52 | def __init__(self, *args, **kwargs): 53 | super(CourseInstructorRole, self).__init__(self.ROLE, *args, **kwargs) 54 | 55 | 56 | class CourseStaffRole(MockCourseRole): 57 | ROLE = 'staff' 58 | 59 | def __init__(self, *args, **kwargs): 60 | super(CourseStaffRole, self).__init__(self.ROLE, *args, **kwargs) 61 | -------------------------------------------------------------------------------- /mocks/juniper/xmodule/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/juniper/xmodule/__init__.py -------------------------------------------------------------------------------- /mocks/juniper/xmodule/modulestore/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/mocks/juniper/xmodule/modulestore/__init__.py -------------------------------------------------------------------------------- /mocks/juniper/xmodule/modulestore/exceptions.py: -------------------------------------------------------------------------------- 1 | class ItemNotFoundError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /pytest-ginkgo.ini: -------------------------------------------------------------------------------- 1 | # This file supports PyTest testing Figures against Ginkgo mocks 2 | 3 | [pytest] 4 | DJANGO_SETTINGS_MODULE = devsite.test_settings 5 | 6 | norecursedirs = .* docs requirements 7 | 8 | python_paths = devsite mocks/ginkgo 9 | 10 | testpaths = ./tests 11 | -------------------------------------------------------------------------------- /pytest-hawthorn.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = devsite.test_settings 3 | 4 | norecursedirs = .* docs requirements 5 | python_paths = devsite mocks/hawthorn 6 | testpaths = ./tests 7 | -------------------------------------------------------------------------------- /pytest-juniper.ini: -------------------------------------------------------------------------------- 1 | # This file supports PyTest testing Figures against Juniper mocks 2 | 3 | [pytest] 4 | DJANGO_SETTINGS_MODULE = devsite.test_settings 5 | 6 | norecursedirs = .* docs requirements 7 | 8 | python_paths = devsite mocks/juniper 9 | 10 | testpaths = ./tests 11 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = devsite.test_settings 3 | 4 | norecursedirs = .* docs requirements 5 | python_paths = devsite mocks/juniper 6 | testpaths = ./tests 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = migrations 3 | max-line-length = 100 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | ''' 2 | 3 | If we want to exclude the top level 'tests' from the build, change the line: 4 | 5 | :: 6 | 7 | packages=find_packages(), 8 | 9 | to 10 | 11 | :: 12 | 13 | packages=find_packages(exclude=['tests.*', 'tests']), 14 | 15 | ''' 16 | 17 | from __future__ import absolute_import 18 | import os 19 | from setuptools import find_packages, setup 20 | 21 | with open(os.path.join(os.path.dirname(__file__), 'docs/readme-pypi.rst')) as readme: 22 | README = readme.read() 23 | 24 | # allow setup.py to be run from any path 25 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) 26 | 27 | setup( 28 | name='Figures', 29 | version='0.4.4', 30 | packages=find_packages(), 31 | include_package_data=True, 32 | license='MIT', 33 | description='Reporting and data retrieval for Open edX', 34 | long_description=README, 35 | url='https://github.com/appsembler/figures', 36 | author='Appsembler', 37 | author_email='opensources@appsembler.com', 38 | classifiers=[ 39 | 'Environment :: Web Environment', 40 | 'Framework :: Django', 41 | 'Framework :: Django :: 1.8', 42 | 'Framework :: Django :: 1.11', 43 | 'Framework :: Django :: 2.2', 44 | 'Intended Audience :: Developers', 45 | 'License :: OSI Approved :: MIT License', 46 | 'Operating System :: OS Independent', 47 | 'Programming Language :: Python', 48 | 'Programming Language :: Python :: 2', 49 | 'Programming Language :: Python :: 2.7', 50 | 'Programming Language :: Python :: 3.5', 51 | 'Programming Language :: Python :: 3.8', 52 | 'Topic :: Internet :: WWW/HTTP', 53 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 54 | ], 55 | entry_points={ 56 | 'lms.djangoapp': [ 57 | 'figures = figures.apps:FiguresConfig', 58 | ], 59 | }, 60 | install_requires=[ 61 | 'sqlparse >= 0.2.2', # This is the requirement specified by Django 2.2+ 62 | ], 63 | ) 64 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/tests/__init__.py -------------------------------------------------------------------------------- /tests/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/tests/commands/__init__.py -------------------------------------------------------------------------------- /tests/metrics/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/tests/metrics/__init__.py -------------------------------------------------------------------------------- /tests/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/tests/models/__init__.py -------------------------------------------------------------------------------- /tests/models/test_pipeline_errors_model.py: -------------------------------------------------------------------------------- 1 | '''Tests Figures PipeLineError model 2 | 3 | ''' 4 | 5 | from __future__ import absolute_import 6 | import datetime 7 | import pytest 8 | 9 | from figures.models import PipelineError 10 | 11 | 12 | @pytest.mark.django_db 13 | class TestPipelineError(object): 14 | 15 | @pytest.fixture(autouse=True) 16 | def setup(self, db): 17 | self.error_data = dict( 18 | value1='value1', 19 | datetime_val=datetime.datetime(2018, 2, 2, 6, 30) 20 | ) 21 | 22 | def test_error_data(self): 23 | obj = PipelineError(error_data=self.error_data) 24 | assert obj.error_data == self.error_data 25 | 26 | def test_create_unspecified(self): 27 | 28 | obj = PipelineError(error_data=self.error_data) 29 | assert obj.error_type == PipelineError.UNSPECIFIED_DATA 30 | 31 | @pytest.mark.parametrize('error_type,', [ 32 | PipelineError.UNSPECIFIED_DATA, 33 | PipelineError.GRADES_DATA, 34 | PipelineError.COURSE_DATA, 35 | PipelineError.SITE_DATA, 36 | ]) 37 | def test_create_with_error_type(self, error_type): 38 | obj = PipelineError( 39 | error_data=self.error_data, 40 | error_type=error_type, 41 | ) 42 | assert obj.error_type == error_type 43 | 44 | def test_str(self): 45 | obj = PipelineError.objects.create( 46 | error_data=self.error_data, 47 | error_type=PipelineError.GRADES_DATA) 48 | assert str(obj) == '{}, {}, {}'.format(obj.id, obj.created, obj.error_type) 49 | -------------------------------------------------------------------------------- /tests/models/test_site_monthly_metrics_model.py: -------------------------------------------------------------------------------- 1 | """Tests SiteMonthlyMetrics model 2 | 3 | """ 4 | 5 | from __future__ import absolute_import 6 | from datetime import date 7 | import pytest 8 | from figures.models import SiteMonthlyMetrics 9 | 10 | from tests.factories import ( 11 | SiteFactory 12 | ) 13 | 14 | 15 | @pytest.mark.django_db 16 | class TestSiteDailyMetrics(object): 17 | 18 | @pytest.fixture(autouse=True) 19 | def setup(self, db): 20 | self.site = SiteFactory() 21 | 22 | def test_create(self): 23 | 24 | assert not SiteMonthlyMetrics.objects.count() 25 | year = 2020 26 | month = 4 27 | 28 | rec = dict( 29 | site=self.site, 30 | year=year, 31 | month=month, 32 | active_user_count=42, 33 | ) 34 | expected_month_for = date(year=year, month=month, day=1) 35 | metrics, created = SiteMonthlyMetrics.add_month(**rec) 36 | assert metrics and created 37 | assert metrics.month_for == expected_month_for 38 | assert metrics.active_user_count == rec['active_user_count'] 39 | -------------------------------------------------------------------------------- /tests/pipeline/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/tests/pipeline/__init__.py -------------------------------------------------------------------------------- /tests/pipeline/test_logger.py: -------------------------------------------------------------------------------- 1 | ''' 2 | 3 | NOTE: These tests need improvement 4 | 5 | * Add testing to skip model via kwarg to override settings value 6 | 7 | ''' 8 | 9 | from __future__ import absolute_import 10 | import datetime 11 | import mock 12 | import pytest 13 | 14 | from figures.pipeline import logger 15 | from figures.models import PipelineError 16 | 17 | from tests.factories import UserFactory 18 | 19 | 20 | @pytest.mark.django_db 21 | class TestPipelineLogging(object): 22 | 23 | @pytest.fixture(autouse=True) 24 | def setup(self, db): 25 | self.error_data = dict( 26 | alpha_key='alpha-value', 27 | bravo_key='bravo-value', 28 | ts=datetime.datetime(2018, 2, 2, 6, 30) 29 | ) 30 | self.user = UserFactory(username='bubba') 31 | 32 | def test_logging_to_logger(self): 33 | assert PipelineError.objects.count() == 0 34 | features = {'FIGURES_LOG_PIPELINE_ERRORS_TO_DB': False} 35 | with mock.patch('figures.helpers.settings.FEATURES', features): 36 | logger.log_error(self.error_data) 37 | assert PipelineError.objects.count() == 0 38 | 39 | def test_logging_to_model(self): 40 | assert PipelineError.objects.count() == 0 41 | logger.log_error(self.error_data) 42 | 43 | @pytest.mark.parametrize('dict_args', [ 44 | {'username': 'bubba'}, 45 | {'username': 'bubba', 'course_id': 'fake-course-id'} 46 | ]) 47 | def test_logging_to_model_with_kwargs(self, dict_args): 48 | assert PipelineError.objects.count() == 0 49 | logger.log_error(self.error_data, **dict_args) 50 | -------------------------------------------------------------------------------- /tests/tasks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/tests/tasks/__init__.py -------------------------------------------------------------------------------- /tests/test-webpack-stats.json: -------------------------------------------------------------------------------- 1 | { 2 | "chunks": { 3 | "main": [ 4 | { 5 | "name": "static/js/main.9a35969a.js", 6 | "path": "/Users/jbaldwin/work/appsembler/devstacks/gfig3/src/figures/figures/static/figures/static/js/main.9a35969a.js", 7 | "publicPath": "/static/figures/static/js/main.9a35969a.js" 8 | }, 9 | { 10 | "name": "styles.css", 11 | "path": "/Users/jbaldwin/work/appsembler/devstacks/gfig3/src/figures/figures/static/figures/styles.css", 12 | "publicPath": "/static/figures/styles.css" 13 | }, 14 | { 15 | "name": "static/js/main.9a35969a.js.map", 16 | "path": "/Users/jbaldwin/work/appsembler/devstacks/gfig3/src/figures/figures/static/figures/static/js/main.9a35969a.js.map", 17 | "publicPath": "/static/figures/static/js/main.9a35969a.js.map" 18 | }, 19 | { 20 | "name": "styles.css.map", 21 | "path": "/Users/jbaldwin/work/appsembler/devstacks/gfig3/src/figures/figures/static/figures/styles.css.map", 22 | "publicPath": "/static/figures/styles.css.map" 23 | } 24 | ] 25 | }, 26 | "publicPath": "/static/figures/", 27 | "status": "done" 28 | } 29 | -------------------------------------------------------------------------------- /tests/test_apps.py: -------------------------------------------------------------------------------- 1 | """Tests figures.apps module 2 | 3 | """ 4 | 5 | from __future__ import absolute_import 6 | import mock 7 | import pytest 8 | 9 | from tests.helpers import OPENEDX_RELEASE, GINKGO 10 | 11 | 12 | class AwsSettingsType(object): 13 | AWS = u'aws' 14 | 15 | 16 | class ProdSettingsType(object): 17 | PRODUCTION = u'production' 18 | 19 | 20 | @pytest.mark.skipif(OPENEDX_RELEASE == GINKGO, 21 | reason='Plugins not supported in Ginkgo') 22 | @pytest.mark.parametrize('klass, expected_val', [ 23 | (AwsSettingsType, AwsSettingsType.AWS), 24 | (ProdSettingsType, ProdSettingsType.PRODUCTION), 25 | ]) 26 | def test_production_settings_name(klass, expected_val): 27 | key = 'openedx.core.djangoapps.plugins.constants' 28 | module = mock.Mock() 29 | setattr(module, 'SettingsType', klass) 30 | with mock.patch.dict('sys.modules', {key: module}): 31 | from figures.apps import production_settings_name 32 | name = production_settings_name() 33 | assert name == expected_val 34 | 35 | 36 | def test_figures_config_name(): 37 | from figures.apps import FiguresConfig 38 | assert FiguresConfig.name == 'figures' 39 | assert FiguresConfig.verbose_name == 'Figures' 40 | -------------------------------------------------------------------------------- /tests/test_log.py: -------------------------------------------------------------------------------- 1 | """Tests figures.log module 2 | """ 3 | 4 | import logging 5 | import pytest 6 | 7 | from figures.log import log_exec_time 8 | 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | def some_func(): 14 | """Just a function we'll call to test the context manager 15 | """ 16 | logger.info('Just some function') 17 | 18 | 19 | @pytest.mark.django_db 20 | class TestLogExecTime(object): 21 | 22 | @pytest.fixture(autouse=True) 23 | def setup(self, db): 24 | pass 25 | 26 | def test_with_default_logger(self, caplog): 27 | """Our basic `log_exec_time` test 28 | """ 29 | caplog.set_level(logging.INFO) 30 | my_message = 'my-message' 31 | with log_exec_time(my_message): 32 | some_func() 33 | last_log = caplog.records[-1] 34 | # Very basic check. We can improve on it by monkeypatching timit or just 35 | # checking the number and 's' for seconds at the end of the string 36 | # For now, we just want to check that our message gets into the log 37 | assert last_log.message.startswith(my_message) 38 | 39 | def test_with_param_logger(self, caplog): 40 | """Test when we provide a logger 41 | This is just a parameter check at this point. We're not checking that 42 | we have multiple logging buffers 43 | """ 44 | caplog.set_level(logging.INFO) 45 | my_message = 'my-message' 46 | with log_exec_time(my_message, logger): 47 | some_func() 48 | last_log = caplog.records[-1] 49 | assert last_log.message.startswith(my_message) 50 | 51 | def test_log_level_warning(self, caplog): 52 | """Make sure we are not outputting the exec time on level > INFO 53 | """ 54 | caplog.set_level(logging.WARNING) 55 | my_message = 'my-message' 56 | with log_exec_time(my_message): 57 | some_func() 58 | assert not caplog.records 59 | -------------------------------------------------------------------------------- /tests/test_mocks.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module tests the mocks and test factories 3 | """ 4 | 5 | from __future__ import absolute_import 6 | import pytest 7 | 8 | from tests.factories import ( 9 | CohortMembershipFactory, 10 | CourseEnrollmentFactory, 11 | CourseOverviewFactory, 12 | CourseUserGroupFactory, 13 | GeneratedCertificateFactory, 14 | StudentModuleFactory, 15 | ) 16 | 17 | 18 | @pytest.mark.django_db 19 | class TestCourseEnrollment(object): 20 | def test_create_default_factory(self): 21 | obj = CourseEnrollmentFactory() 22 | assert obj 23 | 24 | 25 | @pytest.mark.django_db 26 | class TestCourseOverview(object): 27 | def test_create_default_factory(self): 28 | obj = CourseOverviewFactory() 29 | assert obj 30 | 31 | 32 | @pytest.mark.django_db 33 | class TestGeneratedCertificate(object): 34 | def test_create_default_factory(self): 35 | obj = GeneratedCertificateFactory() 36 | assert obj 37 | 38 | 39 | @pytest.mark.django_db 40 | class TestStudentModule(object): 41 | def test_create_student_module_factory(self): 42 | obj = StudentModuleFactory() 43 | assert obj 44 | 45 | 46 | @pytest.mark.django_db 47 | class TestCourseUserGroup(object): 48 | def test_create_course_user_group_factory(self): 49 | obj = CourseUserGroupFactory() 50 | assert obj 51 | 52 | 53 | @pytest.mark.django_db 54 | class TestCohortMembership(object): 55 | def test_create_cohort_membership_factory(self): 56 | obj = CohortMembershipFactory() 57 | assert obj 58 | 59 | # TODO: Add test for UserProfile 60 | -------------------------------------------------------------------------------- /tests/test_pagination.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from figures.pagination import ( 3 | FiguresLimitOffsetPagination, 4 | FiguresKiloPagination, 5 | ) 6 | 7 | 8 | class TestFiguresLimitOffsetPagination(object): 9 | def test_default_pagination_limit(self): 10 | assert FiguresLimitOffsetPagination.default_limit == 20 11 | 12 | 13 | class TestFigureKiloPagination(object): 14 | def test_default_pagination_limit(self): 15 | assert FiguresKiloPagination.default_limit == 1000 16 | -------------------------------------------------------------------------------- /tests/views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appsembler/figures/30fc4ec25aa504e0dee3e1814345c5fc754a7c01/tests/views/__init__.py -------------------------------------------------------------------------------- /tests/views/helpers.py: -------------------------------------------------------------------------------- 1 | '''This module provides test helpers for Figures view tests 2 | 3 | ''' 4 | 5 | from __future__ import absolute_import 6 | from tests.factories import UserFactory 7 | 8 | from tests.helpers import organizations_support_sites 9 | 10 | 11 | if organizations_support_sites(): 12 | from tests.factories import UserOrganizationMappingFactory 13 | 14 | 15 | def create_test_users(): 16 | ''' 17 | Creates four test users to test the combination of permissions 18 | * regular_user (is_staff=False, is_superuser=False) 19 | * staff_user (is_staf=True, is_superuser=False) 20 | * super_user (is_staff=False, is_superuser=True) 21 | * superstaff_user (is_staff=True, is_superuser=True) 22 | ''' 23 | return [ 24 | UserFactory(username='regular_user'), 25 | UserFactory(username='staff_user', is_staff=True), 26 | UserFactory(username='super_user', is_superuser=True), 27 | UserFactory(username='superstaff_user', is_staff=True, is_superuser=True) 28 | ] 29 | 30 | 31 | def is_response_paginated(response_data): 32 | """Checks if the response data dict has expected paginated results keys 33 | 34 | Returns True if it finds all the paginated keys, False otherwise 35 | """ 36 | try: 37 | keys = list(response_data.keys()) 38 | except AttributeError: 39 | # If we can't get keys, wer'e certainly not paginated 40 | return False 41 | return set(keys) == set([u'count', u'next', u'previous', u'results']) 42 | 43 | 44 | def make_caller(org): 45 | """Convenience method to create the API caller user 46 | """ 47 | if organizations_support_sites(): 48 | # TODO: set is_staff to False after we have test coverage 49 | caller = UserFactory(is_staff=True) 50 | UserOrganizationMappingFactory(user=caller, 51 | organization=org, 52 | is_amc_admin=True) 53 | else: 54 | caller = UserFactory(is_staff=True) 55 | return caller 56 | -------------------------------------------------------------------------------- /tests/views/test_general_site_metrics_view.py: -------------------------------------------------------------------------------- 1 | ''' 2 | 3 | ''' 4 | 5 | 6 | from __future__ import absolute_import 7 | import pytest 8 | 9 | from rest_framework.test import ( 10 | APIRequestFactory, 11 | #RequestsClient, Not supported in older rest_framework versions 12 | force_authenticate, 13 | ) 14 | 15 | from figures.views import GeneralSiteMetricsView 16 | from tests.views.base import BaseViewTest 17 | 18 | 19 | def mock_get_monthly_site_metrics(date_for=None, **kwargs): 20 | return dict( 21 | monthly_active_users=1, 22 | total_site_users=2, 23 | total_site_coures=3, 24 | total_course_enrollments=4, 25 | total_course_completions=5, 26 | ) 27 | 28 | @pytest.mark.django_db 29 | class TestGeneralSiteMetricsView(BaseViewTest): 30 | '''Tests the GeneralSiteMetricsView view class 31 | ''' 32 | request_path = 'api/general-site-metrics' 33 | view_class = GeneralSiteMetricsView 34 | 35 | # Because we are testing an APIView and not a ViewSetMixin, 36 | # we set the 'get' action to None because the view 'as_view' 37 | # method takes no argument 38 | get_action=None 39 | 40 | @pytest.fixture(autouse=True) 41 | def setup(self, db): 42 | super(TestGeneralSiteMetricsView, self).setup(db) 43 | self.view_class.metrics_method = property( 44 | lambda self: mock_get_monthly_site_metrics) 45 | 46 | def test_get(self): 47 | request = APIRequestFactory().get(self.request_path) 48 | force_authenticate(request, user=self.staff_user) 49 | view = self.view_class.as_view() 50 | response = view(request) 51 | assert response.status_code == 200 52 | 53 | assert response.data == mock_get_monthly_site_metrics() 54 | 55 | --------------------------------------------------------------------------------