From fccfea512dc1e6533fc63488b58634884ce638f3 Mon Sep 17 00:00:00 2001 From: Shadi Naif Date: Fri, 19 Apr 2024 11:42:48 +0300 Subject: [PATCH 1/6] fix: requirements on Python 3.8 instead of 3.10 --- requirements/base.txt | 14 ++++--- requirements/ci.txt | 8 ++-- requirements/dev.txt | 53 ++++++++++++++++--------- requirements/doc.txt | 80 ++++++++++++++++++++++++-------------- requirements/pip-tools.txt | 10 ++++- requirements/pip.txt | 4 +- requirements/quality.txt | 28 +++++++------ requirements/test.in | 3 ++ requirements/test.txt | 28 +++++++------ 9 files changed, 144 insertions(+), 84 deletions(-) diff --git a/requirements/base.txt b/requirements/base.txt index 7c425700..a1f7d9a0 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,21 +1,23 @@ # -# This file is autogenerated by pip-compile with Python 3.10 +# This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # make upgrade # -asgiref==3.7.2 +asgiref==3.8.1 + # via django +backports-zoneinfo==0.2.1 # via django django==4.2.11 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/base.in # django-model-utils -django-model-utils==4.4.0 +django-model-utils==4.5.0 # via -r requirements/base.in -eox-tenant==11.0.2 +eox-tenant==11.2.0 # via -r requirements/base.in -sqlparse==0.4.4 +sqlparse==0.5.0 # via django -typing-extensions==4.10.0 +typing-extensions==4.11.0 # via asgiref diff --git a/requirements/ci.txt b/requirements/ci.txt index cafc7d05..419cd4f8 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.10 +# This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # make upgrade @@ -12,7 +12,7 @@ colorama==0.4.6 # via tox distlib==0.3.8 # via virtualenv -filelock==3.13.1 +filelock==3.13.4 # via # tox # virtualenv @@ -32,7 +32,7 @@ tomli==2.0.1 # via # pyproject-api # tox -tox==4.14.1 +tox==4.14.2 # via -r requirements/ci.in -virtualenv==20.25.1 +virtualenv==20.25.3 # via tox diff --git a/requirements/dev.txt b/requirements/dev.txt index bf415792..88128b4e 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,10 +1,10 @@ # -# This file is autogenerated by pip-compile with Python 3.10 +# This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # make upgrade # -asgiref==3.7.2 +asgiref==3.8.1 # via # -r requirements/quality.txt # django @@ -13,7 +13,12 @@ astroid==3.1.0 # -r requirements/quality.txt # pylint # pylint-celery -build==1.1.1 +backports-zoneinfo==0.2.1 ; python_version < "3.9" + # via + # -r requirements/quality.txt + # django + # djangorestframework +build==1.2.1 # via # -r requirements/pip-tools.txt # pip-tools @@ -38,7 +43,7 @@ click-log==0.4.0 # via # -r requirements/quality.txt # edx-lint -code-annotations==1.7.0 +code-annotations==1.8.0 # via # -r requirements/quality.txt # edx-lint @@ -50,7 +55,7 @@ coverage[toml]==7.4.4 # via # -r requirements/quality.txt # pytest-cov -diff-cover==8.0.3 +diff-cover==9.0.0 # via -r requirements/dev.in dill==0.3.8 # via @@ -73,29 +78,34 @@ django==4.2.11 # openedx-filters django-crum==0.7.9 # via -r requirements/quality.txt -django-model-utils==4.4.0 +django-model-utils==4.5.0 # via -r requirements/quality.txt django-mysql==4.12.0 # via -r requirements/quality.txt -djangorestframework==3.15.0 +djangorestframework==3.15.1 # via -r requirements/quality.txt -edx-i18n-tools==1.3.0 +edx-i18n-tools==1.5.0 # via -r requirements/dev.in edx-lint==5.3.6 # via -r requirements/quality.txt edx-opaque-keys[django]==2.5.1 # via -r requirements/quality.txt -eox-tenant==11.0.2 +eox-tenant==11.2.0 # via -r requirements/quality.txt -exceptiongroup==1.2.0 +exceptiongroup==1.2.1 # via # -r requirements/quality.txt # pytest -filelock==3.13.1 +filelock==3.13.4 # via # -r requirements/ci.txt # tox # virtualenv +importlib-metadata==6.11.0 + # via + # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -r requirements/pip-tools.txt + # build iniconfig==2.0.0 # via # -r requirements/quality.txt @@ -111,7 +121,7 @@ jinja2==3.1.3 # diff-cover jsonfield==3.1.0 # via -r requirements/quality.txt -lxml==5.1.0 +lxml==5.2.1 # via edx-i18n-tools markupsafe==2.1.5 # via @@ -121,7 +131,7 @@ mccabe==0.7.0 # via # -r requirements/quality.txt # pylint -openedx-filters==1.6.0 +openedx-filters==1.8.1 # via -r requirements/quality.txt packaging==24.0 # via @@ -132,7 +142,7 @@ packaging==24.0 # pyproject-api # pytest # tox -path==16.10.0 +path==16.14.0 # via edx-i18n-tools pbr==6.0.0 # via @@ -200,7 +210,7 @@ pytest==8.1.1 # -r requirements/quality.txt # pytest-cov # pytest-django -pytest-cov==4.1.0 +pytest-cov==5.0.0 # via -r requirements/quality.txt pytest-django==4.8.0 # via -r requirements/quality.txt @@ -221,7 +231,7 @@ snowballstemmer==2.2.0 # via # -r requirements/quality.txt # pydocstyle -sqlparse==0.4.4 +sqlparse==0.5.0 # via # -r requirements/quality.txt # django @@ -251,15 +261,16 @@ tomlkit==0.12.4 # via # -r requirements/quality.txt # pylint -tox==4.14.1 +tox==4.14.2 # via -r requirements/ci.txt -typing-extensions==4.10.0 +typing-extensions==4.11.0 # via # -r requirements/quality.txt # asgiref # astroid # edx-opaque-keys -virtualenv==20.25.1 + # pylint +virtualenv==20.25.3 # via # -r requirements/ci.txt # tox @@ -267,6 +278,10 @@ wheel==0.43.0 # via # -r requirements/pip-tools.txt # pip-tools +zipp==3.18.1 + # via + # -r requirements/pip-tools.txt + # importlib-metadata # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/requirements/doc.txt b/requirements/doc.txt index c3cfd5b4..fb948f8c 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -1,14 +1,14 @@ # -# This file is autogenerated by pip-compile with Python 3.10 +# This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # make upgrade # accessible-pygments==0.0.4 # via pydata-sphinx-theme -alabaster==0.7.16 +alabaster==0.7.13 # via sphinx -asgiref==3.7.2 +asgiref==3.8.1 # via # -r requirements/test.txt # django @@ -16,9 +16,16 @@ babel==2.14.0 # via # pydata-sphinx-theme # sphinx +backports-tarfile==1.1.0 + # via jaraco-context +backports-zoneinfo==0.2.1 ; python_version < "3.9" + # via + # -r requirements/test.txt + # django + # djangorestframework beautifulsoup4==4.12.3 # via pydata-sphinx-theme -build==1.1.1 +build==1.2.1 # via -r requirements/doc.in certifi==2024.2.2 # via requests @@ -30,7 +37,7 @@ click==8.1.7 # via # -r requirements/test.txt # code-annotations -code-annotations==1.7.0 +code-annotations==1.8.0 # via -r requirements/test.txt coverage[toml]==7.4.4 # via @@ -50,15 +57,15 @@ django==4.2.11 # openedx-filters django-crum==0.7.9 # via -r requirements/test.txt -django-model-utils==4.4.0 +django-model-utils==4.5.0 # via -r requirements/test.txt django-mysql==4.12.0 # via -r requirements/test.txt -djangorestframework==3.15.0 +djangorestframework==3.15.1 # via -r requirements/test.txt doc8==1.1.1 # via -r requirements/doc.in -docutils==0.20.1 +docutils==0.19 # via # doc8 # pydata-sphinx-theme @@ -67,26 +74,34 @@ docutils==0.20.1 # sphinx edx-opaque-keys[django]==2.5.1 # via -r requirements/test.txt -eox-tenant==11.0.2 +eox-tenant==11.2.0 # via -r requirements/test.txt -exceptiongroup==1.2.0 +exceptiongroup==1.2.1 # via # -r requirements/test.txt # pytest -idna==3.6 +idna==3.7 # via requests imagesize==1.4.1 # via sphinx importlib-metadata==6.11.0 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # build # keyring + # sphinx # twine +importlib-resources==6.4.0 + # via keyring iniconfig==2.0.0 # via # -r requirements/test.txt # pytest -jaraco-classes==3.3.1 +jaraco-classes==3.4.0 + # via keyring +jaraco-context==5.3.0 + # via keyring +jaraco-functools==4.0.1 # via keyring jeepney==0.8.0 # via @@ -99,7 +114,7 @@ jinja2==3.1.3 # sphinx jsonfield==3.1.0 # via -r requirements/test.txt -keyring==24.3.1 +keyring==25.1.0 # via twine markdown-it-py==3.0.0 # via rich @@ -110,10 +125,12 @@ markupsafe==2.1.5 mdurl==0.1.2 # via markdown-it-py more-itertools==10.2.0 - # via jaraco-classes -nh3==0.2.15 + # via + # jaraco-classes + # jaraco-functools +nh3==0.2.17 # via readme-renderer -openedx-filters==1.6.0 +openedx-filters==1.8.1 # via -r requirements/test.txt packaging==24.0 # via @@ -132,9 +149,9 @@ pluggy==1.4.0 # via # -r requirements/test.txt # pytest -pycparser==2.21 +pycparser==2.22 # via cffi -pydata-sphinx-theme==0.15.2 +pydata-sphinx-theme==0.14.4 # via sphinx-book-theme pygments==2.17.2 # via @@ -155,7 +172,7 @@ pytest==8.1.1 # -r requirements/test.txt # pytest-cov # pytest-django -pytest-cov==4.1.0 +pytest-cov==5.0.0 # via -r requirements/test.txt pytest-django==4.8.0 # via -r requirements/test.txt @@ -163,6 +180,8 @@ python-slugify==8.0.4 # via # -r requirements/test.txt # code-annotations +pytz==2024.1 + # via babel pyyaml==6.0.1 # via # -r requirements/test.txt @@ -190,26 +209,26 @@ snowballstemmer==2.2.0 # via sphinx soupsieve==2.5 # via beautifulsoup4 -sphinx==7.2.6 +sphinx==6.2.1 # via # -r requirements/doc.in # pydata-sphinx-theme # sphinx-book-theme -sphinx-book-theme==1.1.2 +sphinx-book-theme==1.0.1 # via -r requirements/doc.in -sphinxcontrib-applehelp==1.0.8 +sphinxcontrib-applehelp==1.0.4 # via sphinx -sphinxcontrib-devhelp==1.0.6 +sphinxcontrib-devhelp==1.0.2 # via sphinx -sphinxcontrib-htmlhelp==2.0.5 +sphinxcontrib-htmlhelp==2.0.1 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx -sphinxcontrib-qthelp==1.0.7 +sphinxcontrib-qthelp==1.0.3 # via sphinx -sphinxcontrib-serializinghtml==1.1.10 +sphinxcontrib-serializinghtml==1.1.5 # via sphinx -sqlparse==0.4.4 +sqlparse==0.5.0 # via # -r requirements/test.txt # django @@ -233,15 +252,18 @@ tomli==2.0.1 # pytest twine==5.0.0 # via -r requirements/doc.in -typing-extensions==4.10.0 +typing-extensions==4.11.0 # via # -r requirements/test.txt # asgiref # edx-opaque-keys # pydata-sphinx-theme + # rich urllib3==2.2.1 # via # requests # twine zipp==3.18.1 - # via importlib-metadata + # via + # importlib-metadata + # importlib-resources diff --git a/requirements/pip-tools.txt b/requirements/pip-tools.txt index 080b3921..748bf44e 100644 --- a/requirements/pip-tools.txt +++ b/requirements/pip-tools.txt @@ -1,13 +1,17 @@ # -# This file is autogenerated by pip-compile with Python 3.10 +# This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # make upgrade # -build==1.1.1 +build==1.2.1 # via pip-tools click==8.1.7 # via pip-tools +importlib-metadata==6.11.0 + # via + # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # build packaging==24.0 # via build pip-tools==7.4.1 @@ -23,6 +27,8 @@ tomli==2.0.1 # pyproject-hooks wheel==0.43.0 # via pip-tools +zipp==3.18.1 + # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/requirements/pip.txt b/requirements/pip.txt index 9c6ea0da..e3ffcc7b 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.10 +# This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # make upgrade @@ -10,5 +10,5 @@ wheel==0.43.0 # The following packages are considered to be unsafe in a requirements file: pip==24.0 # via -r requirements/pip.in -setuptools==69.2.0 +setuptools==69.5.1 # via -r requirements/pip.in diff --git a/requirements/quality.txt b/requirements/quality.txt index 1744ec19..3c7fbffb 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -1,10 +1,10 @@ # -# This file is autogenerated by pip-compile with Python 3.10 +# This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # make upgrade # -asgiref==3.7.2 +asgiref==3.8.1 # via # -r requirements/test.txt # django @@ -12,6 +12,11 @@ astroid==3.1.0 # via # pylint # pylint-celery +backports-zoneinfo==0.2.1 ; python_version < "3.9" + # via + # -r requirements/test.txt + # django + # djangorestframework click==8.1.7 # via # -r requirements/test.txt @@ -20,7 +25,7 @@ click==8.1.7 # edx-lint click-log==0.4.0 # via edx-lint -code-annotations==1.7.0 +code-annotations==1.8.0 # via # -r requirements/test.txt # edx-lint @@ -42,19 +47,19 @@ django==4.2.11 # openedx-filters django-crum==0.7.9 # via -r requirements/test.txt -django-model-utils==4.4.0 +django-model-utils==4.5.0 # via -r requirements/test.txt django-mysql==4.12.0 # via -r requirements/test.txt -djangorestframework==3.15.0 +djangorestframework==3.15.1 # via -r requirements/test.txt edx-lint==5.3.6 # via -r requirements/quality.in edx-opaque-keys[django]==2.5.1 # via -r requirements/test.txt -eox-tenant==11.0.2 +eox-tenant==11.2.0 # via -r requirements/test.txt -exceptiongroup==1.2.0 +exceptiongroup==1.2.1 # via # -r requirements/test.txt # pytest @@ -78,7 +83,7 @@ markupsafe==2.1.5 # jinja2 mccabe==0.7.0 # via pylint -openedx-filters==1.6.0 +openedx-filters==1.8.1 # via -r requirements/test.txt packaging==24.0 # via @@ -121,7 +126,7 @@ pytest==8.1.1 # -r requirements/test.txt # pytest-cov # pytest-django -pytest-cov==4.1.0 +pytest-cov==5.0.0 # via -r requirements/test.txt pytest-django==4.8.0 # via -r requirements/test.txt @@ -139,7 +144,7 @@ six==1.16.0 # edx-lint snowballstemmer==2.2.0 # via pydocstyle -sqlparse==0.4.4 +sqlparse==0.5.0 # via # -r requirements/test.txt # django @@ -160,9 +165,10 @@ tomli==2.0.1 # pytest tomlkit==0.12.4 # via pylint -typing-extensions==4.10.0 +typing-extensions==4.11.0 # via # -r requirements/test.txt # asgiref # astroid # edx-opaque-keys + # pylint diff --git a/requirements/test.in b/requirements/test.in index 33048682..04453e60 100644 --- a/requirements/test.in +++ b/requirements/test.in @@ -15,3 +15,6 @@ django-mysql jsonfield edx-opaque-keys[django] openedx_filters + +# Python 3.8 compatibility +backports.zoneinfo;python_version<"3.9" diff --git a/requirements/test.txt b/requirements/test.txt index c3767af2..12b89742 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,16 +1,22 @@ # -# This file is autogenerated by pip-compile with Python 3.10 +# This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # make upgrade # -asgiref==3.7.2 +asgiref==3.8.1 # via # -r requirements/base.txt # django +backports-zoneinfo==0.2.1 ; python_version < "3.9" + # via + # -r requirements/base.txt + # -r requirements/test.in + # django + # djangorestframework click==8.1.7 # via code-annotations -code-annotations==1.7.0 +code-annotations==1.8.0 # via -r requirements/test.in coverage[toml]==7.4.4 # via pytest-cov @@ -25,17 +31,17 @@ coverage[toml]==7.4.4 # openedx-filters django-crum==0.7.9 # via -r requirements/test.in -django-model-utils==4.4.0 +django-model-utils==4.5.0 # via -r requirements/base.txt django-mysql==4.12.0 # via -r requirements/test.in -djangorestframework==3.15.0 +djangorestframework==3.15.1 # via -r requirements/test.in edx-opaque-keys[django]==2.5.1 # via -r requirements/test.in -eox-tenant==11.0.2 +eox-tenant==11.2.0 # via -r requirements/base.txt -exceptiongroup==1.2.0 +exceptiongroup==1.2.1 # via pytest iniconfig==2.0.0 # via pytest @@ -45,7 +51,7 @@ jsonfield==3.1.0 # via -r requirements/test.in markupsafe==2.1.5 # via jinja2 -openedx-filters==1.6.0 +openedx-filters==1.8.1 # via -r requirements/test.in packaging==24.0 # via pytest @@ -59,7 +65,7 @@ pytest==8.1.1 # via # pytest-cov # pytest-django -pytest-cov==4.1.0 +pytest-cov==5.0.0 # via -r requirements/test.in pytest-django==4.8.0 # via -r requirements/test.in @@ -69,7 +75,7 @@ pyyaml==6.0.1 # via code-annotations six==1.16.0 # via -r requirements/test.in -sqlparse==0.4.4 +sqlparse==0.5.0 # via # -r requirements/base.txt # django @@ -83,7 +89,7 @@ tomli==2.0.1 # via # coverage # pytest -typing-extensions==4.10.0 +typing-extensions==4.11.0 # via # -r requirements/base.txt # asgiref From 8adc75b077415830967303596783696169a6a2df Mon Sep 17 00:00:00 2001 From: Shadi Naif Date: Thu, 18 Apr 2024 18:48:11 +0300 Subject: [PATCH 2/6] fix: code quality issues --- tests/__init__.py | 0 tests/test_dashboard/test_details/__init__.py | 0 .../test_details/test_details_learners.py | 4 +--- tests/test_dashboard/test_statistics/__init__.py | 0 .../test_statistics/test_certificates.py | 4 ++-- .../test_statistics/test_learners.py | 14 ++++++++++---- tests/test_helpers/__init__.py | 0 tests/test_helpers/test_permissions.py | 8 +++++--- 8 files changed, 18 insertions(+), 12 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/test_dashboard/test_details/__init__.py create mode 100644 tests/test_dashboard/test_statistics/__init__.py create mode 100644 tests/test_helpers/__init__.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_dashboard/test_details/__init__.py b/tests/test_dashboard/test_details/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_dashboard/test_details/test_details_learners.py b/tests/test_dashboard/test_details/test_details_learners.py index 2118d009..90c287c6 100644 --- a/tests/test_dashboard/test_details/test_details_learners.py +++ b/tests/test_dashboard/test_details/test_details_learners.py @@ -1,9 +1,7 @@ """Tests for learner details collectors""" import pytest -from django.db.models import Sum from futurex_openedx_extensions.dashboard.details.learners import get_learners_queryset -from tests.base_test_data import expected_statistics @pytest.mark.django_db @@ -16,6 +14,6 @@ ([7], 'user6', 0), ([4], None, 0), ]) -def test_get_learners_queryset(base_data, tenant_ids, search_text, expected_count): +def test_get_learners_queryset(base_data, tenant_ids, search_text, expected_count): # pylint: disable=unused-argument """Verify that get_learners_queryset returns the correct QuerySet.""" assert get_learners_queryset(tenant_ids, search_text).count() == expected_count diff --git a/tests/test_dashboard/test_statistics/__init__.py b/tests/test_dashboard/test_statistics/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_dashboard/test_statistics/test_certificates.py b/tests/test_dashboard/test_statistics/test_certificates.py index 67a68b41..e6dadabb 100644 --- a/tests/test_dashboard/test_statistics/test_certificates.py +++ b/tests/test_dashboard/test_statistics/test_certificates.py @@ -19,7 +19,7 @@ ([2, 7], {'ORG3': 7, 'ORG8': 2}), ([7, 8], {'ORG3': 7, 'ORG8': 2}), ]) -def test_get_certificates_count(base_data, tenant_ids, expected_result): +def test_get_certificates_count(base_data, tenant_ids, expected_result): # pylint: disable=unused-argument """Verify get_certificates_count function.""" result = certificates.get_certificates_count(tenant_ids) assert result == expected_result, \ @@ -27,7 +27,7 @@ def test_get_certificates_count(base_data, tenant_ids, expected_result): @pytest.mark.django_db -def test_get_certificates_count_not_downloadable(base_data): +def test_get_certificates_count_not_downloadable(base_data): # pylint: disable=unused-argument """Verify get_certificates_count function with empty tenant_ids.""" result = certificates.get_certificates_count([1]) assert result == {'ORG1': 4, 'ORG2': 10}, f'Wrong certificates result. expected: {result}' diff --git a/tests/test_dashboard/test_statistics/test_learners.py b/tests/test_dashboard/test_statistics/test_learners.py index 3c1a6474..87993392 100644 --- a/tests/test_dashboard/test_statistics/test_learners.py +++ b/tests/test_dashboard/test_statistics/test_learners.py @@ -15,7 +15,9 @@ (7, {'ORG3': 13}), (8, {'ORG8': 6}), ]) -def test_get_learners_count_having_enrollment_per_org(base_data, tenant_id, expected_result): +def test_get_learners_count_having_enrollment_per_org( + base_data, tenant_id, expected_result +): # pylint: disable=unused-argument """Test get_learners_count_having_enrollment_per_org function.""" result = learners.get_learners_count_having_enrollment_per_org(tenant_id) assert result.count() == len(expected_result), 'Wrong number of organizations returned' @@ -37,7 +39,9 @@ def test_get_learners_count_having_enrollment_per_org(base_data, tenant_id, expe (7, 13), (8, 6), ]) -def test_get_learners_count_having_enrollment_for_tenant(base_data, tenant_id, expected_result): +def test_get_learners_count_having_enrollment_for_tenant( + base_data, tenant_id, expected_result +): # pylint: disable=unused-argument """Test get_learners_count_having_enrollment_for_tenant function.""" result = learners.get_learners_count_having_enrollment_for_tenant(tenant_id) assert result == expected_result, f'Wrong learners count: {result} for tenant: {tenant_id}' @@ -54,14 +58,16 @@ def test_get_learners_count_having_enrollment_for_tenant(base_data, tenant_id, e (7, 4), (8, 3), ]) -def test_get_learners_count_having_no_enrollment(base_data, tenant_id, expected_result): +def test_get_learners_count_having_no_enrollment( + base_data, tenant_id, expected_result +): # pylint: disable=unused-argument """Test get_learners_count_having_no_enrollment function.""" result = learners.get_learners_count_having_no_enrollment(tenant_id) assert result == expected_result, f'Wrong learners count: {result} for tenant: {tenant_id}' @pytest.mark.django_db -def test_get_learners_count(base_data): +def test_get_learners_count(base_data): # pylint: disable=unused-argument """Test get_learners_count function.""" result = learners.get_learners_count([1, 2, 4]) assert result == { diff --git a/tests/test_helpers/__init__.py b/tests/test_helpers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_helpers/test_permissions.py b/tests/test_helpers/test_permissions.py index c9c7c48a..a4b9d988 100644 --- a/tests/test_helpers/test_permissions.py +++ b/tests/test_helpers/test_permissions.py @@ -26,7 +26,7 @@ def set_user(request, user_id): ('GET', '?tenant_ids=1,2,3', 2), ('GET', '', 1), ]) -def test_has_tenant_access(base_data, method, query_params, user_id): +def test_has_tenant_access(base_data, method, query_params, user_id): # pylint: disable=unused-argument """Verify that HasTenantAccess returns True when user has access to all tenants.""" permission = HasTenantAccess() request = APIRequestFactory().generic(method, f'/dummy/{query_params}') @@ -40,7 +40,9 @@ def test_has_tenant_access(base_data, method, query_params, user_id): ('GET', '?tenant_ids=1,2,3', 6, 'denied', [1, 2, 3]), ('GET', '?tenant_ids=1,2,3,4,9', 1, 'invalid', [9, 4]), ]) -def test_has_tenant_access_no_access(base_data, method, query_params, user_id, reason, bad_tenant_ids): +def test_has_tenant_access_no_access( + base_data, method, query_params, user_id, reason, bad_tenant_ids +): # pylint: disable=unused-argument """Verify that PermissionDenied is raised when user does not have access to one of the tenants.""" permission = HasTenantAccess() request = APIRequestFactory().generic(method, f'/dummy/{query_params}') @@ -63,7 +65,7 @@ def test_has_tenant_access_no_access(base_data, method, query_params, user_id, r @pytest.mark.django_db @pytest.mark.parametrize('user_id', [0, None]) -def test_has_tenant_access_not_authenticated(base_data, user_id): +def test_has_tenant_access_not_authenticated(base_data, user_id): # pylint: disable=unused-argument """Verify that NotAuthenticated is raised when user is not authenticated.""" permission = HasTenantAccess() request = APIRequestFactory().generic('GET', '/dummy/') From 5c1c0c8aa08d21c206424c0c2f3d4a8cfef1da61 Mon Sep 17 00:00:00 2001 From: Shadi Naif Date: Thu, 18 Apr 2024 17:47:04 +0300 Subject: [PATCH 3/6] feat: course details and course statuses APIs --- .../dashboard/details/courses.py | 100 ++++++++++++++ .../dashboard/serializers.py | 89 +++++++++++- .../dashboard/statistics/courses.py | 36 ++++- futurex_openedx_extensions/dashboard/urls.py | 8 +- futurex_openedx_extensions/dashboard/views.py | 75 +++++++++-- .../helpers/constants.py | 8 ++ futurex_openedx_extensions/helpers/filters.py | 6 + futurex_openedx_extensions/helpers/tenants.py | 29 ++++ .../eox_nelp/course_experience/models.py | 2 + .../edx_platform_mocks/fake_models/models.py | 37 +++++ test_utils/edx_platform_mocks/setup.py | 2 +- test_utils/eox_settings.py | 8 +- tests/__init__.py | 0 tests/base_test_data.py | 35 +++++ tests/conftest.py | 23 ++++ .../test_details/test_details_courses.py | 65 +++++++++ .../test_statistics/test_courses.py | 43 +++--- tests/test_dashboard/test_views.py | 127 ++++++++++++++++-- tests/test_helpers/test_filters.py | 10 ++ tests/test_helpers/test_pagination.py | 4 +- tests/test_helpers/test_tenants.py | 44 ++++-- tox.ini | 3 + 22 files changed, 681 insertions(+), 73 deletions(-) create mode 100644 futurex_openedx_extensions/dashboard/details/courses.py create mode 100644 futurex_openedx_extensions/helpers/constants.py create mode 100644 futurex_openedx_extensions/helpers/filters.py create mode 100644 test_utils/edx_platform_mocks/eox_nelp/course_experience/models.py delete mode 100644 tests/__init__.py create mode 100644 tests/test_dashboard/test_details/test_details_courses.py create mode 100644 tests/test_helpers/test_filters.py diff --git a/futurex_openedx_extensions/dashboard/details/courses.py b/futurex_openedx_extensions/dashboard/details/courses.py new file mode 100644 index 00000000..665d3b22 --- /dev/null +++ b/futurex_openedx_extensions/dashboard/details/courses.py @@ -0,0 +1,100 @@ +"""Courses details collectors""" +from __future__ import annotations + +from typing import List + +from common.djangoapps.student.models import CourseAccessRole +from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Subquery, Sum +from django.db.models.functions import Coalesce +from django.db.models.query import QuerySet +from eox_nelp.course_experience.models import FeedbackCourse +from lms.djangoapps.certificates.models import GeneratedCertificate +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview + +from futurex_openedx_extensions.helpers.tenants import get_course_org_filter_list, get_tenant_site + + +def get_courses_queryset(tenant_ids: List, search_text: str = None) -> QuerySet: + """ + Get the courses queryset for the given tenant IDs and search text. + + :param tenant_ids: List of tenant IDs to get the courses for + :type tenant_ids: List + :param search_text: Search text to filter the courses by + :type search_text: str + """ + course_org_filter_list = get_course_org_filter_list(tenant_ids)['course_org_filter_list'] + tenant_sites = [] + for tenant_id in tenant_ids: + if site := get_tenant_site(tenant_id): + tenant_sites.append(site) + + queryset = CourseOverview.objects.filter( + org__in=course_org_filter_list, + ) + search_text = (search_text or '').strip() + if search_text: + queryset = queryset.filter( + Q(display_name__icontains=search_text) | + Q(id__icontains=search_text), + ) + queryset = queryset.annotate( + rating_count=Coalesce(Subquery( + FeedbackCourse.objects.filter( + course_id=OuterRef('id'), + rating_content__isnull=False, + rating_content__gt=0, + ).values('course_id').annotate(count=Count('id')).values('count'), + output_field=IntegerField(), + ), 0), + ).annotate( + rating_total=Coalesce(Subquery( + FeedbackCourse.objects.filter( + course_id=OuterRef('id'), + rating_content__isnull=False, + rating_content__gt=0, + ).values('course_id').annotate(total=Sum('rating_content')).values('total'), + ), 0), + ).annotate( + enrolled_count=Count( + 'courseenrollment', + filter=( + Q(courseenrollment__is_active=True) & + Q(courseenrollment__user__is_active=True) & + Q(courseenrollment__user__is_staff=False) & + Q(courseenrollment__user__is_superuser=False) & + ~Exists( + CourseAccessRole.objects.filter( + user_id=OuterRef('courseenrollment__user_id'), + org=OuterRef('org'), + ), + ) + ), + ) + ).annotate( + active_count=Count( + 'courseenrollment', + filter=( + Q(courseenrollment__is_active=True) & + Q(courseenrollment__user__is_active=True) & + Q(courseenrollment__user__is_staff=False) & + Q(courseenrollment__user__is_superuser=False) & + ~Exists( + CourseAccessRole.objects.filter( + user_id=OuterRef('courseenrollment__user_id'), + org=OuterRef('org'), + ), + ) + ), + ) + ).annotate( + certificates_count=Coalesce(Subquery( + GeneratedCertificate.objects.filter( + course_id=OuterRef('id'), + status='downloadable' + ).values('course_id').annotate(count=Count('id')).values('count'), + output_field=IntegerField(), + ), 0), + ) + + return queryset diff --git a/futurex_openedx_extensions/dashboard/serializers.py b/futurex_openedx_extensions/dashboard/serializers.py index 99a4d1f5..88150714 100644 --- a/futurex_openedx_extensions/dashboard/serializers.py +++ b/futurex_openedx_extensions/dashboard/serializers.py @@ -1,8 +1,12 @@ """Serializers for the dashboard details API.""" - from django.contrib.auth import get_user_model +from django.utils.timezone import now +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from rest_framework import serializers +from futurex_openedx_extensions.helpers.constants import COURSE_STATUS_SELF_PREFIX, COURSE_STATUSES +from futurex_openedx_extensions.helpers.tenants import get_tenants_by_org + class LearnerDetailsSerializer(serializers.ModelSerializer): """Serializer for learner details.""" @@ -66,3 +70,86 @@ def get_certificates_count(self, obj): def get_enrolled_courses_count(self, obj): """Return enrolled courses count.""" return obj.courses_count + + +class CourseDetailsSerializer(serializers.ModelSerializer): + """Serializer for course details.""" + status = serializers.SerializerMethodField() + rating = serializers.SerializerMethodField() + enrolled_count = serializers.IntegerField() + active_count = serializers.IntegerField() + certificates_count = serializers.IntegerField() + start_date = serializers.SerializerMethodField() + end_date = serializers.SerializerMethodField() + start_enrollment_date = serializers.SerializerMethodField() + end_enrollment_date = serializers.SerializerMethodField() + display_name = serializers.CharField() + image_url = serializers.SerializerMethodField() + org = serializers.CharField() + tenant_ids = serializers.SerializerMethodField() + author_name = serializers.SerializerMethodField() + + class Meta: + model = CourseOverview + fields = [ + 'id', + 'status', + 'self_paced', + 'rating', + 'enrolled_count', + 'active_count', + 'certificates_count', + 'start_date', + 'end_date', + 'start_enrollment_date', + 'end_enrollment_date', + 'display_name', + 'image_url', + 'org', + 'tenant_ids', + 'author_name', + ] + + def get_status(self, obj): + """Return the course status.""" + now_time = now() + if obj.end and obj.end < now_time: + status = COURSE_STATUSES['archived'] + elif obj.start and obj.start > now_time: + status = COURSE_STATUSES['upcoming'] + else: + status = COURSE_STATUSES['active'] + + return f'{COURSE_STATUS_SELF_PREFIX if obj.self_paced else ""}{status}' + + def get_rating(self, obj): + """Return the course rating.""" + return round(obj.rating_total / obj.rating_count if obj.rating_count else 0, 1) + + def get_start_enrollment_date(self, obj): + """Return the start enrollment date.""" + return obj.enrollment_start + + def get_end_enrollment_date(self, obj): + """Return the end enrollment date.""" + return obj.enrollment_end + + def get_image_url(self, obj): + """Return the course image URL.""" + return obj.course_image_url + + def get_tenant_ids(self, obj): + """Return the tenant IDs.""" + return get_tenants_by_org(obj.org) + + def get_start_date(self, obj): + """Return the start date.""" + return obj.start + + def get_end_date(self, obj): + """Return the end date.""" + return obj.end + + def get_author_name(self, obj): # pylint: disable=unused-argument + """Return the author name.""" + return None diff --git a/futurex_openedx_extensions/dashboard/statistics/courses.py b/futurex_openedx_extensions/dashboard/statistics/courses.py index 7008f165..3b8c80a9 100644 --- a/futurex_openedx_extensions/dashboard/statistics/courses.py +++ b/futurex_openedx_extensions/dashboard/statistics/courses.py @@ -3,11 +3,12 @@ from typing import List -from django.db.models import Count, Q +from django.db.models import Case, CharField, Count, Q, Value, When from django.db.models.query import QuerySet from django.utils.timezone import now from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from futurex_openedx_extensions.helpers.constants import COURSE_STATUSES from futurex_openedx_extensions.helpers.tenants import get_course_org_filter_list @@ -39,3 +40,36 @@ def get_courses_count(tenant_ids: List[int], only_active=False, only_visible=Fal return q_set.values('org').annotate( courses_count=Count('id') ).order_by('org') + + +def get_courses_count_by_status(tenant_ids: List[int]) -> QuerySet: + """ + Get the count of courses in the given tenants by status + + :param tenant_ids: List of tenant IDs to get the count for + :type tenant_ids: List[int] + :return: QuerySet of courses count per organization and status + :rtype: QuerySet + """ + course_org_filter_list = get_course_org_filter_list(tenant_ids)['course_org_filter_list'] + + q_set = CourseOverview.objects.filter( + org__in=course_org_filter_list + ).annotate( + status=Case( + When( + Q(end__isnull=False) & Q(end__lt=now()), + then=Value(COURSE_STATUSES['archived']) + ), + When( + Q(start__isnull=False) & Q(start__gt=now()), + then=Value(COURSE_STATUSES['upcoming']) + ), + default=Value(COURSE_STATUSES['active']), + output_field=CharField() + ) + ).values('status', 'self_paced').annotate( + courses_count=Count('id') + ).values('status', 'self_paced', 'courses_count') + + return q_set diff --git a/futurex_openedx_extensions/dashboard/urls.py b/futurex_openedx_extensions/dashboard/urls.py index 1bb15397..292af796 100644 --- a/futurex_openedx_extensions/dashboard/urls.py +++ b/futurex_openedx_extensions/dashboard/urls.py @@ -3,11 +3,13 @@ """ from django.urls import re_path -from futurex_openedx_extensions.dashboard.views import LearnersView, TotalCountsView +from futurex_openedx_extensions.dashboard import views app_name = 'fx_dashboard' urlpatterns = [ - re_path(r'^api/fx/statistics/v1/total_counts', TotalCountsView.as_view(), name='total-counts'), - re_path(r'^api/fx/learners/v1/learners', LearnersView.as_view(), name='learners'), + re_path(r'^api/fx/courses/v1/courses', views.CoursesView.as_view(), name='courses'), + re_path(r'^api/fx/learners/v1/learners', views.LearnersView.as_view(), name='learners'), + re_path(r'^api/fx/statistics/v1/course_statuses', views.CourseStatusesView.as_view(), name='course-statuses'), + re_path(r'^api/fx/statistics/v1/total_counts', views.TotalCountsView.as_view(), name='total-counts'), ] diff --git a/futurex_openedx_extensions/dashboard/views.py b/futurex_openedx_extensions/dashboard/views.py index 05c2d74f..a620dd9c 100644 --- a/futurex_openedx_extensions/dashboard/views.py +++ b/futurex_openedx_extensions/dashboard/views.py @@ -4,15 +4,18 @@ from rest_framework.response import Response from rest_framework.views import APIView +from futurex_openedx_extensions.dashboard.details.courses import get_courses_queryset from futurex_openedx_extensions.dashboard.details.learners import get_learners_queryset -from futurex_openedx_extensions.dashboard.serializers import LearnerDetailsSerializer +from futurex_openedx_extensions.dashboard.serializers import CourseDetailsSerializer, LearnerDetailsSerializer from futurex_openedx_extensions.dashboard.statistics.certificates import get_certificates_count -from futurex_openedx_extensions.dashboard.statistics.courses import get_courses_count +from futurex_openedx_extensions.dashboard.statistics.courses import get_courses_count, get_courses_count_by_status from futurex_openedx_extensions.dashboard.statistics.learners import get_learners_count -from futurex_openedx_extensions.helpers.converters import error_details_to_dictionary, ids_string_to_list +from futurex_openedx_extensions.helpers.constants import COURSE_STATUS_SELF_PREFIX, COURSE_STATUSES +from futurex_openedx_extensions.helpers.converters import error_details_to_dictionary +from futurex_openedx_extensions.helpers.filters import DefaultOrderingFilter from futurex_openedx_extensions.helpers.pagination import DefaultPagination from futurex_openedx_extensions.helpers.permissions import HasTenantAccess -from futurex_openedx_extensions.helpers.tenants import get_accessible_tenant_ids +from futurex_openedx_extensions.helpers.tenants import get_selected_tenants class TotalCountsView(APIView): @@ -76,11 +79,7 @@ def get(self, request, *args, **kwargs): if invalid_stats: return Response(error_details_to_dictionary(reason="Invalid stats type", invalid=invalid_stats), status=400) - tenant_ids = request.query_params.get('tenant_ids') - if tenant_ids is None: - tenant_ids = get_accessible_tenant_ids(request.user) - else: - tenant_ids = ids_string_to_list(tenant_ids) + tenant_ids = get_selected_tenants(request) result = dict({tenant_id: {} for tenant_id in tenant_ids}) result.update({ @@ -103,9 +102,63 @@ class LearnersView(ListAPIView): def get_queryset(self): """Get the list of learners""" - tenant_ids = self.request.query_params.get('tenant_ids') + tenant_ids = get_selected_tenants(self.request) search_text = self.request.query_params.get('search_text') return get_learners_queryset( - tenant_ids=ids_string_to_list(tenant_ids) if tenant_ids else get_accessible_tenant_ids(self.request.user), + tenant_ids=tenant_ids, search_text=search_text, ) + + +class CoursesView(ListAPIView): + """View to get the list of courses""" + serializer_class = CourseDetailsSerializer + permission_classes = [HasTenantAccess] + pagination_class = DefaultPagination + filter_backends = [DefaultOrderingFilter] + ordering_fields = [ + 'id', 'self_paced', 'enrolled_count', 'active_count', + 'certificates_count', 'display_name', 'org', + ] + ordering = ['display_name'] + + def get_queryset(self): + """Get the list of learners""" + tenant_ids = get_selected_tenants(self.request) + search_text = self.request.query_params.get('search_text') + return get_courses_queryset( + tenant_ids=tenant_ids, + search_text=search_text, + ) + + +class CourseStatusesView(APIView): + """View to get the course statuses""" + permission_classes = [HasTenantAccess] + + @staticmethod + def to_json(result): + """Convert the result to JSON format""" + dict_result = { + f"{COURSE_STATUS_SELF_PREFIX if self_paced else ''}{status}": 0 + for status in COURSE_STATUSES + for self_paced in [False, True] + } + + for item in result: + status = f"{COURSE_STATUS_SELF_PREFIX if item['self_paced'] else ''}{item['status']}" + dict_result[status] = item['courses_count'] + return dict_result + + def get(self, request, *args, **kwargs): + """ + GET /api/fx/statistics/v1/course_statuses/?tenant_ids= + + (optional): a comma-separated list of the tenant IDs to get the information for. If not provided, + the API will assume the list of all accessible tenants by the user + """ + tenant_ids = get_selected_tenants(request) + + result = get_courses_count_by_status(tenant_ids=tenant_ids) + + return JsonResponse(self.to_json(result)) diff --git a/futurex_openedx_extensions/helpers/constants.py b/futurex_openedx_extensions/helpers/constants.py new file mode 100644 index 00000000..970fe557 --- /dev/null +++ b/futurex_openedx_extensions/helpers/constants.py @@ -0,0 +1,8 @@ +"""Constants for the FutureX Open edX Extensions app.""" +COURSE_STATUSES = { + 'active': 'active', + 'archived': 'archived', + 'upcoming': 'upcoming', +} + +COURSE_STATUS_SELF_PREFIX = 'self_' diff --git a/futurex_openedx_extensions/helpers/filters.py b/futurex_openedx_extensions/helpers/filters.py new file mode 100644 index 00000000..39d65910 --- /dev/null +++ b/futurex_openedx_extensions/helpers/filters.py @@ -0,0 +1,6 @@ +"""Filters helpers and classes for the API views.""" +from rest_framework.filters import OrderingFilter + + +class DefaultOrderingFilter(OrderingFilter): + ordering_param = 'sort' diff --git a/futurex_openedx_extensions/helpers/tenants.py b/futurex_openedx_extensions/helpers/tenants.py index 0989d93f..dbf0f6d5 100644 --- a/futurex_openedx_extensions/helpers/tenants.py +++ b/futurex_openedx_extensions/helpers/tenants.py @@ -8,6 +8,7 @@ from django.db.models import Exists, OuterRef from django.db.models.query import QuerySet from eox_tenant.models import Route, TenantConfig +from rest_framework.request import Request from futurex_openedx_extensions.helpers.converters import error_details_to_dictionary, ids_string_to_list @@ -225,3 +226,31 @@ def check_tenant_access(user: get_user_model(), tenant_ids_string: str) -> tuple ) return True, {} + + +def get_tenants_by_org(org: str) -> List[int]: + """ + Get the tenants that have in their course org filter + + :param org: The org to check + :type org: str + :return: List of tenant IDs + :rtype: List[int] + """ + tenant_configs = get_all_course_org_filter_list() + return [t_id for t_id, course_org_filter in tenant_configs.items() if org in course_org_filter] + + +def get_selected_tenants(request: Request) -> List[int]: + """ + Get the tenant IDs from the request + + :param request: The request + :type request: Request + :return: List of tenant IDs + :rtype: List[int] + """ + tenant_ids = request.query_params.get('tenant_ids') + if tenant_ids is None: + return get_accessible_tenant_ids(request.user) + return ids_string_to_list(tenant_ids) diff --git a/test_utils/edx_platform_mocks/eox_nelp/course_experience/models.py b/test_utils/edx_platform_mocks/eox_nelp/course_experience/models.py new file mode 100644 index 00000000..3d9c88a2 --- /dev/null +++ b/test_utils/edx_platform_mocks/eox_nelp/course_experience/models.py @@ -0,0 +1,2 @@ +"""edx-platform Mocks""" +from fake_models.models import FeedbackCourse # pylint: disable=unused-import diff --git a/test_utils/edx_platform_mocks/fake_models/models.py b/test_utils/edx_platform_mocks/fake_models/models.py index 9dca4e23..e6ad4c51 100644 --- a/test_utils/edx_platform_mocks/fake_models/models.py +++ b/test_utils/edx_platform_mocks/fake_models/models.py @@ -11,6 +11,11 @@ class CourseOverview(models.Model): visible_to_staff_only = models.BooleanField() start = models.DateTimeField(null=True) end = models.DateTimeField(null=True) + display_name = models.TextField(null=True) + enrollment_start = models.DateTimeField(null=True) + enrollment_end = models.DateTimeField(null=True) + self_paced = models.BooleanField(default=False) + course_image_url = models.TextField() class Meta: app_label = "fake_models" @@ -89,3 +94,35 @@ def has_profile_image(self): class Meta: app_label = "fake_models" db_table = "auth_userprofile" + + +class BaseFeedback(models.Model): + """Mock""" + RATING_OPTIONS = [ + (0, '0'), + (1, '1'), + (2, '2'), + (3, '3'), + (4, '4'), + (5, '5') + ] + author = models.ForeignKey(get_user_model(), null=True, on_delete=models.SET_NULL) + rating_content = models.IntegerField(blank=True, null=True, choices=RATING_OPTIONS) + feedback = models.CharField(max_length=500, blank=True, null=True) + public = models.BooleanField(null=True, default=False) + course_id = models.ForeignKey(CourseOverview, null=True, on_delete=models.SET_NULL) + + class Meta: + """Set model abstract""" + abstract = True + + +class FeedbackCourse(BaseFeedback): + """Mock""" + rating_instructors = models.IntegerField(blank=True, null=True, choices=BaseFeedback.RATING_OPTIONS) + recommended = models.BooleanField(null=True, default=True) + + class Meta: + """Set constrain for author an course id""" + unique_together = [["author", "course_id"]] + db_table = "eox_nelp_feedbackcourse" diff --git a/test_utils/edx_platform_mocks/setup.py b/test_utils/edx_platform_mocks/setup.py index 2353e348..b81a1482 100644 --- a/test_utils/edx_platform_mocks/setup.py +++ b/test_utils/edx_platform_mocks/setup.py @@ -4,5 +4,5 @@ setup( name='edx_platform_mocks', version='0.1.0', - packages=['common', 'fake_models', 'lms', 'openedx'], + packages=[], ) diff --git a/test_utils/eox_settings.py b/test_utils/eox_settings.py index 99a52e76..676c7971 100644 --- a/test_utils/eox_settings.py +++ b/test_utils/eox_settings.py @@ -1,5 +1,7 @@ -"""eox_tenant test settings.""" -GET_SITE_CONFIGURATION_MODULE = 'eox_tenant.edxapp_wrapper.backends.site_configuration_module_i_v1' +"""EOX test settings.""" + +# eox-tenant settings +EOX_TENANT_USERS_BACKEND = 'eox_tenant.edxapp_wrapper.backends.users_l_v1' GET_BRANDING_API = 'eox_tenant.edxapp_wrapper.backends.branding_api_l_v1' +GET_SITE_CONFIGURATION_MODULE = 'eox_tenant.edxapp_wrapper.backends.site_configuration_module_i_v1' GET_THEMING_HELPERS = 'eox_tenant.edxapp_wrapper.backends.theming_helpers_h_v1' -EOX_TENANT_USERS_BACKEND = 'eox_tenant.edxapp_wrapper.backends.users_l_v1' diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/base_test_data.py b/tests/base_test_data.py index ff878315..b3d4bdf2 100644 --- a/tests/base_test_data.py +++ b/tests/base_test_data.py @@ -106,6 +106,41 @@ "ORG6": 1, # This is an org with no tenant "ORG8": 2, }, + "course_attributes": { # org id, course id + "course-v1:ORG1+1+1": { + "start": "F", + }, + "course-v1:ORG1+2+2": { + "start": "F", + "end": "F", + }, + + "course-v1:ORG2+3+3": { + "end": "P", + }, + "course-v1:ORG2+4+4": { + "end": "P", + }, + "course-v1:ORG2+5+5": { + "start": "P", + "end": "P", + }, + + "course-v1:ORG2+1+1": { + "start": "P", + }, + "course-v1:ORG2+2+2": { + "start": "P", + "end": "F", + }, + "course-v1:ORG1+3+3": { + "end": "F", + }, + + "course-v1:ORG1+4+4": { + "self_paced": True, + }, + }, "course_enrollments": { # org id, course id, user ids "ORG1": { 1: [4, 5], diff --git a/tests/conftest.py b/tests/conftest.py index c53719a5..93eecc98 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ import pytest from common.djangoapps.student.models import CourseAccessRole, CourseEnrollment, UserSignupSource from django.contrib.auth import get_user_model +from django.utils import timezone from eox_tenant.models import Route, TenantConfig from lms.djangoapps.certificates.models import GeneratedCertificate from openedx.core.djangoapps.content.course_overviews.models import CourseOverview @@ -84,8 +85,30 @@ def _create_course_overviews(): id=f"course-v1:{org}+{i}+{i}", org=org, visible_to_staff_only=False, + display_name=f"Course {i} of {org}", ) + now_time = timezone.now() + for course_id, data in _base_data["course_attributes"].items(): + course = CourseOverview.objects.get(id=course_id) + for field, value in data.items(): + if field in ("start", "end"): + assert value in ("F", "P"), f"Bad value for {field} in course_attributes testing data: {value}" + if field == "start": + if value == "F": + course.start = now_time + timezone.timedelta(days=1) + else: + course.start = now_time - timezone.timedelta(days=10) + continue + if field == "end": + if value == "F": + course.end = now_time + timezone.timedelta(days=10) + else: + course.end = now_time - timezone.timedelta(days=1) + continue + setattr(course, field, value) + course.save() + def _create_course_enrollments(): """Create course enrollments.""" for org, enrollments in _base_data["course_enrollments"].items(): diff --git a/tests/test_dashboard/test_details/test_details_courses.py b/tests/test_dashboard/test_details/test_details_courses.py new file mode 100644 index 00000000..15a76f79 --- /dev/null +++ b/tests/test_dashboard/test_details/test_details_courses.py @@ -0,0 +1,65 @@ +"""Tests for courses details collectors""" +import pytest +from eox_nelp.course_experience.models import FeedbackCourse +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview + +from futurex_openedx_extensions.dashboard.details.courses import get_courses_queryset + + +@pytest.mark.django_db +@pytest.mark.parametrize('tenant_ids, search_text, expected_count', [ + ([7, 8], None, 5), + ([7], None, 3), + ([8], None, 2), + ([7], 'Course 1', 1), + ([7], 'Course 3', 1), + ([7], 'course 3', 1), + ([7], 'course 4', 0), + ([4], None, 0), +]) +def test_get_courses_queryset(base_data, tenant_ids, search_text, expected_count): # pylint: disable=unused-argument + """Verify that get_courses_queryset returns the correct QuerySet.""" + assert get_courses_queryset(tenant_ids, search_text).count() == expected_count + + +@pytest.mark.django_db +def test_get_courses_queryset_result_excludes_staff(base_data): # pylint: disable=unused-argument + """Verify that get_courses_queryset excludes staff users from enrollment, but not from certificates.""" + expected_results = { + 'course-v1:ORG1+1+1': [1, 0], + 'course-v1:ORG1+2+2': [0, 0], + 'course-v1:ORG1+3+3': [0, 0], + 'course-v1:ORG1+4+4': [0, 0], + 'course-v1:ORG1+5+5': [3, 4], + 'course-v1:ORG2+1+1': [0, 0], + 'course-v1:ORG2+2+2': [0, 0], + 'course-v1:ORG2+3+3': [1, 0], + 'course-v1:ORG2+4+4': [6, 4], + 'course-v1:ORG2+5+5': [5, 3], + 'course-v1:ORG2+6+6': [5, 0], + 'course-v1:ORG2+7+7': [5, 3], + } + queryset = get_courses_queryset([1]) + for record in queryset: + assert record.enrolled_count == expected_results[record.id][0] + assert record.certificates_count == expected_results[record.id][1] + + +@pytest.mark.django_db +def test_get_courses_queryset_result_rating(base_data): # pylint: disable=unused-argument + """Verify that get_courses_queryset returns the correct rating.""" + ratings = [3, 4, 5, 3, 4, 5, 3, 2, 5, 2, 4, 5] + no_ratings = [0, 0, 0, 0, 0, 0] + all_ratings = ratings + no_ratings + course = CourseOverview.objects.get(id='course-v1:ORG1+5+5') + for rating in all_ratings: + FeedbackCourse.objects.create( + course_id=course, + rating_content=rating, + ) + queryset = get_courses_queryset([1]) + for record in queryset: + if record.id != course.id: + continue + assert record.rating_count == len(ratings) + assert record.rating_total == sum(ratings) diff --git a/tests/test_dashboard/test_statistics/test_courses.py b/tests/test_dashboard/test_statistics/test_courses.py index 30aa9def..7b619a7a 100644 --- a/tests/test_dashboard/test_statistics/test_courses.py +++ b/tests/test_dashboard/test_statistics/test_courses.py @@ -1,15 +1,15 @@ """Tests for courses statistics.""" import pytest -from django.utils.timezone import now, timedelta from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from futurex_openedx_extensions.dashboard.statistics import courses +from futurex_openedx_extensions.helpers.constants import COURSE_STATUSES from futurex_openedx_extensions.helpers.tenants import get_course_org_filter_list from tests.base_test_data import _base_data @pytest.mark.django_db -def test_get_courses_count(base_data): +def test_get_courses_count(base_data): # pylint: disable=unused-argument """Verify get_courses_count function.""" all_tenants = _base_data["tenant_config"].keys() result = courses.get_courses_count(all_tenants) @@ -28,30 +28,11 @@ def test_get_courses_count(base_data): @pytest.mark.django_db -@pytest.mark.parametrize('start_diff, end_diff, expected_org1_count', [ - (None, None, 5), - (None, 1, 5), - (None, -1, 4), - (1, None, 4), - (-1, None, 5), - (1, 2, 4), - (-1, 1, 5), - (-2, -1, 4), -]) -def test_get_courses_count_only_active(base_data, start_diff, end_diff, expected_org1_count): +def test_get_courses_count_only_active(base_data): # pylint: disable=unused-argument """Verify get_courses_count function with only_active=True.""" - course = CourseOverview.objects.filter(org="ORG1").first() - assert course.start is None - assert course.end is None - - if start_diff is not None: - course.start = now() + timedelta(days=start_diff) - if end_diff is not None: - course.end = now() + timedelta(days=end_diff) - course.save() expected_result = [ - {'org': 'ORG1', 'courses_count': expected_org1_count}, - {'org': 'ORG2', 'courses_count': 7}, + {'org': 'ORG1', 'courses_count': 3}, + {'org': 'ORG2', 'courses_count': 4}, ] result = courses.get_courses_count([1], only_active=True) @@ -59,7 +40,7 @@ def test_get_courses_count_only_active(base_data, start_diff, end_diff, expected @pytest.mark.django_db -def test_get_courses_count_only_visible(base_data): +def test_get_courses_count_only_visible(base_data): # pylint: disable=unused-argument """Verify get_courses_count function with only_visible=True.""" course = CourseOverview.objects.filter(org="ORG1").first() assert course.visible_to_staff_only is False @@ -72,3 +53,15 @@ def test_get_courses_count_only_visible(base_data): result = courses.get_courses_count([1], only_visible=True) assert expected_result == list(result), f'Wrong result: {result}' + + +@pytest.mark.django_db +def test_get_courses_count_by_status(base_data): # pylint: disable=unused-argument + """Verify get_courses_count_by_status function.""" + result = courses.get_courses_count_by_status([1]) + assert list(result) == [ + {'self_paced': False, 'status': COURSE_STATUSES['active'], 'courses_count': 6}, + {'self_paced': False, 'status': COURSE_STATUSES['archived'], 'courses_count': 3}, + {'self_paced': False, 'status': COURSE_STATUSES['upcoming'], 'courses_count': 2}, + {'self_paced': True, 'status': COURSE_STATUSES['active'], 'courses_count': 1} + ] diff --git a/tests/test_dashboard/test_views.py b/tests/test_dashboard/test_views.py index b78c4da3..035bd593 100644 --- a/tests/test_dashboard/test_views.py +++ b/tests/test_dashboard/test_views.py @@ -5,23 +5,34 @@ import pytest from django.contrib.auth import get_user_model from django.http import JsonResponse -from django.urls import reverse +from django.urls import resolve, reverse +from django.utils.timezone import now, timedelta +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from rest_framework.test import APITestCase +from futurex_openedx_extensions.helpers.constants import COURSE_STATUSES +from futurex_openedx_extensions.helpers.filters import DefaultOrderingFilter from tests.base_test_data import expected_statistics -@pytest.mark.usefixtures('base_data') -class TestTotalCountsView(APITestCase): - """Tests for TotalCountsView""" +class BaseTextViewMixin(APITestCase): + """Base test view mixin""" + VIEW_NAME = 'view name is not set!' + def setUp(self): - self.url = reverse('fx_dashboard:total-counts') + self.url = reverse(self.VIEW_NAME) self.staff_user = 2 def login_user(self, user_id): """Helper to login user""" self.client.force_login(get_user_model().objects.get(id=user_id)) + +@pytest.mark.usefixtures('base_data') +class TestTotalCountsView(BaseTextViewMixin): + """Tests for TotalCountsView""" + VIEW_NAME = 'fx_dashboard:total-counts' + def test_unauthorized(self): """Test unauthorized access""" response = self.client.get(self.url) @@ -59,15 +70,9 @@ def test_selected_tenants(self): @pytest.mark.usefixtures('base_data') -class TestLearnersView(APITestCase): +class TestLearnersView(BaseTextViewMixin): """Tests for LearnersView""" - def setUp(self): - self.url = reverse('fx_dashboard:learners') - self.staff_user = 2 - - def login_user(self, user_id): - """Helper to login user""" - self.client.force_login(get_user_model().objects.get(id=user_id)) + VIEW_NAME = 'fx_dashboard:learners' def test_unauthorized(self): """Verify that the view returns 403 when the user is not authenticated""" @@ -95,3 +100,99 @@ def test_success(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.data['count'], 46) self.assertGreater(len(response.data['results']), 0) + + +@pytest.mark.usefixtures('base_data') +class TesttCoursesView(BaseTextViewMixin): + """Tests for CoursesView""" + VIEW_NAME = 'fx_dashboard:courses' + + def test_unauthorized(self): + """Verify that the view returns 403 when the user is not authenticated""" + response = self.client.get(self.url) + self.assertEqual(response.status_code, 403) + + def test_no_tenants(self): + """Verify that the view returns the result for all accessible tenants when no tenant IDs are provided""" + self.login_user(self.staff_user) + with patch('futurex_openedx_extensions.dashboard.views.get_courses_queryset') as mock_queryset: + self.client.get(self.url) + mock_queryset.assert_called_once_with(tenant_ids=[1, 2, 3, 7, 8], search_text=None) + + def test_search(self): + """Verify that the view filters the courses by search text""" + self.login_user(self.staff_user) + with patch('futurex_openedx_extensions.dashboard.views.get_courses_queryset') as mock_queryset: + self.client.get(self.url + '?tenant_ids=1&search_text=course') + mock_queryset.assert_called_once_with(tenant_ids=[1], search_text='course') + + def helper_test_success(self, response): + """Verify that the view returns the correct response""" + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['count'], 18) + self.assertGreater(len(response.data['results']), 0) + self.assertEqual(response.data['results'][0]['id'], 'course-v1:ORG1+1+1') + + def test_success(self): + """Verify that the view returns the correct response""" + self.login_user(self.staff_user) + response = self.client.get(self.url) + self.helper_test_success(response=response) + self.assertEqual(response.data['results'][0]['status'], COURSE_STATUSES['upcoming']) + + def test_status_archived(self): + """Verify that the view sets the correct status when the course is archived""" + CourseOverview.objects.filter(id='course-v1:ORG1+1+1').update(end=now() - timedelta(days=1)) + + self.login_user(self.staff_user) + response = self.client.get(self.url) + self.helper_test_success(response=response) + self.assertEqual(response.data['results'][0]['status'], 'archived') + + def test_status_upcoming(self): + """Verify that the view sets the correct status when the course is upcoming""" + CourseOverview.objects.filter(id='course-v1:ORG1+1+1').update(start=now() + timedelta(days=1)) + + self.login_user(self.staff_user) + response = self.client.get(self.url) + self.helper_test_success(response=response) + self.assertEqual(response.data['results'][0]['status'], 'upcoming') + + def test_sorting(self): + """Verify that the view soring filter is set correctly""" + view_func, _, _ = resolve(self.url) + view_class = view_func.view_class + self.assertEqual(view_class.filter_backends, [DefaultOrderingFilter]) + + +@pytest.mark.usefixtures('base_data') +class TesttCourseCourseStatusesView(BaseTextViewMixin): + """Tests for CourseStatusesView""" + VIEW_NAME = 'fx_dashboard:course-statuses' + + def test_unauthorized(self): + """Verify that the view returns 403 when the user is not authenticated""" + response = self.client.get(self.url) + self.assertEqual(response.status_code, 403) + + def test_no_tenants(self): + """Verify that the view returns the result for all accessible tenants when no tenant IDs are provided""" + self.login_user(self.staff_user) + with patch('futurex_openedx_extensions.dashboard.views.get_courses_count_by_status') as mock_queryset: + self.client.get(self.url) + mock_queryset.assert_called_once_with(tenant_ids=[1, 2, 3, 7, 8]) + + def test_success(self): + """Verify that the view returns the correct response""" + self.login_user(self.staff_user) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + data = json.loads(response.content) + self.assertDictEqual(data, { + "active": 12, + "archived": 3, + "upcoming": 2, + "self_active": 1, + "self_archived": 0, + "self_upcoming": 0, + }) diff --git a/tests/test_helpers/test_filters.py b/tests/test_helpers/test_filters.py new file mode 100644 index 00000000..c53d9f7b --- /dev/null +++ b/tests/test_helpers/test_filters.py @@ -0,0 +1,10 @@ +"""Tests for pagination helpers""" +from rest_framework.filters import OrderingFilter + +from futurex_openedx_extensions.helpers.filters import DefaultOrderingFilter + + +def test_default_sorting_filter(): + """Verify that the DefaultOrderingFilter class is correctly defined.""" + assert issubclass(DefaultOrderingFilter, OrderingFilter) + assert DefaultOrderingFilter.ordering_param == 'sort' diff --git a/tests/test_helpers/test_pagination.py b/tests/test_helpers/test_pagination.py index 880d6dd1..22f78c06 100644 --- a/tests/test_helpers/test_pagination.py +++ b/tests/test_helpers/test_pagination.py @@ -1,10 +1,12 @@ """Tests for pagination helpers""" +from rest_framework.pagination import PageNumberPagination + from futurex_openedx_extensions.helpers.pagination import DefaultPagination def test_default_pagination(): """Verify that the DefaultPagination class is correctly defined.""" - assert issubclass(DefaultPagination, DefaultPagination) + assert issubclass(DefaultPagination, PageNumberPagination) assert DefaultPagination.page_size == 20 assert DefaultPagination.page_size_query_param == 'page_size' assert DefaultPagination.max_page_size == 100 diff --git a/tests/test_helpers/test_tenants.py b/tests/test_helpers/test_tenants.py index 95f43b03..2d0bf374 100644 --- a/tests/test_helpers/test_tenants.py +++ b/tests/test_helpers/test_tenants.py @@ -10,14 +10,14 @@ @pytest.mark.django_db -def test_get_excluded_tenant_ids(base_data): +def test_get_excluded_tenant_ids(base_data): # pylint: disable=unused-argument """Verify get_excluded_tenant_ids function.""" result = tenants.get_excluded_tenant_ids() assert result == [4, 5, 6] @pytest.mark.django_db -def test_get_all_tenants(base_data): +def test_get_all_tenants(base_data): # pylint: disable=unused-argument """Verify get_all_tenants function.""" result = tenants.get_all_tenants() assert TenantConfig.objects.count() == 8 @@ -27,14 +27,14 @@ def test_get_all_tenants(base_data): @pytest.mark.django_db -def test_get_all_tenant_ids(base_data): +def test_get_all_tenant_ids(base_data): # pylint: disable=unused-argument """Verify get_all_tenant_ids function.""" result = tenants.get_all_tenant_ids() assert result == [1, 2, 3, 7, 8] @pytest.mark.django_db -def test_get_accessible_tenant_ids_none(base_data): +def test_get_accessible_tenant_ids_none(base_data): # pylint: disable=unused-argument """Verify that get_accessible_tenant_ids returns an empty list when user is None.""" result = tenants.get_accessible_tenant_ids(None) assert result == [] @@ -44,7 +44,7 @@ def test_get_accessible_tenant_ids_none(base_data): @pytest.mark.parametrize("user_id, expected", [ (1, [1, 2, 3, 7, 8]), ]) -def test_get_accessible_tenant_ids_super_users(base_data, user_id, expected): +def test_get_accessible_tenant_ids_super_users(base_data, user_id, expected): # pylint: disable=unused-argument """Verify get_accessible_tenant_ids function for super users.""" user = get_user_model().objects.get(id=user_id) assert user.is_superuser, 'only super users allowed in this test' @@ -56,7 +56,7 @@ def test_get_accessible_tenant_ids_super_users(base_data, user_id, expected): @pytest.mark.parametrize("user_id, expected", [ (2, [1, 2, 3, 7, 8]), ]) -def test_get_accessible_tenant_ids_staff(base_data, user_id, expected): +def test_get_accessible_tenant_ids_staff(base_data, user_id, expected): # pylint: disable=unused-argument """Verify get_accessible_tenant_ids function for staff users.""" user = get_user_model().objects.get(id=user_id) assert user.is_staff, 'only staff users allowed in this test' @@ -71,7 +71,9 @@ def test_get_accessible_tenant_ids_staff(base_data, user_id, expected): (9, [1]), (23, [2, 3, 8]), ]) -def test_get_accessible_tenant_ids_no_staff_no_sueperuser(base_data, user_id, expected): +def test_get_accessible_tenant_ids_no_staff_no_sueperuser( + base_data, user_id, expected +): # pylint: disable=unused-argument """Verify get_accessible_tenant_ids function for users with no staff and no superuser.""" user = get_user_model().objects.get(id=user_id) assert not user.is_staff and not user.is_superuser, 'only users with no staff and no superuser allowed in this test' @@ -80,7 +82,7 @@ def test_get_accessible_tenant_ids_no_staff_no_sueperuser(base_data, user_id, ex @pytest.mark.django_db -def test_get_accessible_tenant_ids_complex(base_data): +def test_get_accessible_tenant_ids_complex(base_data): # pylint: disable=unused-argument """Verify get_accessible_tenant_ids function for complex cases""" user = get_user_model().objects.get(id=10) user_access_role = 'org_course_creator_group' @@ -143,7 +145,7 @@ def test_get_accessible_tenant_ids_complex(base_data): } )), ]) -def test_check_tenant_access(base_data, user_id, ids_to_check, expected): +def test_check_tenant_access(base_data, user_id, ids_to_check, expected): # pylint: disable=unused-argument """Verify check_tenant_access function.""" user = get_user_model().objects.get(id=user_id) result = tenants.check_tenant_access(user, ids_to_check) @@ -151,7 +153,7 @@ def test_check_tenant_access(base_data, user_id, ids_to_check, expected): @pytest.mark.django_db -def test_get_all_course_org_filter_list(base_data): +def test_get_all_course_org_filter_list(base_data): # pylint: disable=unused-argument """Verify get_all_course_org_filter_list function.""" result = tenants.get_all_course_org_filter_list() assert result == { @@ -198,7 +200,7 @@ def test_get_all_course_org_filter_list(base_data): 'invalid': [], }), ]) -def test_get_course_org_filter_list(base_data, tenant_ids, expected): +def test_get_course_org_filter_list(base_data, tenant_ids, expected): # pylint: disable=unused-argument """Verify get_course_org_filter_list function.""" result = tenants.get_course_org_filter_list(tenant_ids) assert result == expected @@ -210,7 +212,7 @@ def test_get_course_org_filter_list(base_data, tenant_ids, expected): (2, [1, 2, 3, 7, 8]), (3, []), ]) -def test_get_accessible_tenant_ids(base_data, user_id, expected): +def test_get_accessible_tenant_ids(base_data, user_id, expected): # pylint: disable=unused-argument """Verify get_accessible_tenant_ids function.""" user = get_user_model().objects.get(id=user_id) result = tenants.get_accessible_tenant_ids(user) @@ -218,7 +220,7 @@ def test_get_accessible_tenant_ids(base_data, user_id, expected): @pytest.mark.django_db -def test_get_all_tenants_info(base_data): +def test_get_all_tenants_info(base_data): # pylint: disable=unused-argument """Verify get_all_tenants_info function.""" result = tenants.get_all_tenants_info() assert result['tenant_ids'] == [1, 2, 3, 7, 8] @@ -242,6 +244,20 @@ def test_get_all_tenants_info(base_data): (7, 's7.sample.com'), (8, 's8.sample.com'), ]) -def test_get_tenant_site(base_data, tenant_id, expected): +def test_get_tenant_site(base_data, tenant_id, expected): # pylint: disable=unused-argument """Verify get_tenant_site function.""" assert expected == tenants.get_tenant_site(tenant_id) + + +@pytest.mark.django_db +@pytest.mark.parametrize("org, expected", [ + ('ORG1', [1]), + ('ORG2', [1]), + ('ORG3', [2, 7]), + ('ORG4', [3]), + ('ORG5', [3]), + ('ORG8', [2, 8]), +]) +def test_get_tenants_by_org(base_data, org, expected): # pylint: disable=unused-argument + """Verify get_tenants_by_org function.""" + assert expected == tenants.get_tenants_by_org(org) diff --git a/tox.ini b/tox.ini index ca36bfcf..8398eb4b 100644 --- a/tox.ini +++ b/tox.ini @@ -35,12 +35,15 @@ addopts = --cov futurex_openedx_extensions --cov tests --cov-report term-missing norecursedirs = .* docs requirements site-packages [testenv] +allowlist_externals = + rm deps = django32: Django>=3.2,<4.0 django40: Django>=4.0,<4.1 -r{toxinidir}/requirements/test.txt -e{toxinidir}/test_utils/edx_platform_mocks commands = + rm -Rf {toxinidir}/test_utils/edx_platform_mocks/fake_models/migrations python manage.py makemigrations fake_models python manage.py check pytest {posargs} From 6667f405e9a8e6be70b54fe38b7cf3821f37bf51 Mon Sep 17 00:00:00 2001 From: Shadi Naif Date: Sun, 21 Apr 2024 13:44:28 +0300 Subject: [PATCH 4/6] fix: Catalog hidden courses are excluded by default --- .../dashboard/details/courses.py | 17 ++- .../dashboard/details/learners.py | 24 +++- .../dashboard/statistics/certificates.py | 15 ++- .../dashboard/statistics/courses.py | 32 +++-- .../dashboard/statistics/learners.py | 116 +++++++----------- .../helpers/querysets.py | 39 ++++++ .../edx_platform_mocks/fake_models/models.py | 2 +- tests/conftest.py | 2 +- .../test_statistics/test_courses.py | 29 ----- tests/test_helpers/test_filters.py | 2 +- tests/test_helpers/test_querysets.py | 35 ++++++ 11 files changed, 180 insertions(+), 133 deletions(-) create mode 100644 futurex_openedx_extensions/helpers/querysets.py create mode 100644 tests/test_helpers/test_querysets.py diff --git a/futurex_openedx_extensions/dashboard/details/courses.py b/futurex_openedx_extensions/dashboard/details/courses.py index 665d3b22..d68f2385 100644 --- a/futurex_openedx_extensions/dashboard/details/courses.py +++ b/futurex_openedx_extensions/dashboard/details/courses.py @@ -9,12 +9,14 @@ from django.db.models.query import QuerySet from eox_nelp.course_experience.models import FeedbackCourse from lms.djangoapps.certificates.models import GeneratedCertificate -from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from futurex_openedx_extensions.helpers.querysets import get_base_queryset_courses from futurex_openedx_extensions.helpers.tenants import get_course_org_filter_list, get_tenant_site -def get_courses_queryset(tenant_ids: List, search_text: str = None) -> QuerySet: +def get_courses_queryset( + tenant_ids: List, search_text: str = None, only_visible: bool = True, only_active: bool = False +) -> QuerySet: """ Get the courses queryset for the given tenant IDs and search text. @@ -22,6 +24,12 @@ def get_courses_queryset(tenant_ids: List, search_text: str = None) -> QuerySet: :type tenant_ids: List :param search_text: Search text to filter the courses by :type search_text: str + :param only_visible: Whether to only include courses that are visible in the catalog + :type only_visible: bool + :param only_active: Whether to only include active courses + :type only_active: bool + :return: QuerySet of courses + :rtype: QuerySet """ course_org_filter_list = get_course_org_filter_list(tenant_ids)['course_org_filter_list'] tenant_sites = [] @@ -29,9 +37,8 @@ def get_courses_queryset(tenant_ids: List, search_text: str = None) -> QuerySet: if site := get_tenant_site(tenant_id): tenant_sites.append(site) - queryset = CourseOverview.objects.filter( - org__in=course_org_filter_list, - ) + queryset = get_base_queryset_courses(course_org_filter_list, only_visible=only_visible, only_active=only_active) + search_text = (search_text or '').strip() if search_text: queryset = queryset.filter( diff --git a/futurex_openedx_extensions/dashboard/details/learners.py b/futurex_openedx_extensions/dashboard/details/learners.py index d4c100d4..1a92b05c 100644 --- a/futurex_openedx_extensions/dashboard/details/learners.py +++ b/futurex_openedx_extensions/dashboard/details/learners.py @@ -7,12 +7,14 @@ from django.contrib.auth import get_user_model from django.db.models import Count, Exists, OuterRef, Q, Subquery from django.db.models.query import QuerySet -from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from futurex_openedx_extensions.helpers.querysets import get_base_queryset_courses from futurex_openedx_extensions.helpers.tenants import get_course_org_filter_list, get_tenant_site -def get_learners_queryset(tenant_ids: List, search_text: str = None) -> QuerySet: +def get_learners_queryset( + tenant_ids: List, search_text: str = None, only_visible_courses: bool = True, only_active_courses: bool = False +) -> QuerySet: """ Get the learners queryset for the given tenant IDs and search text. @@ -20,6 +22,12 @@ def get_learners_queryset(tenant_ids: List, search_text: str = None) -> QuerySet :type tenant_ids: List :param search_text: Search text to filter the learners by :type search_text: str + :param only_visible_courses: Whether to only count courses that are visible in the catalog + :type only_visible_courses: bool + :param only_active_courses: Whether to only count active courses + :type only_active_courses: bool + :return: QuerySet of learners + :rtype: QuerySet """ course_org_filter_list = get_course_org_filter_list(tenant_ids)['course_org_filter_list'] tenant_sites = [] @@ -44,7 +52,11 @@ def get_learners_queryset(tenant_ids: List, search_text: str = None) -> QuerySet courses_count=Count( 'courseenrollment', filter=( - Q(courseenrollment__course__org__in=course_org_filter_list) & + Q(courseenrollment__course_id__in=get_base_queryset_courses( + course_org_filter_list, + only_visible=only_visible_courses, + only_active=only_active_courses, + )) & ~Exists( CourseAccessRole.objects.filter( user_id=OuterRef('id'), @@ -59,8 +71,10 @@ def get_learners_queryset(tenant_ids: List, search_text: str = None) -> QuerySet 'generatedcertificate', filter=( Q(generatedcertificate__course_id__in=Subquery( - CourseOverview.objects.filter( - org__in=course_org_filter_list + get_base_queryset_courses( + course_org_filter_list, + only_visible=only_visible_courses, + only_active=only_active_courses ).values_list('id', flat=True) )) & Q(generatedcertificate__status='downloadable') diff --git a/futurex_openedx_extensions/dashboard/statistics/certificates.py b/futurex_openedx_extensions/dashboard/statistics/certificates.py index a0e30bcf..ffe95644 100644 --- a/futurex_openedx_extensions/dashboard/statistics/certificates.py +++ b/futurex_openedx_extensions/dashboard/statistics/certificates.py @@ -7,16 +7,23 @@ from lms.djangoapps.certificates.models import GeneratedCertificate from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from futurex_openedx_extensions.helpers.querysets import get_base_queryset_courses from futurex_openedx_extensions.helpers.tenants import get_course_org_filter_list -def get_certificates_count(tenant_ids: List[int]) -> Dict[str, int]: +def get_certificates_count( + tenant_ids: List[int], only_visible_courses: bool = True, only_active_courses: bool = False +) -> Dict[str, int]: """ Get the count of issued certificates in the given tenants. The count is grouped by organization. Certificates for admins, staff, and superusers are also included. :param tenant_ids: List of tenant IDs to get the count for :type tenant_ids: List[int] + :param only_visible_courses: Whether to only count courses that are visible in the catalog + :type only_visible_courses: bool + :param only_active_courses: Whether to only count active courses (according to dates) + :type only_active_courses: bool :return: Count of certificates per organization :rtype: Dict[str, int] """ @@ -24,8 +31,10 @@ def get_certificates_count(tenant_ids: List[int]) -> Dict[str, int]: result = list(GeneratedCertificate.objects.filter( status='downloadable', - course_id__in=CourseOverview.objects.filter( - org__in=course_org_filter_list + course_id__in=get_base_queryset_courses( + course_org_filter_list, + only_visible=only_visible_courses, + only_active=only_active_courses, ), ).annotate(course_org=Subquery( CourseOverview.objects.filter( diff --git a/futurex_openedx_extensions/dashboard/statistics/courses.py b/futurex_openedx_extensions/dashboard/statistics/courses.py index 3b8c80a9..59bfb205 100644 --- a/futurex_openedx_extensions/dashboard/statistics/courses.py +++ b/futurex_openedx_extensions/dashboard/statistics/courses.py @@ -6,56 +6,54 @@ from django.db.models import Case, CharField, Count, Q, Value, When from django.db.models.query import QuerySet from django.utils.timezone import now -from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from futurex_openedx_extensions.helpers.constants import COURSE_STATUSES +from futurex_openedx_extensions.helpers.querysets import get_base_queryset_courses from futurex_openedx_extensions.helpers.tenants import get_course_org_filter_list -def get_courses_count(tenant_ids: List[int], only_active=False, only_visible=False) -> QuerySet: +def get_courses_count(tenant_ids: List[int], only_visible: bool = True, only_active: bool = False) -> QuerySet: """ Get the count of courses in the given tenants :param tenant_ids: List of tenant IDs to get the count for :type tenant_ids: List[int] + :param only_visible: Whether to only count courses that are visible in the catalog + :type only_visible: bool :param only_active: Whether to only count active courses (according to dates) :type only_active: bool - :param only_visible: Whether to only count visible courses (according to staff-only visibility) - :type only_visible: bool :return: QuerySet of courses count per organization :rtype: QuerySet """ course_org_filter_list = get_course_org_filter_list(tenant_ids)['course_org_filter_list'] - q_set = CourseOverview.objects.filter(org__in=course_org_filter_list) - if only_active: - q_set = q_set.filter( - Q(start__isnull=True) | Q(start__lte=now()), - ).filter( - Q(end__isnull=True) | Q(end__gte=now()), - ) - if only_visible: - q_set = q_set.filter(visible_to_staff_only=False) + q_set = get_base_queryset_courses(course_org_filter_list, only_visible=only_visible, only_active=only_active) return q_set.values('org').annotate( courses_count=Count('id') ).order_by('org') -def get_courses_count_by_status(tenant_ids: List[int]) -> QuerySet: +def get_courses_count_by_status( + tenant_ids: List[int], only_visible: bool = True, only_active: bool = False +) -> QuerySet: """ Get the count of courses in the given tenants by status :param tenant_ids: List of tenant IDs to get the count for :type tenant_ids: List[int] + :param only_visible: Whether to only count courses that are visible in the catalog + :type only_visible: bool + :param only_active: Whether to only count active courses (according to dates) + :type only_active: bool :return: QuerySet of courses count per organization and status :rtype: QuerySet """ course_org_filter_list = get_course_org_filter_list(tenant_ids)['course_org_filter_list'] - q_set = CourseOverview.objects.filter( - org__in=course_org_filter_list - ).annotate( + q_set = get_base_queryset_courses(course_org_filter_list, only_visible=only_visible, only_active=only_active) + + q_set = q_set.annotate( status=Case( When( Q(end__isnull=False) & Q(end__lt=now()), diff --git a/futurex_openedx_extensions/dashboard/statistics/learners.py b/futurex_openedx_extensions/dashboard/statistics/learners.py index 823ff142..f06a0fb9 100644 --- a/futurex_openedx_extensions/dashboard/statistics/learners.py +++ b/futurex_openedx_extensions/dashboard/statistics/learners.py @@ -7,44 +7,35 @@ from django.contrib.auth import get_user_model from django.db.models import Count, Exists, OuterRef, Q, Subquery from django.db.models.query import QuerySet -from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from futurex_openedx_extensions.helpers.querysets import get_base_queryset_courses from futurex_openedx_extensions.helpers.tenants import get_course_org_filter_list, get_tenant_site -def get_learners_count_having_enrollment_per_org(tenant_id) -> QuerySet: +def get_learners_count_having_enrollment_per_org( + tenant_id: int, only_visible_courses: bool = True, only_active_courses: bool = False +) -> QuerySet: """ TODO: Cache the result of this function Get the count of learners with enrollments per organization. Admins and staff are excluded from the count. This function takes one tenant ID for performance reasons. - - SELECT coc.org, COUNT(DISTINCT au.id) - FROM edxapp.auth_user au - INNER JOIN edxapp.student_courseenrollment sc ON - au.id = sc.user_id - INNER JOIN edxapp.course_overviews_courseoverview coc ON - sc.course_id = coc.id AND - coc.org IN ('ORG1', 'ORG2') -- course_org_filter_list - WHERE au.id NOT IN ( - SELECT DISTINCT cr.user_id - FROM edxapp.student_courseaccessrole cr - WHERE cr.org = coc.org - ) AND - au.is_superuser = 0 AND - au.is_staff = 0 AND - au.is_active = 1 - GROUP BY coc.org - - + :param tenant_id: Tenant ID to get the count for + :type tenant_id: int + :param only_visible_courses: Whether to only count courses that are visible in the catalog + :type only_visible_courses: bool + :param only_active_courses: Whether to only count active courses (according to dates) + :type only_active_courses: bool :return: QuerySet of learners count per organization :rtype: QuerySet """ course_org_filter_list = get_course_org_filter_list([tenant_id])['course_org_filter_list'] - return CourseOverview.objects.filter( - org__in=course_org_filter_list - ).values('org').annotate( + queryset = get_base_queryset_courses( + course_org_filter_list, only_visible=only_visible_courses, only_active=only_active_courses, + ) + + return queryset.values('org').annotate( learners_count=Count( 'courseenrollment__user_id', filter=~Exists( @@ -61,29 +52,19 @@ def get_learners_count_having_enrollment_per_org(tenant_id) -> QuerySet: ) -def get_learners_count_having_enrollment_for_tenant(tenant_id) -> QuerySet: +def get_learners_count_having_enrollment_for_tenant( + tenant_id: int, only_visible_courses: bool = True, only_active_courses: bool = False +) -> QuerySet: """ TODO: Cache the result of this function Get the count of learners with enrollments per organization. Admins and staff are excluded from the count - - SELECT COUNT(DISTINCT au.id) - FROM edxapp.auth_user au - INNER JOIN edxapp.student_courseenrollment sc ON - au.id = sc.user_id - INNER JOIN edxapp.course_overviews_courseoverview coc ON - sc.course_id = coc.id AND - coc.org IN ('ORG1', 'ORG2') -- course_org_filter_list - WHERE au.id NOT IN ( - SELECT DISTINCT cr.user_id - FROM edxapp.student_courseaccessrole cr - WHERE cr.org = coc.org - ) AND - au.is_superuser = 0 AND - au.is_staff = 0 AND - au.is_active = 1 - - + :param tenant_id: Tenant ID to get the count for + :type tenant_id: int + :param only_visible_courses: Whether to only count courses that are visible in the catalog + :type only_visible_courses: bool + :param only_active_courses: Whether to only count active courses (according to dates) + :type only_active_courses: bool :return: QuerySet of learners count per organization :rtype: QuerySet """ @@ -93,7 +74,11 @@ def get_learners_count_having_enrollment_for_tenant(tenant_id) -> QuerySet: is_superuser=False, is_staff=False, is_active=True, - courseenrollment__course__org__in=course_org_filter_list, + courseenrollment__course_id__in=get_base_queryset_courses( + course_org_filter_list, + only_visible=only_visible_courses, + only_active=only_active_courses, + ), ).exclude( Exists( CourseAccessRole.objects.filter( @@ -104,7 +89,9 @@ def get_learners_count_having_enrollment_for_tenant(tenant_id) -> QuerySet: ).values('id').distinct().count() -def get_learners_count_having_no_enrollment(tenant_id) -> QuerySet: +def get_learners_count_having_no_enrollment( + tenant_id: int, only_visible_courses: bool = True, only_active_courses: bool = False +) -> QuerySet: """ TODO: Cache the result of this function Get the count of learners with no enrollments per organization. Admins and staff are excluded from the count. @@ -112,31 +99,14 @@ def get_learners_count_having_no_enrollment(tenant_id) -> QuerySet: The function returns the count for one tenant for performance reasons. - SELECT COUNT(distinct su.id) - FROM edxapp.student_usersignupsource su - WHERE su.site = 'demo.example.com' -- tenant_site - AND su.user_id not in ( - SELECT distinct au.id - FROM edxapp.auth_user au - INNER JOIN edxapp.student_courseenrollment sc ON - au.id = sc.user_id - INNER JOIN edxapp.course_overviews_courseoverview coc ON - sc.course_id = coc.id AND - coc.org IN ('ORG1', 'ORG2') -- course_org_filter_list - WHERE au.id NOT IN ( - SELECT DISTINCT cr.user_id - FROM edxapp.student_courseaccessrole cr - WHERE cr.org = coc.org - ) AND - au.is_superuser = 0 AND - au.is_staff = 0 AND - au.is_active = 1 AND - ) AND su.user_id NOT IN ( - SELECT DISTINCT cr.user_id - FROM edxapp.student_courseaccessrole cr - WHERE cr.org IN ('ORG1', 'ORG2') -- course_org_filter_list - ) - + :param tenant_id: Tenant ID to get the count for + :type tenant_id: int + :param only_visible_courses: Whether to only count courses that are visible in the catalog + :type only_visible_courses: bool + :param only_active_courses: Whether to only count active courses (according to dates) + :type only_active_courses: bool + :return: QuerySet of learners count per organization + :rtype: QuerySet """ course_org_filter_list = get_course_org_filter_list([tenant_id])['course_org_filter_list'] tenant_site = get_tenant_site(tenant_id) @@ -147,7 +117,11 @@ def get_learners_count_having_no_enrollment(tenant_id) -> QuerySet: user_id__in=Subquery( CourseEnrollment.objects.filter( user_id=OuterRef('user_id'), - course__org__in=course_org_filter_list, + course_id__in=get_base_queryset_courses( + course_org_filter_list, + only_visible=only_visible_courses, + only_active=only_active_courses, + ), user__is_superuser=False, user__is_staff=False, user__is_active=True, diff --git a/futurex_openedx_extensions/helpers/querysets.py b/futurex_openedx_extensions/helpers/querysets.py new file mode 100644 index 00000000..93e2718f --- /dev/null +++ b/futurex_openedx_extensions/helpers/querysets.py @@ -0,0 +1,39 @@ +"""Helper functions for working with Django querysets.""" +from __future__ import annotations + +from typing import List + +from django.db.models import Q +from django.db.models.query import QuerySet +from django.utils.timezone import now +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview + + +def get_base_queryset_courses( + course_org_filter_list: List[str], + only_visible: bool = True, + only_active: bool = False, +) -> QuerySet: + """ + Get the default course queryset for the given filters. + + :param course_org_filter_list: List of course organizations to filter by + :type course_org_filter_list: List[str] + :param only_visible: Whether to only include courses that are visible in the catalog + :type only_visible: bool + :param only_active: Whether to only include active courses + :type only_active: bool + :return: QuerySet of courses + :rtype: QuerySet + """ + q_set = CourseOverview.objects.filter(org__in=course_org_filter_list) + if only_active: + q_set = q_set.filter( + Q(start__isnull=True) | Q(start__lte=now()), + ).filter( + Q(end__isnull=True) | Q(end__gte=now()), + ) + if only_visible: + q_set = q_set.filter(catalog_visibility__in=['about', 'both']) + + return q_set diff --git a/test_utils/edx_platform_mocks/fake_models/models.py b/test_utils/edx_platform_mocks/fake_models/models.py index e6ad4c51..6009ddc0 100644 --- a/test_utils/edx_platform_mocks/fake_models/models.py +++ b/test_utils/edx_platform_mocks/fake_models/models.py @@ -8,7 +8,7 @@ class CourseOverview(models.Model): """Mock""" id = models.CharField(max_length=255, primary_key=True) org = models.CharField(max_length=255) - visible_to_staff_only = models.BooleanField() + catalog_visibility = models.TextField(null=True) start = models.DateTimeField(null=True) end = models.DateTimeField(null=True) display_name = models.TextField(null=True) diff --git a/tests/conftest.py b/tests/conftest.py index 93eecc98..8fc9166a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -84,7 +84,7 @@ def _create_course_overviews(): CourseOverview.objects.create( id=f"course-v1:{org}+{i}+{i}", org=org, - visible_to_staff_only=False, + catalog_visibility="both", display_name=f"Course {i} of {org}", ) diff --git a/tests/test_dashboard/test_statistics/test_courses.py b/tests/test_dashboard/test_statistics/test_courses.py index 7b619a7a..5f4c85f0 100644 --- a/tests/test_dashboard/test_statistics/test_courses.py +++ b/tests/test_dashboard/test_statistics/test_courses.py @@ -1,6 +1,5 @@ """Tests for courses statistics.""" import pytest -from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from futurex_openedx_extensions.dashboard.statistics import courses from futurex_openedx_extensions.helpers.constants import COURSE_STATUSES @@ -27,34 +26,6 @@ def test_get_courses_count(base_data): # pylint: disable=unused-argument ), f'Missing org: {org} in tenant: {tenant_id} results' -@pytest.mark.django_db -def test_get_courses_count_only_active(base_data): # pylint: disable=unused-argument - """Verify get_courses_count function with only_active=True.""" - expected_result = [ - {'org': 'ORG1', 'courses_count': 3}, - {'org': 'ORG2', 'courses_count': 4}, - ] - - result = courses.get_courses_count([1], only_active=True) - assert expected_result == list(result), f'Wrong result: {result}' - - -@pytest.mark.django_db -def test_get_courses_count_only_visible(base_data): # pylint: disable=unused-argument - """Verify get_courses_count function with only_visible=True.""" - course = CourseOverview.objects.filter(org="ORG1").first() - assert course.visible_to_staff_only is False - course.visible_to_staff_only = True - course.save() - expected_result = [ - {'org': 'ORG1', 'courses_count': 4}, - {'org': 'ORG2', 'courses_count': 7}, - ] - - result = courses.get_courses_count([1], only_visible=True) - assert expected_result == list(result), f'Wrong result: {result}' - - @pytest.mark.django_db def test_get_courses_count_by_status(base_data): # pylint: disable=unused-argument """Verify get_courses_count_by_status function.""" diff --git a/tests/test_helpers/test_filters.py b/tests/test_helpers/test_filters.py index c53d9f7b..886f424e 100644 --- a/tests/test_helpers/test_filters.py +++ b/tests/test_helpers/test_filters.py @@ -1,4 +1,4 @@ -"""Tests for pagination helpers""" +"""Tests for filters helpers""" from rest_framework.filters import OrderingFilter from futurex_openedx_extensions.helpers.filters import DefaultOrderingFilter diff --git a/tests/test_helpers/test_querysets.py b/tests/test_helpers/test_querysets.py new file mode 100644 index 00000000..26cdb08e --- /dev/null +++ b/tests/test_helpers/test_querysets.py @@ -0,0 +1,35 @@ +"""Tests for querysets helpers""" +import pytest +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview + +from futurex_openedx_extensions.helpers.querysets import get_base_queryset_courses + + +@pytest.mark.django_db +def test_get_base_queryset_courses(base_data): # pylint: disable=unused-argument + """Verify get_base_queryset_courses function.""" + result = get_base_queryset_courses(["ORG1", "ORG2"]) + assert result.count() == 12 + for course in result: + assert course.catalog_visibility == "both" + + +@pytest.mark.django_db +def test_get_base_queryset_courses_not_only_visible(base_data): # pylint: disable=unused-argument + """Verify get_base_queryset_courses function with only_visible=False.""" + course = CourseOverview.objects.filter(org="ORG1").first() + assert course.catalog_visibility == "both", "Catalog visibility should be initialized as (both) for test courses" + course.catalog_visibility = "none" + course.save() + + result = get_base_queryset_courses(["ORG1", "ORG2"]) + assert result.count() == 11 + result = get_base_queryset_courses(["ORG1", "ORG2"], only_visible=False) + assert result.count() == 12 + + +@pytest.mark.django_db +def test_get_base_queryset_courses_only_active(base_data): # pylint: disable=unused-argument + """Verify get_base_queryset_courses function with only_active=True.""" + result = get_base_queryset_courses(["ORG1", "ORG2"], only_active=True) + assert result.count() == 7 From 8d36a6419c1470d3d74a1032f75327f52669f77c Mon Sep 17 00:00:00 2001 From: Shadi Naif Date: Sun, 21 Apr 2024 14:44:17 +0300 Subject: [PATCH 5/6] fix: use year_of_birth instead of date_of_birth --- futurex_openedx_extensions/dashboard/serializers.py | 12 ++++++------ tests/test_dashboard/test_serializers.py | 5 +++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/futurex_openedx_extensions/dashboard/serializers.py b/futurex_openedx_extensions/dashboard/serializers.py index 88150714..f9f58e41 100644 --- a/futurex_openedx_extensions/dashboard/serializers.py +++ b/futurex_openedx_extensions/dashboard/serializers.py @@ -15,7 +15,7 @@ class LearnerDetailsSerializer(serializers.ModelSerializer): username = serializers.CharField() email = serializers.EmailField() mobile_no = serializers.SerializerMethodField() - date_of_birth = serializers.SerializerMethodField() + year_of_birth = serializers.SerializerMethodField() gender = serializers.SerializerMethodField() date_joined = serializers.DateTimeField() last_login = serializers.DateTimeField() @@ -30,7 +30,7 @@ class Meta: 'username', 'email', 'mobile_no', - 'date_of_birth', + 'year_of_birth', 'gender', 'date_joined', 'last_login', @@ -55,10 +55,6 @@ def get_mobile_no(self, obj): """Return mobile number.""" return self._get_profile_field(obj, 'phone_number') - def get_date_of_birth(self, obj): # pylint: disable=unused-argument - """Return date of birth.""" - return None - def get_gender(self, obj): """Return gender.""" return self._get_profile_field(obj, 'gender') @@ -71,6 +67,10 @@ def get_enrolled_courses_count(self, obj): """Return enrolled courses count.""" return obj.courses_count + def get_year_of_birth(self, obj): + """Return year of birth.""" + return self._get_profile_field(obj, 'year_of_birth') + class CourseDetailsSerializer(serializers.ModelSerializer): """Serializer for course details.""" diff --git a/tests/test_dashboard/test_serializers.py b/tests/test_dashboard/test_serializers.py index 7649788f..93200072 100644 --- a/tests/test_dashboard/test_serializers.py +++ b/tests/test_dashboard/test_serializers.py @@ -24,7 +24,7 @@ def test_learner_details_serializer_no_profile(): assert data[0]['user_id'] == 10 assert data[0]['full_name'] is None assert data[0]['mobile_no'] is None - assert data[0]['date_of_birth'] is None + assert data[0]['year_of_birth'] is None assert data[0]['gender'] is None @@ -36,6 +36,7 @@ def test_learner_details_serializer_with_profile(): name='Test User', phone_number='1234567890', gender='m', + year_of_birth=1988, ) queryset = get_dummy_queryset() data = LearnerDetailsSerializer(queryset, many=True).data @@ -43,5 +44,5 @@ def test_learner_details_serializer_with_profile(): assert data[0]['user_id'] == 10 assert data[0]['full_name'] == 'Test User' assert data[0]['mobile_no'] == '1234567890' - assert data[0]['date_of_birth'] is None + assert data[0]['year_of_birth'] == 1988 assert data[0]['gender'] == 'm' From a5b1fdd19718bbee8643f9236ee398d12fc4bc52 Mon Sep 17 00:00:00 2001 From: Shadi Naif Date: Sun, 21 Apr 2024 14:59:28 +0300 Subject: [PATCH 6/6] feat: bump version to 0.2.0 --- futurex_openedx_extensions/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/futurex_openedx_extensions/__init__.py b/futurex_openedx_extensions/__init__.py index 372375a1..5a04fb70 100644 --- a/futurex_openedx_extensions/__init__.py +++ b/futurex_openedx_extensions/__init__.py @@ -1,3 +1,3 @@ """One-line description for README and other doc files.""" -__version__ = '0.1.1' +__version__ = '0.2.1'