diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index 34cb846..0000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1 +0,0 @@ -@bryanlandia diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index e5e3dc3..0000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,29 +0,0 @@ -## Change description - -> Description here -## Type of change -- [ ] Bug fix (fixes an issue) -- [ ] New feature (adds functionality) - -## Related issues - -> Fix [#1]() -## Checklists - -### Development - -- [ ] Lint rules pass locally -- [ ] Application changes have been tested thoroughly -- [ ] Automated tests covering modified code pass - -### Security - -- [ ] Security impact of change has been considered -- [ ] Code follows company security practices and guidelines - -### Code review - -- [ ] Pull request has a descriptive title and context useful to a reviewer. Screenshots or screencasts are attached as necessary -- [ ] "Ready for review" label attached and reviewers assigned -- [ ] Changes have been reviewed by at least one other contributor -- [ ] Pull request linked to task tracker where applicable diff --git a/.github/issue_template.md b/.github/issue_template.md deleted file mode 100644 index f8b8ed9..0000000 --- a/.github/issue_template.md +++ /dev/null @@ -1,17 +0,0 @@ -## Expected Behavior - - -## Actual Behavior - - -## Steps to Reproduce the Problem - - 1. - 1. - 1. - -## Specifications - - - Version: - - Platform: - - Subsystem: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..6df6bbb --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,29 @@ +name: Tests + +on: + workflow_dispatch: + push: + branches: [main] + pull_request: + types: [opened, reopened, synchronize] + + +jobs: + tests: + name: tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Setup python + uses: actions/setup-python@v2 + with: + python-version: "3.11" + + - name: Install tox + run: | + pip install "tox<5" + + - name: Run quality tests + run: make quality diff --git a/.gitignore b/.gitignore index bf94d57..c17c528 100644 --- a/.gitignore +++ b/.gitignore @@ -32,7 +32,9 @@ pip-log.txt coverage.xml htmlcov/ - +# virtual envs +.venv +venv # The Silver Searcher .agignore diff --git a/Makefile b/Makefile index 957deb1..27b5b5c 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,4 @@ -.PHONY: clean compile_translations coverage diff_cover docs dummy_translations \ - extract_translations fake_translations help pii_check pull_translations push_translations \ - quality requirements selfcheck test test-all upgrade validate +.PHONY: clean coverage diff_cover pii_check help quality requirements selfcheck test test-all upgrade validate test_migrations format .DEFAULT_GOAL := help @@ -25,29 +23,23 @@ coverage: clean ## generate and view HTML coverage report pytest --cov-report html $(BROWSER)htmlcov/index.html -docs: ## generate Sphinx HTML documentation, including API docs - tox -e docs - $(BROWSER)docs/_build/html/index.html - # Define PIP_COMPILE_OPTS=-v to get more information during make upgrade. PIP_COMPILE = pip-compile --rebuild --upgrade $(PIP_COMPILE_OPTS) upgrade: export CUSTOM_COMPILE_COMMAND=make upgrade upgrade: ## update the requirements/*.txt files with the latest packages satisfying requirements/*.in - pip install -qr requirements/pip-tools.txt + pip install -U -qr requirements/pip-tools.in # Make sure to compile files after any other files they include! $(PIP_COMPILE) -o requirements/pip-tools.txt requirements/pip-tools.in $(PIP_COMPILE) -o requirements/base.txt requirements/base.in $(PIP_COMPILE) -o requirements/test.txt requirements/test.in - $(PIP_COMPILE) -o requirements/doc.txt requirements/doc.in $(PIP_COMPILE) -o requirements/quality.txt requirements/quality.in - $(PIP_COMPILE) -o requirements/ci.txt requirements/ci.in $(PIP_COMPILE) -o requirements/dev.txt requirements/dev.in # Let tox control the Django version for tests sed '/^[dD]jango==/d' requirements/test.txt > requirements/test.tmp mv requirements/test.tmp requirements/test.txt -quality: ## check coding style with pycodestyle and pylint +quality: selfcheck ## check coding style with pycodestyle and pylint tox -e quality pii_check: ## check for PII annotations on all Django models @@ -63,36 +55,17 @@ test: clean ## run tests in the current virtualenv diff_cover: test ## find diff lines that need test coverage diff-cover coverage.xml -test-all: quality pii_check ## run tests on every supported Python/Django combination +test-all: quality ## run tests on every supported Python/Django combination tox -validate: quality pii_check test ## run tests and quality checks +validate: quality test ## run tests and quality checks selfcheck: ## check that the Makefile is well-formed @echo "The Makefile is well-formed." -## Localization targets - -extract_translations: ## extract strings to be translated, outputting .mo files - rm -rf docs/_build - cd shoppingcart && ../manage.py makemessages -l en -v1 -d django - cd shoppingcart && ../manage.py makemessages -l en -v1 -d djangojs - -compile_translations: ## compile translation files, outputting .po files for each supported language - cd shoppingcart && ../manage.py compilemessages - -detect_changed_source_translations: - cd shoppingcart && i18n_tool changed - -pull_translations: ## pull translations from Transifex - tx pull -af --mode reviewed - -push_translations: ## push source translation files (.po) from Transifex - tx push -s - -dummy_translations: ## generate dummy translation (.po) files - cd shoppingcart && i18n_tool dummy - -build_dummy_translations: extract_translations dummy_translations compile_translations ## generate and compile dummy translation files +test_migrations: ## check that Django migrations reflect all model changes + tox -e migrations -validate_translations: build_dummy_translations detect_changed_source_translations ## validate translations +format: ## apply standard code formatting + isort shoppingcart setup.py manage.py tests test_utils test_settings.py + black shoppingcart setup.py manage.py tests test_utils test_settings.py diff --git a/README.md b/README.md index 01d378a..58be93b 100644 --- a/README.md +++ b/README.md @@ -16,3 +16,25 @@ Primary authors were @johnbaldwin, @melvinsoft Updated for standalone, Juniper/Py3 by @bryanlandia See [apidocs.md](./appsembler_api/apidocs.md) for details on usage/the API + +## Testing + +For some tests you need to run them from within a working Open edX environment. +Install in a Tutor devstack, then shell into the lms: + +```sh +tutor dev exec lms -- bash +``` + +In the LMS shell, cd to the plugin directory: + +```sh +cd /mnt/legacy-appsembler-api +``` + +Then you can run the unit tests (no unit tests yet): + +```sh +pytest +python ./manage.py makemigrations shoppingcart --check --dry-run --verbosity 3 +``` diff --git a/manage.py b/manage.py index 4afa5aa..f45575c 100644 --- a/manage.py +++ b/manage.py @@ -8,8 +8,8 @@ PWD = os.path.abspath(os.path.dirname(__file__)) -if __name__ == '__main__': - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_settings') +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_settings") sys.path.append(PWD) try: from django.core.management import execute_from_command_line @@ -18,7 +18,7 @@ # issue is really that Django is missing to avoid masking other # exceptions on Python 2. try: - import django # pylint: disable=unused-import, wrong-import-position + import django # pylint: disable=unused-import except ImportError as import_error: raise ImportError( "Couldn't import Django. Are you sure it's installed and " diff --git a/pylintrc b/pylintrc index 7d81c52..068a9ff 100644 --- a/pylintrc +++ b/pylintrc @@ -1,5 +1,73 @@ +# *************************** +# ** DO NOT EDIT THIS FILE ** +# *************************** +# +# This file was generated by edx-lint: https://github.com/openedx/edx-lint +# +# If you want to change this file, you have two choices, depending on whether +# you want to make a local change that applies only to this repo, or whether +# you want to make a central change that applies to all repos using edx-lint. +# +# Note: If your pylintrc file is simply out-of-date relative to the latest +# pylintrc in edx-lint, ensure you have the latest edx-lint installed +# and then follow the steps for a "LOCAL CHANGE". +# +# LOCAL CHANGE: +# +# 1. Edit the local pylintrc_tweaks file to add changes just to this +# repo's file. +# +# 2. Run: +# +# $ edx_lint write pylintrc +# +# 3. This will modify the local file. Submit a pull request to get it +# checked in so that others will benefit. +# +# +# CENTRAL CHANGE: +# +# 1. Edit the pylintrc file in the edx-lint repo at +# https://github.com/openedx/edx-lint/blob/master/edx_lint/files/pylintrc +# +# 2. install the updated version of edx-lint (in edx-lint): +# +# $ pip install . +# +# 3. Run (in edx-lint): +# +# $ edx_lint write pylintrc +# +# 4. Make a new version of edx_lint, submit and review a pull request with the +# pylintrc update, and after merging, update the edx-lint version and +# publish the new version. +# +# 5. In your local repo, install the newer version of edx-lint. +# +# 6. Run: +# +# $ edx_lint write pylintrc +# +# 7. This will modify the local file. Submit a pull request to get it +# checked in so that others will benefit. +# +# +# +# +# +# STAY AWAY FROM THIS FILE! +# +# +# +# +# +# SERIOUSLY. +# +# ------------------------------ +# Generated by edx-lint version: 5.4.0 +# ------------------------------ [MASTER] -ignore = migrations +ignore = persistent = yes load-plugins = edx_lint.pylint,pylint_django,pylint_celery @@ -7,6 +75,7 @@ load-plugins = edx_lint.pylint,pylint_django,pylint_celery enable = blacklisted-name, line-too-long, + abstract-class-instantiated, abstract-method, access-member-before-definition, @@ -33,54 +102,39 @@ enable = cell-var-from-loop, confusing-with-statement, continue-in-finally, - cyclical-import, dangerous-default-value, - dict-items-not-iterating, - dict-keys-not-iterating, - dict-values-not-iterating, duplicate-argument-name, duplicate-bases, duplicate-except, duplicate-key, - eq-without-hash, - exception-escape, - exception-message-attribute, expression-not-assigned, - filter-builtin-not-iterating, format-combined-specification, format-needs-mapping, function-redefined, global-variable-undefined, - # import-error, + import-error, import-self, inconsistent-mro, - indexing-exception, inherit-non-class, init-is-generator, invalid-all-object, - invalid-encoded-data, invalid-format-index, invalid-length-returned, invalid-sequence-index, invalid-slice-index, invalid-slots-object, invalid-slots, - invalid-str-codec, invalid-unary-operand-type, logging-too-few-args, logging-too-many-args, logging-unsupported-format, lost-exception, - map-builtin-not-iterating, method-hidden, misplaced-bare-raise, misplaced-future, missing-format-argument-key, missing-format-attribute, missing-format-string-key, - missing-super-argument, - mixed-fomat-string, - model-unicode-not-callable, no-member, no-method-argument, no-name-in-module, @@ -89,8 +143,6 @@ enable = non-iterator-returned, non-parent-method-called, nonexistent-operator, - nonimplemented-raised, - nonstandard-exception, not-a-mapping, not-an-iterable, not-callable, @@ -98,35 +150,25 @@ enable = not-in-loop, pointless-statement, pointless-string-statement, - property-on-old-class, raising-bad-type, raising-non-exception, - raising-string, - range-builtin-not-iterating, redefined-builtin, - redefined-in-handler, redefined-outer-name, - redefined-variable-type, redundant-keyword-arg, - relative-import, repeated-keyword, return-arg-in-generator, return-in-init, return-outside-function, signature-differs, - slots-on-old-class, super-init-not-called, super-method-not-called, - super-on-old-class, syntax-error, - sys-max-int, test-inherits-tests, too-few-format-args, too-many-format-args, too-many-function-args, translation-of-non-string, truncated-format-string, - unbalance-tuple-unpacking, undefined-all-variable, undefined-loop-variable, undefined-variable, @@ -142,25 +184,22 @@ enable = used-before-assignment, using-constant-test, yield-outside-function, - zip-builtin-not-iterating, astroid-error, - django-not-available-placeholder, - django-not-available, fatal, method-check-failed, parse-error, raw-checker-failed, - # empty-docstring, + empty-docstring, invalid-characters-in-docstring, - # missing-docstring, + missing-docstring, wrong-spelling-in-comment, wrong-spelling-in-docstring, unused-argument, unused-import, - # unused-variable, + unused-variable, eval-used, exec-used, @@ -168,7 +207,6 @@ enable = bad-classmethod-argument, bad-mcs-classmethod-argument, bad-mcs-method-argument, - bad-whitespace, bare-except, broad-except, consider-iterating-dictionary, @@ -178,17 +216,11 @@ enable = literal-used-as-attribute, logging-format-interpolation, logging-not-lazy, - metaclass-assignment, - model-has-unicode, - model-missing-unicode, - model-no-explicit-unicode, multiple-imports, multiple-statements, no-classmethod-decorator, no-staticmethod-decorator, - old-raise-syntax, - old-style-class, - # protected-access, + protected-access, redundant-unittest-assert, reimported, simplifiable-if-statement, @@ -215,7 +247,6 @@ enable = wrong-import-position, missing-final-newline, - mixed-indentation, mixed-line-endings, trailing-newlines, trailing-whitespace, @@ -226,119 +257,21 @@ enable = deprecated-pragma, unrecognized-inline-option, useless-suppression, - - cmp-method, - coerce-method, - delslice-method, - dict-iter-method, - dict-view-method, - div-method, - getslice-method, - hex-method, - idiv-method, - next-method-called, - next-method-defined, - nonzero-method, - oct-method, - rdiv-method, - setslice-method, - using-cmp-argument, disable = - protected-access, - unused-variable, - useless-supression, - no-member, - raise-missing-from, - imported-auth-user, - empty-docstring, + import-error, missing-class-docstring, missing-function-docstring, - import-error, - missing-module-docstring, - bad-continuation, - bad-indentation, consider-using-f-string, - duplicate-code, - file-ignored, - fixme, - global-statement, - invalid-name, - locally-disabled, - locally-enabled, - lowercase-l-suffix, - misplaced-comparison-constant, - no-else-return, - no-init, - no-self-use, - suppressed-message, too-few-public-methods, - too-many-ancestors, - too-many-arguments, - too-many-branches, - too-many-instance-attributes, - too-many-lines, - too-many-locals, - too-many-public-methods, - too-many-return-statements, - ungrouped-imports, - unspecified-encoding, - unused-wildcard-import, - use-maxsplit-arg, - - feature-toggle-needs-doc, - illegal-waffle-usage, - - apply-builtin, - backtick, - bad-python3-import, - basestring-builtin, - buffer-builtin, - cmp-builtin, - coerce-builtin, - deprecated-itertools-function, - deprecated-operator-function, - deprecated-str-translate-call, - deprecated-string-function, - deprecated-sys-function, - deprecated-types-field, - deprecated-urllib-function, - execfile-builtin, - file-builtin, - import-star-module-level, - input-builtin, - intern-builtin, - long-builtin, - long-suffix, - no-absolute-import, - non-ascii-bytes-literal, - old-division, - old-ne-operator, - old-octal-literal, - parameter-unpacking, - print-statement, - raw_input-builtin, - reduce-builtin, - reload-builtin, - round-builtin, - standarderror-builtin, - unichr-builtin, - unicode-builtin, - unpacking-in-except, - xrange-builtin, - - logging-fstring-interpolation, - invalid-name, django-not-configured, - consider-using-with, + raise-missing-from, [REPORTS] output-format = text -files-output = no reports = no score = no [BASIC] -bad-functions = map,filter,apply,input module-rgx = (([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ const-rgx = (([A-Z_][A-Z0-9_]*)|(__.*__)|log|urlpatterns)$ class-rgx = [A-Z_][a-zA-Z0-9]+$ @@ -355,10 +288,9 @@ no-docstring-rgx = __.*__$|test_.+|setUp$|setUpClass$|tearDown$|tearDownClass$|M docstring-min-length = 5 [FORMAT] -max-line-length = 100 +max-line-length = 120 ignore-long-lines = ^\s*(# )?((?)|(\.\. \w+: .*))$ single-line-if-stmt = no -no-space-check = trailing-comma,dict-separator max-module-lines = 1000 indent-string = ' ' @@ -427,6 +359,6 @@ ext-import-graph = int-import-graph = [EXCEPTIONS] -overgeneral-exceptions = Exception +overgeneral-exceptions = builtins.Exception -# fdd6243c2a1620197611c864233486f2192f1144 +# a75e30c55a99c04aebb562bcf97d561b90344174 diff --git a/pylintrc_tweaks b/pylintrc_tweaks new file mode 100644 index 0000000..85aafc9 --- /dev/null +++ b/pylintrc_tweaks @@ -0,0 +1,10 @@ + +[MESSAGES CONTROL] +disable = + import-error, + missing-class-docstring, + missing-function-docstring, + consider-using-f-string, + too-few-public-methods, + django-not-configured, + raise-missing-from, diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..55ec8d7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,2 @@ +[tool.black] +line-length = 120 diff --git a/requirements/base.in b/requirements/base.in index a954780..efd51e5 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -3,3 +3,4 @@ Django # Web application framework +djangorestframework diff --git a/requirements/base.txt b/requirements/base.txt index 0f62934..f0ff9f1 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,16 +1,17 @@ # -# This file is autogenerated by pip-compile with python 3.9 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: # -# pip-compile --output-file=requirements/base.txt requirements/base.in +# make upgrade # -asgiref==3.5.2 +asgiref==3.9.1 # via django -django==3.2.13 +django==4.2.24 # via - # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/base.in -pytz==2022.1 - # via django -sqlparse==0.4.2 + # djangorestframework +djangorestframework==3.16.1 + # via -r requirements/base.in +sqlparse==0.5.3 # via django diff --git a/requirements/dev.in b/requirements/dev.in index 4a7645b..4925daf 100644 --- a/requirements/dev.in +++ b/requirements/dev.in @@ -3,7 +3,6 @@ -r pip-tools.txt # pip-tools and its dependencies, for managing requirements files -r quality.txt # Core and quality check dependencies --r ci.txt # dependencies for setting up testing in CI diff-cover # Changeset diff test coverage edx-i18n-tools # For i18n_tool dummy diff --git a/requirements/dev.txt b/requirements/dev.txt new file mode 100644 index 0000000..3dd8025 --- /dev/null +++ b/requirements/dev.txt @@ -0,0 +1,345 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# make upgrade +# +asgiref==3.9.1 + # via + # -r requirements/quality.txt + # django +astroid==3.3.11 + # via + # -r requirements/quality.txt + # pylint + # pylint-celery +backports-tarfile==1.2.0 + # via + # -r requirements/quality.txt + # jaraco-context +black==25.9.0 + # via -r requirements/quality.txt +build==1.3.0 + # via + # -r requirements/pip-tools.txt + # pip-tools +certifi==2025.8.3 + # via + # -r requirements/quality.txt + # requests +cffi==2.0.0 + # via + # -r requirements/quality.txt + # cryptography +chardet==5.2.0 + # via diff-cover +charset-normalizer==3.4.3 + # via + # -r requirements/quality.txt + # requests +click==8.3.0 + # via + # -r requirements/pip-tools.txt + # -r requirements/quality.txt + # black + # click-log + # code-annotations + # edx-lint + # pip-tools +click-log==0.4.0 + # via + # -r requirements/quality.txt + # edx-lint +code-annotations==2.3.0 + # via + # -r requirements/quality.txt + # edx-lint +coverage[toml]==7.10.7 + # via + # -r requirements/quality.txt + # pytest-cov +cryptography==46.0.1 + # via + # -r requirements/quality.txt + # secretstorage +diff-cover==9.6.0 + # via -r requirements/dev.in +dill==0.4.0 + # via + # -r requirements/quality.txt + # pylint +distlib==0.4.0 + # via virtualenv +django==4.2.24 + # via + # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -r requirements/quality.txt + # djangorestframework + # edx-i18n-tools +djangorestframework==3.16.1 + # via -r requirements/quality.txt +docutils==0.22.2 + # via + # -r requirements/quality.txt + # readme-renderer +edx-i18n-tools==1.9.0 + # via -r requirements/dev.in +edx-lint==5.6.0 + # via -r requirements/quality.txt +filelock==3.19.1 + # via + # tox + # virtualenv +id==1.5.0 + # via + # -r requirements/quality.txt + # twine +idna==3.10 + # via + # -r requirements/quality.txt + # requests +importlib-metadata==8.7.0 + # via + # -r requirements/quality.txt + # keyring +iniconfig==2.1.0 + # via + # -r requirements/quality.txt + # pytest +isort==6.0.1 + # via + # -r requirements/quality.txt + # pylint +jaraco-classes==3.4.0 + # via + # -r requirements/quality.txt + # keyring +jaraco-context==6.0.1 + # via + # -r requirements/quality.txt + # keyring +jaraco-functools==4.3.0 + # via + # -r requirements/quality.txt + # keyring +jeepney==0.9.0 + # via + # -r requirements/quality.txt + # keyring + # secretstorage +jinja2==3.1.6 + # via + # -r requirements/quality.txt + # code-annotations + # diff-cover +keyring==25.6.0 + # via + # -r requirements/quality.txt + # twine +lxml[html-clean]==6.0.2 + # via + # edx-i18n-tools + # lxml-html-clean +lxml-html-clean==0.4.2 + # via lxml +markdown-it-py==4.0.0 + # via + # -r requirements/quality.txt + # rich +markupsafe==3.0.2 + # via + # -r requirements/quality.txt + # jinja2 +mccabe==0.7.0 + # via + # -r requirements/quality.txt + # pylint +mdurl==0.1.2 + # via + # -r requirements/quality.txt + # markdown-it-py +more-itertools==10.8.0 + # via + # -r requirements/quality.txt + # jaraco-classes + # jaraco-functools +mypy-extensions==1.1.0 + # via + # -r requirements/quality.txt + # black +nh3==0.3.0 + # via + # -r requirements/quality.txt + # readme-renderer +packaging==25.0 + # via + # -r requirements/pip-tools.txt + # -r requirements/quality.txt + # black + # build + # pytest + # tox + # twine +path==16.16.0 + # via edx-i18n-tools +pathspec==0.12.1 + # via + # -r requirements/quality.txt + # black +pip-tools==7.5.0 + # via -r requirements/pip-tools.txt +platformdirs==4.4.0 + # via + # -r requirements/quality.txt + # black + # pylint + # virtualenv +pluggy==1.6.0 + # via + # -r requirements/quality.txt + # diff-cover + # pytest + # pytest-cov + # tox +polib==1.2.0 + # via edx-i18n-tools +py==1.11.0 + # via tox +pycodestyle==2.14.0 + # via -r requirements/quality.txt +pycparser==2.23 + # via + # -r requirements/quality.txt + # cffi +pydocstyle==6.3.0 + # via -r requirements/quality.txt +pygments==2.19.2 + # via + # -r requirements/quality.txt + # diff-cover + # pytest + # readme-renderer + # rich +pylint==3.3.8 + # via + # -r requirements/quality.txt + # edx-lint + # pylint-celery + # pylint-django + # pylint-plugin-utils +pylint-celery==0.3 + # via + # -r requirements/quality.txt + # edx-lint +pylint-django==2.6.1 + # via + # -r requirements/quality.txt + # edx-lint +pylint-plugin-utils==0.9.0 + # via + # -r requirements/quality.txt + # pylint-celery + # pylint-django +pyproject-hooks==1.2.0 + # via + # -r requirements/pip-tools.txt + # build + # pip-tools +pytest==8.4.2 + # via + # -r requirements/quality.txt + # pytest-cov + # pytest-django +pytest-cov==7.0.0 + # via -r requirements/quality.txt +pytest-django==4.11.1 + # via -r requirements/quality.txt +python-slugify==8.0.4 + # via + # -r requirements/quality.txt + # code-annotations +pytokens==0.1.10 + # via + # -r requirements/quality.txt + # black +pyyaml==6.0.2 + # via + # -r requirements/quality.txt + # code-annotations + # edx-i18n-tools +readme-renderer==44.0 + # via + # -r requirements/quality.txt + # twine +requests==2.32.5 + # via + # -r requirements/quality.txt + # id + # requests-toolbelt + # twine +requests-toolbelt==1.0.0 + # via + # -r requirements/quality.txt + # twine +rfc3986==2.0.0 + # via + # -r requirements/quality.txt + # twine +rich==14.1.0 + # via + # -r requirements/quality.txt + # twine +secretstorage==3.4.0 + # via + # -r requirements/quality.txt + # keyring +six==1.17.0 + # via + # -r requirements/quality.txt + # edx-lint + # tox +snowballstemmer==3.0.1 + # via + # -r requirements/quality.txt + # pydocstyle +sqlparse==0.5.3 + # via + # -r requirements/quality.txt + # django +stevedore==5.5.0 + # via + # -r requirements/quality.txt + # code-annotations +text-unidecode==1.3 + # via + # -r requirements/quality.txt + # python-slugify +tomlkit==0.13.3 + # via + # -r requirements/quality.txt + # pylint +tox==3.28.0 + # via tox-battery +tox-battery==0.6.2 + # via -r requirements/dev.in +twine==6.2.0 + # via -r requirements/quality.txt +urllib3==2.5.0 + # via + # -r requirements/quality.txt + # requests + # twine +virtualenv==20.34.0 + # via tox +wheel==0.45.1 + # via + # -r requirements/pip-tools.txt + # pip-tools +zipp==3.23.0 + # via + # -r requirements/quality.txt + # importlib-metadata + +# The following packages are considered to be unsafe in a requirements file: +# pip +# setuptools diff --git a/requirements/pip-tools.txt b/requirements/pip-tools.txt index 3d3242d..d2ab0a7 100644 --- a/requirements/pip-tools.txt +++ b/requirements/pip-tools.txt @@ -1,18 +1,22 @@ # -# This file is autogenerated by pip-compile with python 3.8 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: # # make upgrade # -click==8.1.2 +build==1.3.0 # via pip-tools -pep517==0.12.0 +click==8.3.0 # via pip-tools -pip-tools==6.6.0 - # via -r python-template/placeholder_repo_name_0/requirements/pip-tools.in -tomli==2.0.1 - # via pep517 -wheel==0.37.1 +packaging==25.0 + # via build +pip-tools==7.5.0 + # via -r requirements/pip-tools.in +pyproject-hooks==1.2.0 + # via + # build + # pip-tools +wheel==0.45.1 # via pip-tools # The following packages are considered to be unsafe in a requirements file: diff --git a/requirements/quality.in b/requirements/quality.in index 55ddd02..5679b41 100644 --- a/requirements/quality.in +++ b/requirements/quality.in @@ -3,6 +3,7 @@ -r test.txt # Core and testing dependencies for this package +black edx-lint # edX pylint rules and plugins isort # to standardize order of imports pycodestyle # PEP 8 compliance validation diff --git a/requirements/quality.txt b/requirements/quality.txt index 985b454..57be367 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -1,112 +1,137 @@ # -# This file is autogenerated by pip-compile with python 3.9 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: # -# pip-compile --output-file=requirements/quality.txt requirements/quality.in +# make upgrade # -asgiref==3.5.2 +asgiref==3.9.1 # via # -r requirements/test.txt # django -astroid==2.11.5 +astroid==3.3.11 # via # pylint # pylint-celery -attrs==21.4.0 - # via - # -r requirements/test.txt - # pytest -bleach==5.0.0 - # via readme-renderer -certifi==2022.5.18 +backports-tarfile==1.2.0 + # via jaraco-context +black==25.9.0 + # via -r requirements/quality.in +certifi==2025.8.3 # via requests -charset-normalizer==2.0.12 +cffi==2.0.0 + # via cryptography +charset-normalizer==3.4.3 # via requests -click==8.1.3 +click==8.3.0 # via # -r requirements/test.txt + # black # click-log # code-annotations # edx-lint click-log==0.4.0 # via edx-lint -code-annotations==1.3.0 +code-annotations==2.3.0 # via # -r requirements/test.txt # edx-lint -commonmark==0.9.1 - # via rich -coverage[toml]==6.3.3 +coverage[toml]==7.10.7 # via # -r requirements/test.txt # pytest-cov -dill==0.3.4 +cryptography==46.0.1 + # via secretstorage +dill==0.4.0 # via pylint -django==3.2.13 +django==4.2.24 # via - # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/test.txt -docutils==0.18.1 + # djangorestframework +djangorestframework==3.16.1 + # via -r requirements/test.txt +docutils==0.22.2 # via readme-renderer -edx-lint==5.2.2 +edx-lint==5.6.0 # via -r requirements/quality.in -idna==3.3 +id==1.5.0 + # via twine +idna==3.10 # via requests -importlib-metadata==4.11.3 - # via - # keyring - # twine -iniconfig==1.1.1 +importlib-metadata==8.7.0 + # via keyring +iniconfig==2.1.0 # via # -r requirements/test.txt # pytest -isort==5.10.1 +isort==6.0.1 # via # -r requirements/quality.in # pylint -jinja2==3.1.2 +jaraco-classes==3.4.0 + # via keyring +jaraco-context==6.0.1 + # via keyring +jaraco-functools==4.3.0 + # via keyring +jeepney==0.9.0 + # via + # keyring + # secretstorage +jinja2==3.1.6 # via # -r requirements/test.txt # code-annotations -keyring==23.5.0 +keyring==25.6.0 # via twine -lazy-object-proxy==1.7.1 - # via astroid -markupsafe==2.1.1 +markdown-it-py==4.0.0 + # via rich +markupsafe==3.0.2 # via # -r requirements/test.txt # jinja2 mccabe==0.7.0 # via pylint -packaging==21.3 +mdurl==0.1.2 + # via markdown-it-py +more-itertools==10.8.0 + # via + # jaraco-classes + # jaraco-functools +mypy-extensions==1.1.0 + # via black +nh3==0.3.0 + # via readme-renderer +packaging==25.0 # via # -r requirements/test.txt + # black # pytest -pbr==5.9.0 - # via - # -r requirements/test.txt - # stevedore -pkginfo==1.8.2 - # via twine -platformdirs==2.5.2 - # via pylint -pluggy==1.0.0 + # twine +pathspec==0.12.1 + # via black +platformdirs==4.4.0 # via - # -r requirements/test.txt - # pytest -py==1.11.0 + # black + # pylint +pluggy==1.6.0 # via # -r requirements/test.txt # pytest -pycodestyle==2.8.0 + # pytest-cov +pycodestyle==2.14.0 # via -r requirements/quality.in -pydocstyle==6.1.1 +pycparser==2.23 + # via cffi +pydocstyle==6.3.0 # via -r requirements/quality.in -pygments==2.12.0 +pygments==2.19.2 # via + # -r requirements/test.txt + # pytest # readme-renderer # rich -pylint==2.13.9 +pylint==3.3.8 # via # -r requirements/quality.in # edx-lint @@ -115,60 +140,55 @@ pylint==2.13.9 # pylint-plugin-utils pylint-celery==0.3 # via edx-lint -pylint-django==2.5.3 +pylint-django==2.6.1 # via edx-lint -pylint-plugin-utils==0.7 +pylint-plugin-utils==0.9.0 # via # pylint-celery # pylint-django -pyparsing==3.0.9 - # via - # -r requirements/test.txt - # packaging -pytest==7.1.2 +pytest==8.4.2 # via # -r requirements/test.txt # pytest-cov # pytest-django -pytest-cov==3.0.0 +pytest-cov==7.0.0 # via -r requirements/test.txt -pytest-django==4.5.2 +pytest-django==4.11.1 # via -r requirements/test.txt -python-slugify==6.1.2 +python-slugify==8.0.4 # via # -r requirements/test.txt # code-annotations -pytz==2022.1 - # via - # -r requirements/test.txt - # django -pyyaml==6.0 +pytokens==0.1.10 + # via black +pyyaml==6.0.2 # via # -r requirements/test.txt # code-annotations -readme-renderer==35.0 +readme-renderer==44.0 # via twine -requests==2.27.1 +requests==2.32.5 # via + # id # requests-toolbelt # twine -requests-toolbelt==0.9.1 +requests-toolbelt==1.0.0 # via twine rfc3986==2.0.0 # via twine -rich==12.4.1 +rich==14.1.0 # via twine -six==1.16.0 - # via - # bleach - # edx-lint -snowballstemmer==2.2.0 +secretstorage==3.4.0 + # via keyring +six==1.17.0 + # via edx-lint +snowballstemmer==3.0.1 # via pydocstyle -sqlparse==0.4.2 +sqlparse==0.5.3 # via # -r requirements/test.txt # django -stevedore==3.5.0 +stevedore==5.5.0 # via # -r requirements/test.txt # code-annotations @@ -176,28 +196,13 @@ text-unidecode==1.3 # via # -r requirements/test.txt # python-slugify -tomli==2.0.1 - # via - # -r requirements/test.txt - # coverage - # pylint - # pytest -twine==4.0.0 +tomlkit==0.13.3 + # via pylint +twine==6.2.0 # via -r requirements/quality.in -typing-extensions==4.2.0 - # via - # astroid - # pylint -urllib3==1.26.9 +urllib3==2.5.0 # via # requests # twine -webencodings==0.5.1 - # via bleach -wrapt==1.14.1 - # via astroid -zipp==3.8.0 +zipp==3.23.0 # via importlib-metadata - -# The following packages are considered to be unsafe in a requirements file: -# setuptools diff --git a/requirements/test.txt b/requirements/test.txt index fde97a8..c114844 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,66 +1,56 @@ # -# This file is autogenerated by pip-compile with python 3.9 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: # -# pip-compile --output-file=requirements/test.txt requirements/test.in +# make upgrade # -asgiref==3.5.2 +asgiref==3.9.1 # via # -r requirements/base.txt # django -attrs==21.4.0 - # via pytest -click==8.1.3 +click==8.3.0 # via code-annotations -code-annotations==1.3.0 +code-annotations==2.3.0 # via -r requirements/test.in -coverage[toml]==6.3.3 +coverage[toml]==7.10.7 # via pytest-cov -django==3.2.13 # via - # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/base.txt -iniconfig==1.1.1 + # djangorestframework +djangorestframework==3.16.1 + # via -r requirements/base.txt +iniconfig==2.1.0 # via pytest -jinja2==3.1.2 +jinja2==3.1.6 # via code-annotations -markupsafe==2.1.1 +markupsafe==3.0.2 # via jinja2 -packaging==21.3 - # via pytest -pbr==5.9.0 - # via stevedore -pluggy==1.0.0 +packaging==25.0 # via pytest -py==1.11.0 +pluggy==1.6.0 + # via + # pytest + # pytest-cov +pygments==2.19.2 # via pytest -pyparsing==3.0.9 - # via packaging -pytest==7.1.2 +pytest==8.4.2 # via # pytest-cov # pytest-django -pytest-cov==3.0.0 +pytest-cov==7.0.0 # via -r requirements/test.in -pytest-django==4.5.2 +pytest-django==4.11.1 # via -r requirements/test.in -python-slugify==6.1.2 +python-slugify==8.0.4 # via code-annotations -pytz==2022.1 - # via - # -r requirements/base.txt - # django -pyyaml==6.0 +pyyaml==6.0.2 # via code-annotations -sqlparse==0.4.2 +sqlparse==0.5.3 # via # -r requirements/base.txt # django -stevedore==3.5.0 +stevedore==5.5.0 # via code-annotations text-unidecode==1.3 # via python-slugify -tomli==2.0.1 - # via - # coverage - # pytest diff --git a/setup.cfg b/setup.cfg index 9a902de..a61eee7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,8 +1,5 @@ [isort] -include_trailing_comma = True -indent = ' ' -line_length = 120 -multi_line_output = 3 +profile = black [wheel] universal = 1 diff --git a/setup.py b/setup.py index 70c6a19..e0fe760 100755 --- a/setup.py +++ b/setup.py @@ -18,12 +18,12 @@ def get_version(*file_paths): version string """ filename = os.path.join(os.path.dirname(__file__), *file_paths) - version_file = open(filename).read() - version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", - version_file, re.M) + with open(filename, encoding="utf-8") as version_file_handle: + version_file = version_file_handle.read() + version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M) if version_match: return version_match.group(1) - raise RuntimeError('Unable to find version string.') + raise RuntimeError("Unable to find version string.") def load_requirements(*requirements_paths): @@ -49,14 +49,15 @@ def add_version_constraint_or_raise(current_line, current_requirements, add_if_n # fine to add constraints to an unconstrained package, # raise an error if there are already constraints in place if existing_version_constraints and existing_version_constraints != version_constraints: - raise BaseException('Multiple constraint definitions found for {package}:' - ' "{existing_version_constraints}" and "{version_constraints}".' - 'Combine constraints into one location with {package}' - '{existing_version_constraints},{version_constraints}.'.format( - package=package, - existing_version_constraints=existing_version_constraints, - version_constraints=version_constraints - ) + raise BaseException( + "Multiple constraint definitions found for {package}:" + ' "{existing_version_constraints}" and "{version_constraints}".' + "Combine constraints into one location with {package}" + "{existing_version_constraints},{version_constraints}.".format( + package=package, + existing_version_constraints=existing_version_constraints, + version_constraints=version_constraints, + ) ) if add_if_not_present or package in current_requirements: current_requirements[package] = version_constraints @@ -64,25 +65,25 @@ def add_version_constraint_or_raise(current_line, current_requirements, add_if_n # read requirements from .in # store the path to any constraint files that are pulled in for path in requirements_paths: - with open(path) as reqs: + with open(path, encoding="utf-8") as reqs: for line in reqs: if is_requirement(line): add_version_constraint_or_raise(line, requirements, True) - if line and line.startswith('-c') and not line.startswith('-c http'): - constraint_files.add(os.path.dirname(path) + '/' + - line.split('#')[0].replace('-c', '').strip()) + if line and line.startswith("-c") and not line.startswith("-c http"): + constraint_files.add(os.path.dirname(path) + "/" + line.split("#")[0].replace("-c", "").strip()) # process constraint files: add constraints to existing requirements for constraint_file in constraint_files: - with open(constraint_file) as reader: + with open(constraint_file, encoding="utf-8") as reader: for line in reader: if is_requirement(line): add_version_constraint_or_raise(line, requirements, False) # process back into list of pkg><=constraints strings - constrained_requirements = ['{pkg}{version}'.format( - pkg=pkg, version=version or "") for (pkg, version) in sorted(requirements.items()) + constrained_requirements = [ + "{pkg}{version}".format(pkg=pkg, version=version or "") for (pkg, version) in sorted(requirements.items()) ] + return constrained_requirements def is_requirement(line): @@ -96,46 +97,44 @@ def is_requirement(line): return line and line.strip() and not line.startswith(("-r", "#", "-e", "git+", "-c")) -VERSION = get_version('shoppingcart', '__init__.py') +VERSION = get_version("shoppingcart", "__init__.py") -if sys.argv[-1] == 'tag': +if sys.argv[-1] == "tag": print("Tagging the version on github:") os.system("git tag -a %s -m 'version %s'" % (VERSION, VERSION)) os.system("git push --tags") sys.exit() -README = open(os.path.join(os.path.dirname(__file__), 'README.md')).read() -CHANGELOG = open(os.path.join(os.path.dirname(__file__), 'CHANGELOG.rst')).read() +with open(os.path.join(os.path.dirname(__file__), "README.md"), encoding="utf-8") as f: + README = f.read() +with open(os.path.join(os.path.dirname(__file__), "CHANGELOG.rst"), encoding="utf-8") as f: + CHANGELOG = f.read() setup( - name='shoppingcart', + name="shoppingcart", version=VERSION, description="""Provides otherwise deprecated appsembler_api as a plugin LMS app""", - long_description=README + '\n\n' + CHANGELOG, - author='Appsembler, Inc.', - author_email='john@appsembler.com', - url='https://github.com/appsembler/legacy-appsembler-api', + long_description=README + "\n\n" + CHANGELOG, + author="Appsembler, Inc.", + author_email="john@appsembler.com", + url="https://github.com/appsembler/legacy-appsembler-api", packages=[ - 'shoppingcart', + "shoppingcart", ], include_package_data=True, - install_requires=load_requirements('requirements/base.in'), + install_requires=load_requirements("requirements/base.in"), python_requires=">=3.5", zip_safe=False, - keywords='Python edx', + keywords="Python edx", classifiers=[ - 'Development Status :: 3 - Alpha', - 'Framework :: Django', - 'Framework :: Django :: 2.2', - 'Intended Audience :: Developers', - 'License :: Other/Proprietary License', - 'Natural Language :: English', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', + "Development Status :: 3 - Alpha", + "Framework :: Django", + "Framework :: Django :: 2.2", + "Intended Audience :: Developers", + "License :: Other/Proprietary License", + "Natural Language :: English", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.5", ], - entry_points={ - 'lms.djangoapp': [ - 'shoppingcart = shoppingcart.apps:AppsemblerApiConfig' - ] - } + entry_points={"lms.djangoapp": ["shoppingcart = shoppingcart.apps:AppsemblerApiConfig"]}, ) diff --git a/shoppingcart/__init__.py b/shoppingcart/__init__.py index d55b01e..879c162 100644 --- a/shoppingcart/__init__.py +++ b/shoppingcart/__init__.py @@ -3,8 +3,7 @@ Provides account, enrollment, analytics APIs. """ +__version__ = "0.1.0" -__version__ = '0.1.0' - -default_app_config = 'shoppingcart.apps.AppsemblerApiConfig' # pylint: disable=invalid-name +default_app_config = "shoppingcart.apps.AppsemblerApiConfig" # pylint: disable=invalid-name diff --git a/shoppingcart/apps.py b/shoppingcart/apps.py index d62f6bc..cffdd81 100644 --- a/shoppingcart/apps.py +++ b/shoppingcart/apps.py @@ -11,13 +11,13 @@ class AppsemblerApiConfig(AppConfig): Configuration for the appsembler_api Django application. """ - name = 'shoppingcart' + name = "shoppingcart" plugin_app = { PluginURLs.CONFIG: { ProjectType.LMS: { - PluginURLs.NAMESPACE: 'appsembler_api', - PluginURLs.REGEX: '^appsembler_api/v0/', - PluginURLs.RELATIVE_PATH: 'urls', + PluginURLs.NAMESPACE: "appsembler_api", + PluginURLs.REGEX: "^appsembler_api/v0/", + PluginURLs.RELATIVE_PATH: "urls", } }, } diff --git a/shoppingcart/migrations/0005_new_initial.py b/shoppingcart/migrations/0005_new_initial.py index 9fef223..a6679b2 100644 --- a/shoppingcart/migrations/0005_new_initial.py +++ b/shoppingcart/migrations/0005_new_initial.py @@ -1,45 +1,70 @@ # Generated by Django 4.2.20 on 2025-09-19 05:43 -from django.conf import settings -from django.db import migrations, models import django.db.models.deletion import opaque_keys.edx.django.models +from django.conf import settings +from django.db import migrations, models class Migration(migrations.Migration): - replaces = [('shoppingcart', '0001_initial'), ('shoppingcart', '0002_auto_20151208_1034'), ('shoppingcart', '0003_auto_20151217_0958'), ('shoppingcart', '0004_change_meta_options')] + replaces = [ + ("shoppingcart", "0001_initial"), + ("shoppingcart", "0002_auto_20151208_1034"), + ("shoppingcart", "0003_auto_20151217_0958"), + ("shoppingcart", "0004_change_meta_options"), + ] dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('student', '0046_alter_userprofile_phone_number'), + ("student", "0046_alter_userprofile_phone_number"), ] operations = [ migrations.CreateModel( - name='CourseRegistrationCode', + name="CourseRegistrationCode", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('code', models.CharField(db_index=True, max_length=32, unique=True)), - ('course_id', opaque_keys.edx.django.models.CourseKeyField(db_index=True, max_length=255)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('order', models.IntegerField(null=True)), - ('mode_slug', models.CharField(max_length=100, null=True)), - ('is_valid', models.BooleanField(default=True)), - ('invoice', models.IntegerField(null=True)), - ('invoice_item', models.IntegerField(null=True)), - ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_by_user', to=settings.AUTH_USER_MODEL)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("code", models.CharField(db_index=True, max_length=32, unique=True)), + ("course_id", opaque_keys.edx.django.models.CourseKeyField(db_index=True, max_length=255)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("order", models.IntegerField(null=True)), + ("mode_slug", models.CharField(max_length=100, null=True)), + ("is_valid", models.BooleanField(default=True)), + ("invoice", models.IntegerField(null=True)), + ("invoice_item", models.IntegerField(null=True)), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="created_by_user", + to=settings.AUTH_USER_MODEL, + ), + ), ], ), migrations.CreateModel( - name='RegistrationCodeRedemption', + name="RegistrationCodeRedemption", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('order', models.IntegerField(null=True)), - ('redeemed_at', models.DateTimeField(auto_now_add=True, null=True)), - ('course_enrollment', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='student.courseenrollment')), - ('redeemed_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ('registration_code', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shoppingcart.courseregistrationcode')), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("order", models.IntegerField(null=True)), + ("redeemed_at", models.DateTimeField(auto_now_add=True, null=True)), + ( + "course_enrollment", + models.ForeignKey( + null=True, on_delete=django.db.models.deletion.CASCADE, to="student.courseenrollment" + ), + ), + ( + "redeemed_by", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ( + "registration_code", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="shoppingcart.courseregistrationcode" + ), + ), ], ), ] diff --git a/shoppingcart/models.py b/shoppingcart/models.py index 80bce80..b659655 100644 --- a/shoppingcart/models.py +++ b/shoppingcart/models.py @@ -2,10 +2,12 @@ Database models for appsembler_api. """ -from django.contrib.auth.models import User +from common.djangoapps.student.models import CourseEnrollment +from django.contrib import auth from django.db import models from opaque_keys.edx.django.models import CourseKeyField -from common.djangoapps.student.models import CourseEnrollment + +User = auth.get_user_model() class CourseRegistrationCode(models.Model): @@ -15,19 +17,22 @@ class CourseRegistrationCode(models.Model): .. no_pii: """ - class Meta(object): + + class Meta: app_label = "shoppingcart" code = models.CharField(max_length=32, db_index=True, unique=True) course_id = CourseKeyField(max_length=255, db_index=True) - created_by = models.ForeignKey(User, related_name='created_by_user', on_delete=models.CASCADE) + created_by = models.ForeignKey(User, related_name="created_by_user", on_delete=models.CASCADE) created_at = models.DateTimeField(auto_now_add=True) order = models.IntegerField(null=True) # this was originally a foreign key to Order, but we don't use that mode_slug = models.CharField(max_length=100, null=True) is_valid = models.BooleanField(default=True) invoice = models.IntegerField(null=True) # this was originally a foreign key to Invoice, but we don't use that - invoice_item = models.IntegerField(null=True) # this was originally a foreign key to CourseRegistrationCodeInvoiceItem, but we don't use that + invoice_item = models.IntegerField( + null=True + ) # this was originally a foreign key to CourseRegistrationCodeInvoiceItem, but we don't use that class RegistrationCodeRedemption(models.Model): @@ -36,7 +41,8 @@ class RegistrationCodeRedemption(models.Model): .. no_pii: """ - class Meta(object): + + class Meta: app_label = "shoppingcart" order = models.IntegerField(null=True) # this was originally a foreign key to Order, but we don't use that diff --git a/shoppingcart/serializers.py b/shoppingcart/serializers.py index 9ef557c..60f9d39 100644 --- a/shoppingcart/serializers.py +++ b/shoppingcart/serializers.py @@ -9,22 +9,17 @@ class StringListField(serializers.ListField): def to_internal_value(self, data): - return data.split(',') + return data.split(",") -class BulkEnrollmentSerializer(serializers.Serializer): +class BulkEnrollmentSerializer(serializers.Serializer): # pylint: disable=abstract-method """Serializes enrollment information for a collection of students/emails. This is mainly useful for implementing validation when performing bulk enrollment operations. """ + identifiers = serializers.CharField(required=True) courses = StringListField(required=True) - action = serializers.ChoiceField( - choices=( - ('enroll', 'enroll'), - ('unenroll', 'unenroll') - ), - required=True - ) + action = serializers.ChoiceField(choices=(("enroll", "enroll"), ("unenroll", "unenroll")), required=True) auto_enroll = serializers.BooleanField(default=False) email_students = serializers.BooleanField(default=False) diff --git a/shoppingcart/urls.py b/shoppingcart/urls.py index ed5631a..58e156a 100644 --- a/shoppingcart/urls.py +++ b/shoppingcart/urls.py @@ -1,10 +1,9 @@ -# pylint: disable=line-too-long, +"""API endpoint urls for the plugin""" -from django.urls import re_path from django.db import transaction - -from openedx.core.djangoapps.user_authn.views import login +from django.urls import re_path from openedx.core.djangoapps.cors_csrf.decorators import ensure_csrf_cookie_cross_domain +from openedx.core.djangoapps.user_authn.views import login from shoppingcart import views @@ -13,36 +12,47 @@ # use an Nginx redirect from that to # /appsembler_api/v0/account/login_session if you want to use this one: # override of login_session from openedx.core.djangoapps.user_authn.urls_common - re_path(r'^account/login_session/$', ensure_csrf_cookie_cross_domain(login.LoginSessionView.as_view()), - kwargs={"api_version": "v1"}, name="user_api_login_session" + re_path( + r"^account/login_session/$", + ensure_csrf_cookie_cross_domain(login.LoginSessionView.as_view()), + kwargs={"api_version": "v1"}, + name="user_api_login_session", ), - # user API re_path( - r'^accounts/user_without_password', + r"^accounts/user_without_password", transaction.non_atomic_requests(views.CreateUserAccountWithoutPasswordView.as_view()), - name="create_user_account_without_password_api" + name="create_user_account_without_password_api", ), - re_path(r'^accounts/create', + re_path( + r"^accounts/create", transaction.non_atomic_requests(views.CreateUserAccountView.as_view()), - name="create_user_account_api" + name="create_user_account_api", + ), + re_path(r"^accounts/connect", views.UserAccountConnect.as_view(), name="user_account_connect_api"), + re_path(r"^accounts/update_user", views.UpdateUserAccount.as_view(), name="user_account_update_user"), + re_path( + r"^accounts/get-user/(?P[\w.+-]+)", views.GetUserAccountView.as_view(), name="get_user_account_api" ), - re_path(r'^accounts/connect', views.UserAccountConnect.as_view(), name="user_account_connect_api"), - re_path(r'^accounts/update_user', views.UpdateUserAccount.as_view(), name="user_account_update_user"), - re_path(r'^accounts/get-user/(?P[\w.+-]+)', views.GetUserAccountView.as_view(), name="get_user_account_api"), - # Just like CourseListView API, but with search - re_path(r'^search_courses', views.CourseListSearchView.as_view(), name="course_list_search"), - + re_path(r"^search_courses", views.CourseListSearchView.as_view(), name="course_list_search"), # bulk enrollment API - re_path(r'^bulk-enrollment/bulk-enroll', views.BulkEnrollView.as_view(), name="bulk_enrollment_api"), - + re_path(r"^bulk-enrollment/bulk-enroll", views.BulkEnrollView.as_view(), name="bulk_enrollment_api"), # enrollment codes API - re_path(r'^enrollment-codes/generate', views.GenerateRegistrationCodesView.as_view(), name="generate_registration_codes_api"), # pylint: disable=line-too-long - re_path(r'^enrollment-codes/enroll-user', views.EnrollUserWithEnrollmentCodeView.as_view(), name="enroll_use_with_code_api"), # pylint: disable=line-too-long - re_path(r'^enrollment-codes/status', views.EnrollmentCodeStatusView.as_view(), name="enrollment_code_status_api"), - + re_path( + r"^enrollment-codes/generate", + views.GenerateRegistrationCodesView.as_view(), + name="generate_registration_codes_api", + ), + re_path( + r"^enrollment-codes/enroll-user", + views.EnrollUserWithEnrollmentCodeView.as_view(), + name="enroll_use_with_code_api", + ), + re_path(r"^enrollment-codes/status", views.EnrollmentCodeStatusView.as_view(), name="enrollment_code_status_api"), # enrollment analytics API - re_path(r'^analytics/accounts/batch', views.GetBatchUserDataView.as_view(), name="get_batch_user_data"), - re_path(r'^analytics/enrollment/batch', views.GetBatchEnrollmentDataView.as_view(), name="get_batch_enrollment_data"), + re_path(r"^analytics/accounts/batch", views.GetBatchUserDataView.as_view(), name="get_batch_user_data"), + re_path( + r"^analytics/enrollment/batch", views.GetBatchEnrollmentDataView.as_view(), name="get_batch_enrollment_data" + ), ] diff --git a/shoppingcart/utils.py b/shoppingcart/utils.py index 5526bb7..c345247 100644 --- a/shoppingcart/utils.py +++ b/shoppingcart/utils.py @@ -4,25 +4,31 @@ import logging import random +import secrets import string +from common.djangoapps.student.models import ( + email_exists_or_retired, + username_exists_or_retired, +) from django.conf import settings -from django.core.validators import validate_email from django.core.exceptions import ValidationError +from django.core.validators import validate_email from django.db import transaction from django.db.utils import IntegrityError from django.http import Http404 -from common.djangoapps.student.models import email_exists_or_retired, username_exists_or_retired from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers -from openedx.core.djangoapps.user_authn.views.password_reset import PasswordResetFormNoActive +from openedx.core.djangoapps.user_authn.views.password_reset import ( + PasswordResetFormNoActive, +) from .models import CourseRegistrationCode, RegistrationCodeRedemption AUDIT_LOG = logging.getLogger("audit") +# source: https://github.com/appsembler/edx-platform/blob/appsembler/psu-temp-tahoe-juniper/openedx/core/djangoapps/appsembler/api/v1/api.py#L35-L55 pylint: disable=line-too-long def account_exists(email, username): - # source: https://github.com/appsembler/edx-platform/blob/appsembler/psu-temp-tahoe-juniper/openedx/core/djangoapps/appsembler/api/v1/api.py#L35-L55 """Check if an account exists for either the email or the username Both email and username are required as parameters, but either or both can @@ -34,14 +40,8 @@ def account_exists(email, username): AccountRecovery.objects.filter(secondary_email=email).exists() ``` """ - if email and email_exists_or_retired(email): - email_exists = True - else: - email_exists = False - if username and username_exists_or_retired(username): - username_exists = True - else: - username_exists = False + email_exists = bool(email and email_exists_or_retired(email)) + username_exists = bool(username and username_exists_or_retired(username)) return email_exists or username_exists @@ -56,10 +56,10 @@ def auto_generate_username(email): except ValidationError: raise ValueError("Email is a invalid format") - username = ''.join(e for e in email.split('@')[0] if e.isalnum()) + username = "".join(e for e in email.split("@")[0] if e.isalnum()) while account_exists(username=username, email=None): - username = ''.join(e for e in email.split('@')[0] if e.isalnum()) + str(random.randint(100, 999)) + username = "".join(e for e in email.split("@")[0] if e.isalnum()) + str(random.randint(100, 999)) return username @@ -69,14 +69,15 @@ def send_activation_email(request): if form.is_valid(): form.save( use_https=request.is_secure(), - from_email=configuration_helpers.get_value( - 'email_from_address', settings.DEFAULT_FROM_EMAIL), + from_email=configuration_helpers.get_value("email_from_address", settings.DEFAULT_FROM_EMAIL), request=request, - subject_template_name='appsembler_api/set_password_subject.txt', - email_template_name='appsembler_api/set_password_email.html') + # NOTE: these templates don't exist. + # This is only used in CreateUserAccountWithoutPasswordView; I don't think that view is used any more. + subject_template_name="appsembler_api/set_password_subject.txt", + email_template_name="appsembler_api/set_password_email.html", + ) return True - else: - return False + return False def get_reg_code_validity(registration_code, request): @@ -90,13 +91,10 @@ def get_reg_code_validity(registration_code, request): except CourseRegistrationCode.DoesNotExist: reg_code_is_valid = False else: - if course_registration.is_valid: - reg_code_is_valid = True - else: - reg_code_is_valid = False + reg_code_is_valid = bool(course_registration.is_valid) reg_code_already_redeemed = RegistrationCodeRedemption.is_registration_code_redeemed(registration_code) if not reg_code_is_valid: - AUDIT_LOG.info(u"Redemption of a invalid RegistrationCode %s", registration_code) + AUDIT_LOG.info("Redemption of a invalid RegistrationCode %s", registration_code) raise Http404() return reg_code_is_valid, reg_code_already_redeemed, course_registration @@ -107,10 +105,9 @@ def generate_random_string(length): Create a string of random characters of specified length """ chars = [ - char for char in string.ascii_uppercase + string.digits + string.ascii_lowercase - if char not in 'aAeEiIoOuU1l' + char for char in string.ascii_uppercase + string.digits + string.ascii_lowercase if char not in "aAeEiIoOuU1l" ] - return ''.join((random.choice(chars) for i in range(length))) + return "".join((secrets.choice(chars) for i in range(length))) def random_code_generator(): @@ -118,11 +115,11 @@ def random_code_generator(): generate a random alphanumeric code of length defined in REGISTRATION_CODE_LENGTH settings """ - code_length = getattr(settings, 'REGISTRATION_CODE_LENGTH', 8) + code_length = getattr(settings, "REGISTRATION_CODE_LENGTH", 8) return generate_random_string(code_length) -def save_registration_code(user, course_id, mode_slug, invoice=None, order=None, invoice_item=None): +def save_registration_code(user, course_id, mode_slug): """ recursive function that generate a new code every time and saves in the Course Registration Table if validation check passes @@ -131,9 +128,6 @@ def save_registration_code(user, course_id, mode_slug, invoice=None, order=None, user (User): The user creating the course registration codes. course_id (str): The string representation of the course ID. mode_slug (str): The Course Mode Slug associated with any enrollment made by these codes. - invoice (Invoice): (Optional) The associated invoice for this code. - order (Order): (Optional) The associated order for this code. - invoice_item (CourseRegistrationCodeInvoiceItem) : (Optional) The associated CourseRegistrationCodeInvoiceItem Returns: The newly created CourseRegistrationCode. @@ -145,21 +139,18 @@ def save_registration_code(user, course_id, mode_slug, invoice=None, order=None, code=code, course_id=str(course_id), created_by=user, - invoice=invoice, - order=order, + invoice=None, + order=None, mode_slug=mode_slug, - invoice_item=invoice_item + invoice_item=None, ) try: with transaction.atomic(): course_registration.save() return course_registration except IntegrityError: - return save_registration_code( - user, course_id, mode_slug, invoice=invoice, order=order, invoice_item=invoice_item - ) + return save_registration_code(user, course_id, mode_slug) class RedemptionCodeError(Exception): - """An error occurs while processing redemption codes. """ - pass + """An error occurs while processing redemption codes.""" diff --git a/shoppingcart/views.py b/shoppingcart/views.py index 6472dcc..b065016 100644 --- a/shoppingcart/views.py +++ b/shoppingcart/views.py @@ -1,14 +1,24 @@ +"""Views for the API""" + import json import logging -import pytz -import random +import secrets import string +import pytz import search from common.djangoapps.course_modes.models import CourseMode -from openedx.core.lib.courses import get_course_by_id +from common.djangoapps.student.models import ( + AlreadyEnrolledError, + CourseEnrollment, + CourseFullError, + EnrollmentClosedError, + UserProfile, +) +from common.djangoapps.student.views import validate_new_email +from common.djangoapps.util.disable_rate_limit import can_disable_rate_limit from dateutil import parser -from django.contrib.auth.models import User +from django.contrib import auth from django.core.exceptions import NON_FIELD_ERRORS, ValidationError from django.core.validators import validate_email from django.db.models import Q @@ -28,30 +38,34 @@ EnrollmentUserThrottle, ) from openedx.core.djangoapps.user_authn.views.register import create_account_with_params -from openedx.core.djangoapps.user_authn.views.registration_form import \ - get_registration_extension_form +from openedx.core.djangoapps.user_authn.views.registration_form import ( + get_registration_extension_form, +) from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser -from openedx.core.lib.api.permissions import ApiKeyHeaderPermissionIsAuthenticated, IsStaffOrOwner +from openedx.core.lib.api.permissions import ( + ApiKeyHeaderPermissionIsAuthenticated, + IsStaffOrOwner, +) from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes +from openedx.core.lib.courses import get_course_by_id from rest_framework import status from rest_framework.generics import ListAPIView from rest_framework.response import Response from rest_framework.views import APIView -from common.djangoapps.student.models import ( - AlreadyEnrolledError, - CourseEnrollment, - CourseFullError, - EnrollmentClosedError, - UserProfile -) -from common.djangoapps.student.views import validate_new_email -from common.djangoapps.util.disable_rate_limit import can_disable_rate_limit + from .forms import CourseListGetAndSearchForm -from .serializers import BulkEnrollmentSerializer -from .utils import auto_generate_username, send_activation_email, account_exists, save_registration_code, get_reg_code_validity, RedemptionCodeError from .models import CourseRegistrationCode, RegistrationCodeRedemption +from .serializers import BulkEnrollmentSerializer +from .utils import ( + RedemptionCodeError, + account_exists, + auto_generate_username, + get_reg_code_validity, + save_registration_code, + send_activation_email, +) - +User = auth.get_user_model() log = logging.getLogger(__name__) @@ -83,16 +97,16 @@ def post(self, request): # set the honor_code and honor_code like checked, # so we can use the already defined methods for creating an user - data['honor_code'] = "True" - data['terms_of_service'] = "True" + data["honor_code"] = "True" + data["terms_of_service"] = "True" - if 'send_activation_email' in data and data['send_activation_email'] == "False": - data['send_activation_email'] = False + if "send_activation_email" in data and data["send_activation_email"] == "False": + data["send_activation_email"] = False else: - data['send_activation_email'] = True + data["send_activation_email"] = True - email = request.data.get('email') - username = request.data.get('username') + email = request.data.get("email") + username = request.data.get("username") # Handle duplicate email/username conflicts = account_exists(email=email, username=username) @@ -113,7 +127,7 @@ def post(self, request): errors = {"user_message": "Wrong parameters on user creation"} return Response(errors, status=400) - response = Response({'user_id ': user_id}, status=200) + response = Response({"user_id ": user_id}, status=200) return response @@ -123,16 +137,18 @@ class CreateUserAccountWithoutPasswordView(APIView): def post(self, request): """ + Create user account without a password. + NOTE: this is possibly no longer used """ data = request.data # set the honor_code and honor_code like checked, # so we can use the already defined methods for creating an user - data['honor_code'] = "True" - data['terms_of_service'] = "True" + data["honor_code"] = "True" + data["terms_of_service"] = "True" - email = request.data.get('email') + email = request.data.get("email") # Handle duplicate email/username conflicts = account_exists(email=email, username=None) @@ -142,14 +158,13 @@ def post(self, request): try: username = auto_generate_username(email) - password = ''.join( - random.choice( - string.ascii_uppercase + string.ascii_lowercase + string.digits) - for _ in range(32)) + password = "".join( + secrets.choice(string.ascii_uppercase + string.ascii_lowercase + string.digits) for _ in range(32) + ) - data['username'] = username - data['password'] = password - data['send_activation_email'] = False + data["username"] = username + data["password"] = password + data["send_activation_email"] = False user = create_account_with_params(request, data) # set the user as inactive @@ -163,11 +178,11 @@ def post(self, request): # Only return first error for each field errors = {"user_message": "Wrong parameters on user creation"} return Response(errors, status=400) - except ValueError as err: + except ValueError: errors = {"user_message": "Wrong email format"} return Response(errors, status=400) - response = Response({'user_id': user_id, 'username': username}, status=200) + response = Response({"user_id": user_id, "username": username}, status=200) return response @@ -198,10 +213,10 @@ def post(self, request): """ data = request.data - username = data.get('username', '') - new_email = data.get('email', '') - new_password = data.get('password', '') - new_name = data.get('name', '') + username = data.get("username", "") + new_email = data.get("email", "") + new_password = data.get("password", "") + new_name = data.get("name", "") try: user = User.objects.get(username=username) @@ -214,8 +229,7 @@ def post(self, request): validate_email(new_email) if account_exists(email=new_email, username=None): - errors = { - "user_message": "The email %s is in use by another user" % (new_email)} + errors = {"user_message": "The email %s is in use by another user" % (new_email)} return Response(errors, status=409) user.email = new_email @@ -230,19 +244,17 @@ def post(self, request): user.save() except User.DoesNotExist: - return Response( - status=status.HTTP_404_NOT_FOUND - ) + return Response(status=status.HTTP_404_NOT_FOUND) except ValidationError: errors = {"user_message": "Wrong parameters on user connection"} return Response(errors, status=400) - response = Response({'user_id': user.id}, status=200) + response = Response({"user_id": user.id}, status=200) return response class UpdateUserAccount(APIView): - """ HTTP endpoint for updating and user account """ + """HTTP endpoint for updating and user account""" authentication_classes = (BearerAuthenticationAllowInactiveUser,) permission_classes = (IsStaffOrOwner,) @@ -275,40 +287,52 @@ def post(self, request): """ data = request.data - if not str(data.get('user_lookup', '')).strip(): + if not str(data.get("user_lookup", "")).strip(): errors = {"lookup_error": "No user lookup has been provided"} return Response(errors, status=400) try: - user = User.objects.get( - Q(username=data['user_lookup']) | Q(email=data['user_lookup']) - ) + user = User.objects.get(Q(username=data["user_lookup"]) | Q(email=data["user_lookup"])) except User.DoesNotExist: - return Response({ - "user_not_found": "The user for the Given username or email doesn't exists", - }, status=404) + return Response( + { + "user_not_found": "The user for the Given username or email doesn't exists", + }, + status=404, + ) except User.MultipleObjectsReturned: - return Response({ - "lookup_error": "Two users have been found with the provided user_lookup", - }, status=400) + return Response( + { + "lookup_error": "Two users have been found with the provided user_lookup", + }, + status=400, + ) updated_fields = {} # update email - if 'email' in data and data['email'] != user.email: + if "email" in data and data["email"] != user.email: try: - validate_new_email(user, data['email']) - except ValueError as e: - return Response({"integrity_error": e.message}, status=400) + validate_new_email(user, data["email"]) + except ValueError as err: + return Response({"integrity_error": str(err)}, status=400) - user.email = data['email'] + user.email = data["email"] user.save() - updated_fields.update({'email': data['email']}) + updated_fields.update({"email": data["email"]}) # update profile fields profile_fields = [ - "name", "level_of_education", "gender", "mailing_address", "city", - "country", "goals", "bio", "year_of_birth", "language" + "name", + "level_of_education", + "gender", + "mailing_address", + "city", + "country", + "goals", + "bio", + "year_of_birth", + "language", ] profile_fields_to_update = {} @@ -331,14 +355,16 @@ def post(self, request): updated_fields.update(custom_profile_fields_to_update) if len(custom_profile_fields_to_update): - custom_form.Meta.model.objects.filter(user=user).update( - **custom_profile_fields_to_update) + custom_form.Meta.model.objects.filter(user=user).update(**custom_profile_fields_to_update) - return Response({ - "success": "The following fields has been updated: {}".format( - ', '.join( - '{}={}'.format(f, v) for f, v in list(updated_fields.items()))) - }, status=200) + return Response( + { + "success": "The following fields has been updated: {}".format( + ", ".join("{}={}".format(f, v) for f, v in list(updated_fields.items())) + ) + }, + status=200, + ) class GetUserAccountView(APIView): @@ -359,107 +385,89 @@ def get(self, request, username): """ try: - account_settings = User.objects.select_related('profile').get(username=username) + account_settings = User.objects.select_related("profile").get(username=username) except User.DoesNotExist: - return Response( - status=status.HTTP_404_NOT_FOUND - ) + return Response(status=status.HTTP_404_NOT_FOUND) - return Response({'user_id': account_settings.username}, status=200) + return Response({"user_id": account_settings.username}, status=200) @can_disable_rate_limit class BulkEnrollView(APIView, ApiKeyPermissionMixIn): - authentication_classes = ( - BearerAuthenticationAllowInactiveUser, - EnrollmentCrossDomainSessionAuth - ) + authentication_classes = (BearerAuthenticationAllowInactiveUser, EnrollmentCrossDomainSessionAuth) permission_classes = (ApiKeyHeaderPermissionIsAuthenticated,) throttle_classes = (EnrollmentUserThrottle,) def post(self, request): serializer = BulkEnrollmentSerializer(data=request.data) if serializer.is_valid(): - request._request.POST = request.data + request._request.POST = request.data # pylint: disable=protected-access response_dict = { - 'auto_enroll': serializer.data.get('auto_enroll'), - 'email_students': serializer.data.get('email_students'), - 'action': serializer.data.get('action'), - 'courses': {} + "auto_enroll": serializer.data.get("auto_enroll"), + "email_students": serializer.data.get("email_students"), + "action": serializer.data.get("action"), + "courses": {}, } - for course in serializer.data.get('courses'): + for course in serializer.data.get("courses"): response = students_update_enrollment( - request._request, course_id=course + request._request, # pylint: disable=protected-access + course_id=course, ) - response_dict['courses'][course] = json.loads(response.content.decode('utf-8')) + response_dict["courses"][course] = json.loads(response.content.decode("utf-8")) return Response(data=response_dict, status=status.HTTP_200_OK) - else: - return Response( - serializer.errors, status=status.HTTP_400_BAD_REQUEST - ) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) class GenerateRegistrationCodesView(APIView): - authentication_classes = ( - BearerAuthenticationAllowInactiveUser, - EnrollmentCrossDomainSessionAuth - ) + authentication_classes = (BearerAuthenticationAllowInactiveUser, EnrollmentCrossDomainSessionAuth) permission_classes = (IsStaffOrOwner,) def post(self, request): - course_id = CourseKey.from_string(request.data.get('course_id')) + course_id = CourseKey.from_string(request.data.get("course_id")) try: - course_code_number = int( - request.data.get('total_registration_codes') - ) + course_code_number = int(request.data.get("total_registration_codes")) except ValueError: - course_code_number = int( - float(request.data.get('total_registration_codes')) - ) + course_code_number = int(float(request.data.get("total_registration_codes"))) course_mode = CourseMode.DEFAULT_MODE_SLUG registration_codes = [] for __ in range(course_code_number): generated_registration_code = save_registration_code( - request.user, course_id, course_mode, order=None, + request.user, + course_id, + course_mode, ) registration_codes.append(generated_registration_code.code) return Response( data={ - 'codes': registration_codes, - 'course_id': request.data.get('course_id'), - 'course_url': reverse( - 'about_course', - kwargs={'course_id': request.data.get('course_id')} - ) + "codes": registration_codes, + "course_id": request.data.get("course_id"), + "course_url": reverse("about_course", kwargs={"course_id": request.data.get("course_id")}), } ) class EnrollUserWithEnrollmentCodeView(APIView): - authentication_classes = ( - BearerAuthenticationAllowInactiveUser, - EnrollmentCrossDomainSessionAuth - ) + authentication_classes = (BearerAuthenticationAllowInactiveUser, EnrollmentCrossDomainSessionAuth) permission_classes = (IsStaffOrOwner,) def post(self, request): if is_ratelimited(request, key="user", group="enrollment-codes.enroll-user", rate="6/m"): raise Ratelimited() - enrollment_code = request.data.get('enrollment_code') + enrollment_code = request.data.get("enrollment_code") error_reason = "" try: - user = User.objects.get(email=request.data.get('email')) + user = User.objects.get(email=request.data.get("email")) user_is_valid = True except User.DoesNotExist: user_is_valid = False error_reason = "User not found" try: - reg_code_is_valid, reg_code_already_redeemed, course_registration = get_reg_code_validity( # pylint: disable=line-too-long + reg_code_is_valid, reg_code_already_redeemed, course_registration = get_reg_code_validity( enrollment_code, request, ) @@ -471,21 +479,23 @@ def post(self, request): error_reason = "Enrollment code not found" if user_is_valid and reg_code_is_valid and not reg_code_already_redeemed: course = get_course_by_id(course_registration.course_id, depth=0) - redemption = RegistrationCodeRedemption.create_invoice_generated_registration_redemption( # pylint: disable=line-too-long - course_registration, - user) + redemption = RegistrationCodeRedemption.create_invoice_generated_registration_redemption( + course_registration, user + ) try: kwargs = {} if course_registration.mode_slug is not None: if CourseMode.mode_for_course(course.id, course_registration.mode_slug): - kwargs['mode'] = course_registration.mode_slug + kwargs["mode"] = course_registration.mode_slug else: raise RedemptionCodeError() redemption.course_enrollment = CourseEnrollment.enroll(user, course.id, **kwargs) redemption.save() - return Response(data={ - 'success': True, - }) + return Response( + data={ + "success": True, + } + ) except RedemptionCodeError: error_reason = "Enrollment code error" except EnrollmentClosedError: @@ -496,10 +506,10 @@ def post(self, request): error_reason = "Already enrolled" return Response( data={ - 'success': False, - 'reason': error_reason, + "success": False, + "reason": error_reason, }, - status=status.HTTP_400_BAD_REQUEST + status=status.HTTP_400_BAD_REQUEST, ) @@ -512,51 +522,44 @@ class EnrollmentCodeStatusView(APIView): restore: If the code was user for enroll an user, the user is unenrolled and the code becomes available for use it again. """ - authentication_classes = ( - BearerAuthenticationAllowInactiveUser, EnrollmentCrossDomainSessionAuth - ) + + authentication_classes = (BearerAuthenticationAllowInactiveUser, EnrollmentCrossDomainSessionAuth) permission_classes = (IsStaffOrOwner,) def post(self, request): - code = request.data.get('enrollment_code') - action = request.data.get('action') + code = request.data.get("enrollment_code") + action = request.data.get("action") try: registration_code = CourseRegistrationCode.objects.get(code=code) except CourseRegistrationCode.DoesNotExist: return Response( - data={ - 'reason': 'The enrollment code ({code}) was not found'.format(code=code), - 'success': False}, - status=400 + data={"reason": "The enrollment code ({code}) was not found".format(code=code), "success": False}, + status=400, ) # check if the code was in use (redeemed) redemption = RegistrationCodeRedemption.get_registration_code_redemption( registration_code.code, registration_code.course_id ) - if action == 'cancel': + if action == "cancel": if redemption: # if was redeemed, unenroll the user from the course and delete the # redemption object. - CourseEnrollment.unenroll( - redemption.course_enrollment.user, registration_code.course_id - ) + CourseEnrollment.unenroll(redemption.course_enrollment.user, registration_code.course_id) redemption.delete() # make the enrollment code unavailable registration_code.is_valid = False registration_code.save() - if action == 'restore': + if action == "restore": if redemption: # if was redeemed, unenroll the user from the course and delete the # redemption object. - CourseEnrollment.unenroll( - redemption.course_enrollment.user, registration_code.course_id - ) + CourseEnrollment.unenroll(redemption.course_enrollment.user, registration_code.course_id) redemption.delete() # make the enrollment code available registration_code.is_valid = True registration_code.save() - return Response(data={'success': True}) + return Response(data={"success": True}) class GetBatchUserDataView(APIView): @@ -565,16 +568,16 @@ class GetBatchUserDataView(APIView): def get(self, request): """ - /appsembler_api/v0/analytics/accounts/batch[?time-parameter] + /appsembler_api/v0/analytics/accounts/batch[?time-parameter] - time-parameter is an optional query parameter of: - ?updated_min=yyyy-mm-ddThh:mm:ss - ?updated_max=yyyy-mm-ddThh:mm:ss - ?updated_min=yyyy-mm-ddThh:mm:ss&updated_max=yyyy-mm-ddThh:mm:ss + time-parameter is an optional query parameter of: + ?updated_min=yyyy-mm-ddThh:mm:ss + ?updated_max=yyyy-mm-ddThh:mm:ss + ?updated_min=yyyy-mm-ddThh:mm:ss&updated_max=yyyy-mm-ddThh:mm:ss """ - updated_min = request.GET.get('updated_min', '') - updated_max = request.GET.get('updated_max', '') + updated_min = request.GET.get("updated_min", "") + updated_max = request.GET.get("updated_max", "") users = User.objects.all() if updated_min: @@ -588,11 +591,11 @@ def get(self, request): user_list = [] for user in users: user_data = { - 'id': user.id, - 'username': user.username, - 'email': user.email, - 'is_active': user.is_active, - 'date_joined': user.date_joined + "id": user.id, + "username": user.username, + "email": user.email, + "is_active": user.is_active, + "date_joined": user.date_joined, } user_list.append(user_data) @@ -683,37 +686,32 @@ def get_queryset(self): """ Return a list of courses visible to the user. """ - form = CourseListGetAndSearchForm( - self.request.query_params, initial={'requesting_user': self.request.user} - ) + form = CourseListGetAndSearchForm(self.request.query_params, initial={"requesting_user": self.request.user}) if not form.is_valid(): raise ValidationError(form.errors) courses_db = list_courses( self.request, - form.cleaned_data['username'], - org=form.cleaned_data['org'], - filter_=form.cleaned_data['filter_'], + form.cleaned_data["username"], + org=form.cleaned_data["org"], + filter_=form.cleaned_data["filter_"], ) courses_search = search.api.course_discovery_search( - form.cleaned_data['search_term'], + form.cleaned_data["search_term"], size=self.results_size_infinity, ) - course_search_ids = {course['data']['id']: True for course in courses_search['results']} + course_search_ids = {course["data"]["id"]: True for course in courses_search["results"]} - return [ - course for course in courses_db - if str(course.id) in course_search_ids - ] + return [course for course in courses_db if str(course.id) in course_search_ids] class GetBatchEnrollmentDataView(APIView): authentication_classes = (BearerAuthenticationAllowInactiveUser,) permission_classes = (IsStaffOrOwner,) - def get(self, request): + def get(self, request): # pylint: disable=too-many-locals """ /appsembler_api/v0/analytics/accounts/batch[?course_id=course_slug&time-parameter] @@ -728,60 +726,58 @@ def get(self, request): ?updated_min=yyyy-mm-ddThh:mm:ss&updated_max=yyyy-mm-ddThh:mm:ss """ - updated_min = request.GET.get('updated_min', '') - updated_max = request.GET.get('updated_max', '') - course_id = request.GET.get('course_id') - username = request.GET.get('username') + updated_min = request.GET.get("updated_min", "") + updated_max = request.GET.get("updated_max", "") + course_id = request.GET.get("course_id") + username = request.GET.get("username") enrollment_query_filter = {} cert_query_filter = {} if course_id: - course_id = course_id.replace(' ', '+') + course_id = course_id.replace(" ", "+") # the replace function is because Django encodes '+' or '%2B' as spaces if course_id: course_key = CourseKey.from_string(course_id) - enrollment_query_filter['course_id'] = course_key - cert_query_filter['course_id'] = course_key + enrollment_query_filter["course_id"] = course_key + cert_query_filter["course_id"] = course_key if username: - enrollment_query_filter['user__username'] = username - cert_query_filter['user__username'] = username + enrollment_query_filter["user__username"] = username + cert_query_filter["user__username"] = username if updated_min: min_date = parser.parse(updated_min).replace(tzinfo=pytz.UTC) - enrollment_query_filter['created__gt'] = min_date - cert_query_filter['created_date__gt'] = min_date + enrollment_query_filter["created__gt"] = min_date + cert_query_filter["created_date__gt"] = min_date if updated_max: max_date = parser.parse(updated_max).replace(tzinfo=pytz.UTC) - enrollment_query_filter['created__lt'] = max_date - cert_query_filter['created_date__lt'] = max_date + enrollment_query_filter["created__lt"] = max_date + cert_query_filter["created_date__lt"] = max_date - user_ids_with_certs = GeneratedCertificate.objects.filter(**cert_query_filter).values('user_id') + user_ids_with_certs = GeneratedCertificate.objects.filter(**cert_query_filter).values("user_id") - enrollments = CourseEnrollment.objects.filter( - Q(user_id__in=user_ids_with_certs) | Q(**enrollment_query_filter)) + enrollments = CourseEnrollment.objects.filter(Q(user_id__in=user_ids_with_certs) | Q(**enrollment_query_filter)) enrollment_list = [] for enrollment in enrollments: enrollment_data = { - 'enrollment_id': enrollment.id, - 'user_id': enrollment.user.id, - 'username': enrollment.user.username, - 'course_id': str(enrollment.course_id), - 'date_enrolled': enrollment.created, + "enrollment_id": enrollment.id, + "user_id": enrollment.user.id, + "username": enrollment.user.username, + "course_id": str(enrollment.course_id), + "date_enrolled": enrollment.created, } try: - cert = GeneratedCertificate.objects.get( - course_id=enrollment.course_id, user=enrollment.user - ) - enrollment_data['certificate'] = { - 'completion_date': str(cert.created_date), - 'grade': cert.grade, - 'url': "{}/certificates/{}".format( - request._request._current_scheme_host, cert.verify_uuid + cert = GeneratedCertificate.objects.get(course_id=enrollment.course_id, user=enrollment.user) + enrollment_data["certificate"] = { + "completion_date": str(cert.created_date), + "grade": cert.grade, + "url": "{}/certificates/{}".format( + request._request._current_scheme_host, # pylint: disable=protected-access + cert.verify_uuid, ), } except GeneratedCertificate.DoesNotExist: diff --git a/test_settings.py b/test_settings.py index dbe8fc9..1199351 100644 --- a/test_settings.py +++ b/test_settings.py @@ -16,46 +16,48 @@ def root(*args): DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': 'default.db', - 'USER': '', - 'PASSWORD': '', - 'HOST': '', - 'PORT': '', + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": "default.db", + "USER": "", + "PASSWORD": "", + "HOST": "", + "PORT": "", } } INSTALLED_APPS = ( - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.messages', - 'django.contrib.sessions', - 'shoppingcart', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.messages", + "django.contrib.sessions", + "shoppingcart", ) LOCALE_PATHS = [ - root('shoppingcart', 'conf', 'locale'), + root("shoppingcart", "conf", "locale"), ] -ROOT_URLCONF = 'shoppingcart.urls' +ROOT_URLCONF = "shoppingcart.urls" -SECRET_KEY = 'insecure-secret-key' +SECRET_KEY = "insecure-secret-key" MIDDLEWARE = ( - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", ) -TEMPLATES = [{ - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'APP_DIRS': False, - 'OPTIONS': { - 'context_processors': [ - 'django.contrib.auth.context_processors.auth', # this is required for admin - 'django.contrib.messages.context_processors.messages', # this is required for admin - ], - }, -}] +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "APP_DIRS": False, + "OPTIONS": { + "context_processors": [ + "django.contrib.auth.context_processors.auth", # this is required for admin + "django.contrib.messages.context_processors.messages", # this is required for admin + ], + }, + } +] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tox.ini b/tox.ini index cf12282..3c6f4a5 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,5 @@ [tox] -envlist = quality -; py39-django{22} +envlist = quality,pii_check,migrations [doc8] ; D001 = Line too long @@ -20,7 +19,7 @@ max-line-length = 100 ; D405 = Section name should be properly capitalized (numpy style) ; D406 = Section name should end with a newline (numpy style) ; D407 = Missing dashed underline after section (numpy style) -; D408 = Section underline should be in the line following the section’s name (numpy style) +; D408 = Section underline should be in the line following the section's name (numpy style) ; D409 = Section underline should match the length of its name (numpy style) ; D410 = Missing blank line after section (numpy style) ; D411 = Missing blank line before section (numpy style) @@ -31,29 +30,26 @@ ignore = D101,D200,D203,D212,D215,D404,D405,D406,D407,D408,D409,D410,D411,D412,D match-dir = (?!migrations) [pytest] -DJANGO_SETTINGS_MODULE = test_settings addopts = --cov shoppingcart --cov-report term-missing --cov-report xml norecursedirs = .* docs requirements site-packages +django_find_project = false -[testenv] -basepython=python3 +[testenv:quality] deps = - django2: Django>=2.2,<3 - -rrequirements/test.txt -;commands = -; python manage.py check -; pytest {posargs} + -r{toxinidir}/requirements/quality.txt +commands = + pylint shoppingcart manage.py setup.py tests test_utils test_settings.py + isort --check shoppingcart setup.py manage.py tests test_utils test_settings.py + black --check shoppingcart setup.py manage.py tests test_utils test_settings.py -[testenv:quality] -basepython=python3 -whitelist_externals = - make - rm - touch +[testenv:pii_check] +deps = + -r{toxinidir}/requirements/test.txt +commands = + code_annotations django_find_annotations --config_file .pii_annotations.yml --lint --report --coverage + +[testenv:migrations] deps = - -rrequirements/quality.txt + -r{toxinidir}/requirements/base.txt commands = - touch tests/__init__.py - pylint --rcfile=pylintrc shoppingcart tests test_utils manage.py setup.py - rm tests/__init__.py - make selfcheck + python ./manage.py makemigrations shoppingcart --check --dry-run --verbosity 3