diff --git a/.github/workflows/add-depr-ticket-to-depr-board.yml b/.github/workflows/add-depr-ticket-to-depr-board.yml new file mode 100644 index 0000000..250e394 --- /dev/null +++ b/.github/workflows/add-depr-ticket-to-depr-board.yml @@ -0,0 +1,19 @@ +# Run the workflow that adds new tickets that are either: +# - labelled "DEPR" +# - title starts with "[DEPR]" +# - body starts with "Proposal Date" (this is the first template field) +# to the org-wide DEPR project board + +name: Add newly created DEPR issues to the DEPR project board + +on: + issues: + types: [opened] + +jobs: + routeissue: + uses: openedx/.github/.github/workflows/add-depr-ticket-to-depr-board.yml@master + secrets: + GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }} + GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }} + SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }} diff --git a/.github/workflows/add-remove-label-on-comment.yml b/.github/workflows/add-remove-label-on-comment.yml new file mode 100644 index 0000000..0f369db --- /dev/null +++ b/.github/workflows/add-remove-label-on-comment.yml @@ -0,0 +1,20 @@ +# This workflow runs when a comment is made on the ticket +# If the comment starts with "label: " it tries to apply +# the label indicated in rest of comment. +# If the comment starts with "remove label: ", it tries +# to remove the indicated label. +# Note: Labels are allowed to have spaces and this script does +# not parse spaces (as often a space is legitimate), so the command +# "label: really long lots of words label" will apply the +# label "really long lots of words label" + +name: Allows for the adding and removing of labels via comment + +on: + issue_comment: + types: [created] + +jobs: + add_remove_labels: + uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 442226f..bc0303f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,8 +14,9 @@ jobs: strategy: matrix: os: [ubuntu-20.04] - python-version: ['3.8'] - toxenv: [django22-drf312, django30-drf312, django31-drf312, django32-drf312, django32-drflatest, quality, docs] + python-version: ['3.8', '3.11', '3.12'] + toxenv: [quality, docs, django42-drf315, django42-drflatest] + steps: - uses: actions/checkout@v2 @@ -36,8 +37,9 @@ jobs: run: tox - name: Run Coverage - if: matrix.python-version == '3.8' && matrix.toxenv=='django22-drf312' - uses: codecov/codecov-action@v1 + if: matrix.python-version == '3.12' && matrix.toxenv=='django42-drf314' + uses: codecov/codecov-action@v4 with: flags: unittests - fail_ci_if_error: true \ No newline at end of file + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index e2b0661..fec11d6 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -7,4 +7,4 @@ on: jobs: commitlint: - uses: edx/.github/.github/workflows/commitlint.yml@master + uses: openedx/.github/.github/workflows/commitlint.yml@master diff --git a/.github/workflows/migrations-check.yml b/.github/workflows/migrations-check.yml new file mode 100644 index 0000000..08b3255 --- /dev/null +++ b/.github/workflows/migrations-check.yml @@ -0,0 +1,86 @@ +name: Migrations check on mysql8 + +on: + workflow_dispatch: + pull_request: + push: + branches: + - master + +jobs: + check_migrations: + name: check migrations + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ ubuntu-20.04 ] + python-version: [ 3.8 ] + + steps: + - name: Checkout repo + uses: actions/checkout@v2 + + - name: Setup Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install system Packages + run: | + sudo apt-get update + sudo apt-get install -y libxmlsec1-dev + + - name: Get pip cache dir + id: pip-cache-dir + run: | + echo "::set-output name=dir::$(pip cache dir)" + + - name: Cache pip dependencies + id: cache-dependencies + uses: actions/cache@v2 + with: + path: ${{ steps.pip-cache-dir.outputs.dir }} + key: ${{ runner.os }}-pip-${{ hashFiles('requirements/pip_tools.txt') }} + restore-keys: ${{ runner.os }}-pip- + + - name: Ubuntu and MySQL Versions + run: | + lsb_release -a + mysql -V + + - name: Install Python dependencies + run: | + pip install -r requirements/pip-tools.txt + pip install -r requirements/test.txt + pip install -r requirements/base.txt + pip uninstall -y mysqlclient + pip install --no-binary mysqlclient mysqlclient + + - name: Initiate Services + run: | + sudo /etc/init.d/mysql start + + - name: Reset mysql password + run: | + cat <`_ for details. - -Even though it was written with ``edx-platform`` in mind, the guidelines -should be followed for Open edX code in general. diff --git a/LICENSE b/LICENSE.txt similarity index 100% rename from LICENSE rename to LICENSE.txt diff --git a/Makefile b/Makefile index e97ec1f..524e0a5 100644 --- a/Makefile +++ b/Makefile @@ -37,12 +37,26 @@ dev_requirements: ## Install Dev Requirements test_requirements: ## Install Test Requirements pip install -r requirements/test.txt -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 +piptools: + pip install -q -r requirements/pip-tools.txt + +define COMMON_CONSTRAINTS_TEMP_COMMENT +# This is a temporary solution to override the real common_constraints.txt\n# In edx-lint, until the pyjwt constraint in edx-lint has been removed.\n# See BOM-2721 for more details.\n# Below is the copied and edited version of common_constraints\n +endef + +COMMON_CONSTRAINTS_TXT=requirements/common_constraints.txt +.PHONY: $(COMMON_CONSTRAINTS_TXT) +$(COMMON_CONSTRAINTS_TXT): + wget -O "$(@)" https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt || touch "$(@)" + echo "$(COMMON_CONSTRAINTS_TEMP_COMMENT)" | cat - $(@) > temp && mv temp $(@) + +export CUSTOM_COMPILE_COMMAND = make upgrade +upgrade: piptools $(COMMON_CONSTRAINTS_TXT) ## update the requirements/*.txt files with the latest packages satisfying requirements/*.in # Make sure to compile files after any other files they include! pip-compile --upgrade --allow-unsafe --rebuild -o requirements/pip.txt requirements/pip.in pip-compile --upgrade --verbose --rebuild -o requirements/pip-tools.txt requirements/pip-tools.in + pip install -qr requirements/pip.txt + pip install -qr requirements/pip-tools.txt pip-compile --upgrade --verbose --rebuild -o requirements/base.txt requirements/base.in pip-compile --upgrade --verbose --rebuild -o requirements/docs.txt requirements/docs.in pip-compile --upgrade --verbose --rebuild -o requirements/test.txt requirements/test.in @@ -66,7 +80,7 @@ diff_cover: test ## Generate diff coverage report test_quality: ## Run Quality checks pylint submissions - isort --check-only submissions manage.py setup.py settings.py + isort --check-only submissions manage.py setup.py settings.py --skip migrations pycodestyle . --config=pycodestyle ################## diff --git a/README.rst b/README.rst index 6df35ce..ee44177 100644 --- a/README.rst +++ b/README.rst @@ -1,44 +1,30 @@ -Part of `edX code`__. - -__ http://code.edx.org/ - -.. image:: https://github.com/edx/edx-submissions/workflows/Python%20CI/badge.svg?branch=master - :target: https://github.com/edx/edx-submissions/actions?query=workflow%3A%22Python+CI%22 - :alt: Build status - -.. image:: https://coveralls.io/repos/edx/edx-submissions/badge.png?branch=master - :target: https://coveralls.io/r/edx/edx-submissions?branch=master - :alt: Coverage badge - - edx-submissions -=============== +############### -API for creating submissions and scores. +|pypi-badge| |ci-badge| |codecov-badge| |doc-badge| |pyversions-badge| |license-badge| |status-badge| -Overview --------- +Purpose +******* ``submissions`` is a Django app that defines a common interface for creating submissions and scores. +Getting Started with Development +******************************** -Getting Started ---------------- - -To install the ``submissions`` app: +To install the ``submissions`` app, run these commands from the `edx-submissions` root directory: .. code:: bash - python setup.py install + pip install -e To run the test suite: .. code:: bash - make test_requirements - tox # to run only a single environment, do e.g. tox -e py35-django22-drf39 + pip install tox + tox # to run only a single environment, do e.g. tox -e py38-django42-drf314 To use a Django shell to test commands: @@ -46,41 +32,120 @@ To use a Django shell to test commands: .. code:: bash make dev_requirements - ./manage.py migrate - ./manage.py shell --settings=settings + python manage.py migrate + python manage.py shell --settings=settings >>> from submissions.serializers import StudentItemSerializer >>> +Deploying +********* + +Tagged versions of the edx-submissions library are released to pypi.org. + +To use the latest release in your project, add the following to your pip requirements file: + +.. code:: bash + + edx-submissions + +Getting Help +************ + +Documentation +============= + +Start by going through `the documentation`_ (generated from `/docs `_). If you need more help see below. + +.. _the documentation: https://docs.openedx.org/projects/edx-submissions + +More Help +========= + +If you're having trouble, we have discussion forums at +https://discuss.openedx.org where you can connect with others in the +community. + +Our real-time conversations are on Slack. You can request a `Slack +invitation`_, then join our `community Slack workspace`_. + +For anything non-trivial, the best path is to open an issue in this +repository with as many details about the issue you are facing as you +can provide. + +https://github.com/openedx/edx-submissions/issues + +For more information about these options, see the `Getting Help `__ page. + +.. _Slack invitation: https://openedx.org/slack +.. _community Slack workspace: https://openedx.slack.com/ + License -------- +******* The code in this repository is licensed under version 3 of the AGPL unless otherwise noted. -Please see ``LICENSE.txt`` for details. - +Please see `LICENSE.txt `_ for details. -How To Contribute ------------------ +Contributing +************ Contributions are very welcome. +Please read `How To Contribute `_ for details. -Please read `How To Contribute `_ for details. +This project is currently accepting all types of contributions, bug fixes, +security fixes, maintenance work, or new features. However, please make sure +to have a discussion about your new feature idea with the maintainers prior to +beginning development to maximize the chances of your change being accepted. +You can start a conversation by creating a new issue on this repo summarizing +your idea. -Even though it was written with ``edx-platform`` in mind, the guidelines -should be followed for Open edX code in general. +The Open edX Code of Conduct +**************************** +All community members are expected to follow the `Open edX Code of Conduct`_. + +.. _Open edX Code of Conduct: https://openedx.org/code-of-conduct/ + +People +****** + +The assigned maintainers for this component and other project details may be +found in `Backstage`_. Backstage pulls this data from the ``catalog-info.yaml`` +file in this repo. + +.. _Backstage: https://backstage.openedx.org/catalog/default/component/edx-submissions Reporting Security Issues -------------------------- +************************* + +Please do not report security issues in public. Please email security@openedx.org. + +.. |pypi-badge| image:: https://img.shields.io/pypi/v/edx-submissions.svg + :target: https://pypi.python.org/pypi/edx-submissions/ + :alt: PyPI + +.. |ci-badge| image:: https://github.com/openedx/edx-submissions/actions/workflows/ci.yml/badge.svg?branch=master + :target: https://github.com/openedx/edx-submissions/actions/workflows/ci.yml?branch=master + :alt: CI -Please do not report security issues in public. Please email security@edx.org +.. |codecov-badge| image:: https://codecov.io/github/openedx/edx-submissions/coverage.svg?branch=master + :target: https://codecov.io/github/openedx/edx-submissions?branch=master + :alt: Codecov +.. |doc-badge| image:: https://readthedocs.org/projects/edx-submissions/badge/?version=latest + :target: https://docs.openedx.org/projects/edx-submissions + :alt: Documentation -Mailing List and IRC Channel ----------------------------- +.. |pyversions-badge| image:: https://img.shields.io/pypi/pyversions/edx-submissions.svg + :target: https://pypi.python.org/pypi/edx-submissions/ + :alt: Supported Python versions -You can discuss this code on the `edx-code Google Group`__ or in the -``edx-code`` IRC channel on Freenode. +.. |license-badge| image:: https://img.shields.io/github/license/openedx/edx-submissions.svg + :target: https://github.com/openedx/edx-submissions/blob/master/LICENSE.txt + :alt: License -__ https://groups.google.com/forum/#!forum/edx-code +.. .. |status-badge| image:: https://img.shields.io/badge/Status-Experimental-yellow +.. |status-badge| image:: https://img.shields.io/badge/Status-Maintained-brightgreen +.. .. |status-badge| image:: https://img.shields.io/badge/Status-Deprecated-orange +.. .. |status-badge| image:: https://img.shields.io/badge/Status-Unsupported-red diff --git a/catalog-info.yaml b/catalog-info.yaml new file mode 100644 index 0000000..dd779ac --- /dev/null +++ b/catalog-info.yaml @@ -0,0 +1,17 @@ +# This file records information about this repo. Its use is described in OEP-55: +# https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html + +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: 'edx-submissions' + description: "API for creating submissions and scores" + links: + - url: "https://github.com/openedx/edx-submissions" + title: "Submissions API" + icon: "Web" +spec: + owner: group:committers-edx-submissions + type: 'library' + lifecycle: 'production' + subcomponentOf: 'edx-ora2' diff --git a/docs/source/conf.py b/docs/source/conf.py index 6de4a57..a4b4b2a 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -14,12 +14,14 @@ import os import sys +import django # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.insert(0, os.path.abspath('../..')) os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") +django.setup() # -- General configuration ------------------------------------------------ @@ -102,7 +104,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'default' +html_theme = 'sphinx_book_theme' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the @@ -260,3 +262,41 @@ # If true, do not generate a @detailmenu in the "Top" node's menu. #texinfo_no_detailmenu = False + +html_theme_options = { + + "repository_url": 'https://github.com/openedx/edx-submissions', + "repository_branch": 'master', + "path_to_docs": "docs/", + "use_repository_button": True, + "use_issues_button": True, + "use_edit_page_button": True, + "extra_footer": """ + + Creative Commons License + +
+ These works by + Axim Collaborative + are licensed under a + Creative Commons Attribution-ShareAlike 4.0 International License. + """ +} + +# Note the logo won't show up properly yet because there is an upstream +# bug in the theme that needs to be fixed first. +# If you'd like you can temporarily copy the logo file to your `_static` +# directory. +html_logo = "https://logos.openedx.org/open-edx-logo-color.png" +html_favicon = "https://logos.openedx.org/open-edx-favicon.ico" diff --git a/pylintrc b/pylintrc index e9761d9..2853a4d 100644 --- a/pylintrc +++ b/pylintrc @@ -2,12 +2,16 @@ # ** DO NOT EDIT THIS FILE ** # *************************** # -# This file was generated by edx-lint: http://github.com/edx/edx-lint +# 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 @@ -24,7 +28,7 @@ # CENTRAL CHANGE: # # 1. Edit the pylintrc file in the edx-lint repo at -# https://github.com/edx/edx-lint/blob/master/edx_lint/files/pylintrc +# https://github.com/openedx/edx-lint/blob/master/edx_lint/files/pylintrc # # 2. install the updated version of edx-lint (in edx-lint): # @@ -32,19 +36,16 @@ # # 3. Run (in edx-lint): # -# # uses pylintrc_tweaks from edx-lint for linting in edx-lint -# # NOTE: Use Python 3.x, which no longer includes comments in the output file # $ 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 by -# creating a new tag in the repo (uses pbr). +# 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: # -# # uses local pylintrc_tweaks # $ edx_lint write pylintrc # # 7. This will modify the local file. Submit a pull request to get it @@ -63,6 +64,8 @@ # SERIOUSLY. # # ------------------------------ +# Generated by edx-lint version: 5.3.6 +# ------------------------------ [MASTER] ignore = persistent = yes @@ -73,136 +76,116 @@ enable = blacklisted-name, line-too-long, - syntax-error, - init-is-generator, - return-in-init, - function-redefined, - not-in-loop, - return-outside-function, - yield-outside-function, - return-arg-in-generator, - nonexistent-operator, - duplicate-argument-name, abstract-class-instantiated, - bad-reversed-sequence, - continue-in-finally, - method-hidden, + abstract-method, access-member-before-definition, - no-method-argument, - no-self-argument, - invalid-slots-object, + anomalous-backslash-in-string, + anomalous-unicode-escape-in-string, + arguments-differ, + assert-on-tuple, assigning-non-slot, - invalid-slots, - inherit-non-class, - inconsistent-mro, + assignment-from-no-return, + assignment-from-none, + attribute-defined-outside-init, + bad-except-order, + bad-format-character, + bad-format-string-key, + bad-format-string, + bad-open-mode, + bad-reversed-sequence, + bad-staticmethod-argument, + bad-str-strip-call, + bad-super-call, + binary-op-exception, + boolean-datetime, + catching-non-exception, + cell-var-from-loop, + confusing-with-statement, + continue-in-finally, + dangerous-default-value, + duplicate-argument-name, duplicate-bases, - non-iterator-returned, - unexpected-special-method-signature, - invalid-length-returned, + duplicate-except, + duplicate-key, + expression-not-assigned, + format-combined-specification, + format-needs-mapping, + function-redefined, + global-variable-undefined, import-error, - used-before-assignment, - undefined-variable, - undefined-all-variable, + import-self, + inconsistent-mro, + inherit-non-class, + init-is-generator, invalid-all-object, - no-name-in-module, - unbalance-tuple-unpacking, - unpacking-non-sequence, - bad-except-order, - raising-bad-type, - misplaced-bare-raise, - raising-non-exception, - nonimplemented-raised, - catching-non-exception, - slots-on-old-class, - super-on-old-class, - bad-super-call, - missing-super-argument, - no-member, - not-callable, - assignment-from-no-return, - no-value-for-parameter, - too-many-function-args, - unexpected-keyword-arg, - redundant-keyword-arg, + invalid-format-index, + invalid-length-returned, invalid-sequence-index, invalid-slice-index, - assignment-from-none, - not-context-manager, + invalid-slots-object, + invalid-slots, invalid-unary-operand-type, - unsupported-binary-operation, - repeated-keyword, - not-an-iterable, - not-a-mapping, - unsupported-membership-test, - unsubscriptable-object, - logging-unsupported-format, - logging-too-many-args, logging-too-few-args, - bad-format-character, - truncated-format-string, - mixed-fomat-string, - format-needs-mapping, + logging-too-many-args, + logging-unsupported-format, + lost-exception, + method-hidden, + misplaced-bare-raise, + misplaced-future, + missing-format-argument-key, + missing-format-attribute, missing-format-string-key, - too-many-format-args, - too-few-format-args, - bad-str-strip-call, - model-unicode-not-callable, - super-method-not-called, - non-parent-method-called, - test-inherits-tests, - translation-of-non-string, - redefined-variable-type, - cyclical-import, - unreachable, - dangerous-default-value, + no-member, + no-method-argument, + no-name-in-module, + no-self-argument, + no-value-for-parameter, + non-iterator-returned, + nonexistent-operator, + not-a-mapping, + not-an-iterable, + not-callable, + not-context-manager, + not-in-loop, pointless-statement, pointless-string-statement, - expression-not-assigned, - duplicate-key, - confusing-with-statement, - using-constant-test, - lost-exception, - assert-on-tuple, - attribute-defined-outside-init, - bad-staticmethod-argument, - arguments-differ, + raising-bad-type, + raising-non-exception, + redefined-builtin, + redefined-outer-name, + redundant-keyword-arg, + repeated-keyword, + return-arg-in-generator, + return-in-init, + return-outside-function, signature-differs, - abstract-method, super-init-not-called, - relative-import, - import-self, - misplaced-future, - invalid-encoded-data, - global-variable-undefined, - redefined-outer-name, - redefined-builtin, - redefined-in-handler, + syntax-error, + too-few-format-args, + too-many-format-args, + too-many-function-args, + truncated-format-string, + undefined-all-variable, undefined-loop-variable, - cell-var-from-loop, - duplicate-except, - nonstandard-exception, - binary-op-exception, - property-on-old-class, - bad-format-string-key, - unused-format-string-key, - bad-format-string, - missing-format-argument-key, + undefined-variable, + unexpected-keyword-arg, + unexpected-special-method-signature, + unpacking-non-sequence, + unreachable, + unsubscriptable-object, + unsupported-binary-operation, + unsupported-membership-test, unused-format-string-argument, - format-combined-specification, - missing-format-attribute, - invalid-format-index, - anomalous-backslash-in-string, - anomalous-unicode-escape-in-string, - bad-open-mode, - boolean-datetime, + unused-format-string-key, + used-before-assignment, + using-constant-test, + yield-outside-function, - fatal, astroid-error, - parse-error, + fatal, method-check-failed, - django-not-available, + parse-error, raw-checker-failed, - django-not-available-placeholder, empty-docstring, invalid-characters-in-docstring, @@ -210,133 +193,74 @@ enable = wrong-spelling-in-comment, wrong-spelling-in-docstring, + unused-argument, unused-import, unused-variable, - unused-argument, - exec-used, eval-used, + exec-used, bad-classmethod-argument, bad-mcs-classmethod-argument, bad-mcs-method-argument, - bad-whitespace, + bare-except, + broad-except, consider-iterating-dictionary, consider-using-enumerate, - literal-used-as-attribute, + global-at-module-level, + global-variable-not-assigned, + logging-format-interpolation, + logging-not-lazy, multiple-imports, multiple-statements, - old-style-class, - simplifiable-range, + no-classmethod-decorator, + no-staticmethod-decorator, + protected-access, + redundant-unittest-assert, + reimported, + simplifiable-if-statement, singleton-comparison, superfluous-parens, unidiomatic-typecheck, - unneeded-not, - wrong-assert-type, - simplifiable-if-statement, - no-classmethod-decorator, - no-staticmethod-decorator, - unnecessary-pass, unnecessary-lambda, - useless-else-on-loop, + unnecessary-pass, unnecessary-semicolon, - reimported, - global-variable-not-assigned, - global-at-module-level, - bare-except, - broad-except, - logging-not-lazy, - redundant-unittest-assert, - model-missing-unicode, - model-has-unicode, - model-no-explicit-unicode, - protected-access, + unneeded-not, + useless-else-on-loop, - deprecated-module, deprecated-method, + deprecated-module, + too-many-boolean-expressions, too-many-nested-blocks, too-many-statements, - too-many-boolean-expressions, + wildcard-import, wrong-import-order, wrong-import-position, - wildcard-import, missing-final-newline, mixed-line-endings, trailing-newlines, trailing-whitespace, unexpected-line-ending-format, - mixed-indentation, + bad-inline-option, bad-option-value, + deprecated-pragma, unrecognized-inline-option, useless-suppression, - bad-inline-option, - deprecated-pragma, disable = - bad-continuation, - invalid-name, - misplaced-comparison-constant, - file-ignored, bad-indentation, - lowercase-l-suffix, - unused-wildcard-import, - global-statement, - no-else-return, - - apply-builtin, - backtick, - basestring-builtin, - buffer-builtin, - cmp-builtin, - cmp-method, - coerce-builtin, - coerce-method, - delslice-method, - dict-iter-method, - dict-view-method, + broad-exception-raised, + consider-using-f-string, duplicate-code, - execfile-builtin, - feature-toggle-needs-doc, - file-builtin, - filter-builtin-not-iterating, + file-ignored, fixme, - getslice-method, - hex-method, - illegal-waffle-usage, - import-star-module-level, - indexing-exception, - input-builtin, - intern-builtin, + global-statement, + invalid-name, locally-disabled, - locally-enabled, - logging-format-interpolation, - long-builtin, - long-suffix, - map-builtin-not-iterating, - metaclass-assignment, - next-method-called, - no-absolute-import, - no-init, - no-self-use, - nonzero-method, - oct-method, - old-division, - old-ne-operator, - old-octal-literal, - old-raise-syntax, - parameter-unpacking, - print-statement, - raising-string, - range-builtin-not-iterating, - raw_input-builtin, - reduce-builtin, - reload-builtin, - round-builtin, - setslice-method, - standarderror-builtin, + no-else-return, suppressed-message, too-few-public-methods, too-many-ancestors, @@ -348,21 +272,18 @@ disable = too-many-public-methods, too-many-return-statements, ungrouped-imports, - unichr-builtin, - unicode-builtin, - unpacking-in-except, - using-cmp-argument, - xrange-builtin, - zip-builtin-not-iterating, + unspecified-encoding, + unused-wildcard-import, + use-maxsplit-arg, + + logging-fstring-interpolation, [REPORTS] output-format = text -files-output = no reports = no -evaluation = 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) +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]+$ @@ -382,7 +303,6 @@ docstring-min-length = 5 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 = ' ' @@ -451,6 +371,6 @@ ext-import-graph = int-import-graph = [EXCEPTIONS] -overgeneral-exceptions = Exception +overgeneral-exceptions = builtins.Exception -# d784746e5ebe9eb66794d942ded84799d4f7f2fa +# 5f343c05ade94b1f05eeb1763ca543bf867246f6 diff --git a/requirements/base.in b/requirements/base.in index 0dfc559..18a523f 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -3,12 +3,7 @@ -c constraints.txt Django -django-model-utils>=2.3.1 - +django-model-utils jsonfield pytz - - -# Don't let edx-platform upgrade DRF past versions that have already been tested here -djangorestframework<3.13.0 - +djangorestframework diff --git a/requirements/base.txt b/requirements/base.txt index c5afd51..d05b414 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,25 +1,32 @@ # -# This file is autogenerated by pip-compile with python 3.8 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: # # make upgrade # -django==2.2.24 +asgiref==3.8.1 + # via django +backports-zoneinfo==0.2.1 ; python_version < "3.9" + # via + # -c requirements/constraints.txt + # django + # djangorestframework +django==4.2.13 # via - # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c requirements/common_constraints.txt # -r requirements/base.in # django-model-utils # djangorestframework # jsonfield -django-model-utils==4.1.1 +django-model-utils==4.5.1 # via -r requirements/base.in -djangorestframework==3.12.4 +djangorestframework==3.15.1 # via -r requirements/base.in jsonfield==3.1.0 # via -r requirements/base.in -pytz==2021.1 - # via - # -r requirements/base.in - # django -sqlparse==0.4.1 +pytz==2024.1 + # via -r requirements/base.in +sqlparse==0.5.0 # via django +typing-extensions==4.11.0 + # via asgiref diff --git a/requirements/ci.txt b/requirements/ci.txt index aa79340..44956f8 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -1,70 +1,73 @@ # -# This file is autogenerated by pip-compile with python 3.8 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: # # make upgrade # -backports.entry-points-selectable==1.1.0 +cachetools==5.3.3 # via # -r requirements/tox.txt - # virtualenv -certifi==2021.5.30 + # tox +certifi==2024.2.2 # via requests -charset-normalizer==2.0.4 +chardet==5.2.0 + # via + # -r requirements/tox.txt + # tox +charset-normalizer==3.3.2 # via requests -coverage==5.5 +colorama==0.4.6 + # via + # -r requirements/tox.txt + # tox +coverage[toml]==7.5.1 # via coveralls -coveralls==3.2.0 +coveralls==4.0.1 # via -r requirements/ci.in -distlib==0.3.2 +distlib==0.3.8 # via # -r requirements/tox.txt # virtualenv docopt==0.6.2 # via coveralls -filelock==3.0.12 +filelock==3.14.0 # via # -r requirements/tox.txt # tox # virtualenv -idna==3.2 +idna==3.7 # via requests -packaging==21.0 +packaging==24.0 # via # -r requirements/tox.txt + # pyproject-api # tox -platformdirs==2.2.0 +platformdirs==4.2.1 # via # -r requirements/tox.txt + # tox # virtualenv -pluggy==0.13.1 +pluggy==1.5.0 # via # -r requirements/tox.txt # tox -py==1.10.0 +pyproject-api==1.6.1 # via # -r requirements/tox.txt # tox -pyparsing==2.4.7 - # via - # -r requirements/tox.txt - # packaging -requests==2.26.0 +requests==2.32.2 # via coveralls -six==1.16.0 - # via - # -r requirements/tox.txt - # tox - # virtualenv -toml==0.10.2 +tomli==2.0.1 # via # -r requirements/tox.txt + # coverage + # pyproject-api # tox -tox==3.24.3 +tox==4.15.0 # via -r requirements/tox.txt -urllib3==1.26.6 +urllib3==2.2.1 # via requests -virtualenv==20.7.2 +virtualenv==20.26.2 # via # -r requirements/tox.txt # tox diff --git a/requirements/common_constraints.txt b/requirements/common_constraints.txt new file mode 100644 index 0000000..c9d8d9c --- /dev/null +++ b/requirements/common_constraints.txt @@ -0,0 +1,37 @@ +# This is a temporary solution to override the real common_constraints.txt +# In edx-lint, until the pyjwt constraint in edx-lint has been removed. +# See BOM-2721 for more details. +# Below is the copied and edited version of common_constraints + +# A central location for most common version constraints +# (across edx repos) for pip-installation. +# +# Similar to other constraint files this file doesn't install any packages. +# It specifies version constraints that will be applied if a package is needed. +# When pinning something here, please provide an explanation of why it is a good +# idea to pin this package across all edx repos, Ideally, link to other information +# that will help people in the future to remove the pin when possible. +# Writing an issue against the offending project and linking to it here is good. +# +# Note: Changes to this file will automatically be used by other repos, referencing +# this file from Github directly. It does not require packaging in edx-lint. + + +# using LTS django version +Django<5.0 + +# elasticsearch>=7.14.0 includes breaking changes in it which caused issues in discovery upgrade process. +# elastic search changelog: https://www.elastic.co/guide/en/enterprise-search/master/release-notes-7.14.0.html +elasticsearch<7.14.0 + +# django-simple-history>3.0.0 adds indexing and causes a lot of migrations to be affected +django-simple-history==3.0.0 + +# opentelemetry requires version 6.x at the moment: +# https://github.com/open-telemetry/opentelemetry-python/issues/3570 +# Normally this could be added as a constraint in edx-django-utils, where we're +# adding the opentelemetry dependency. However, when we compile pip-tools.txt, +# that uses version 7.x, and then there's no undoing that when compiling base.txt. +# So we need to pin it globally, for now. +# Ticket for unpinning: https://github.com/openedx/edx-lint/issues/407 +importlib-metadata<7 diff --git a/requirements/constraints.txt b/requirements/constraints.txt index eedc924..e824313 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -9,7 +9,7 @@ # linking to it here is good. # This file contains all common constraints for edx-repos --c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt +# Common constraints for edx repos +-c common_constraints.txt -# zipp 2.0.0 requires Python >= 3.6 -zipp<2.0.0 \ No newline at end of file +backports.zoneinfo;python_version<"3.9" \ No newline at end of file diff --git a/requirements/dev.txt b/requirements/dev.txt index fb4340a..53df241 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,110 +1,147 @@ # -# This file is autogenerated by pip-compile with python 3.8 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: # # make upgrade # -alabaster==0.7.12 +accessible-pygments==0.0.4 + # via + # -r requirements/docs.txt + # -r requirements/test.txt + # pydata-sphinx-theme +alabaster==0.7.13 # via # -r requirements/docs.txt # -r requirements/test.txt # sphinx -astroid==2.7.2 +asgiref==3.8.1 + # via + # -r requirements/base.txt + # -r requirements/docs.txt + # -r requirements/test.txt + # django +astroid==3.2.2 # via # -r requirements/test.txt # pylint # pylint-celery -attrs==21.2.0 +babel==2.15.0 # via + # -r requirements/docs.txt # -r requirements/test.txt - # pytest -babel==2.9.1 + # pydata-sphinx-theme + # sphinx +backports-zoneinfo==0.2.1 ; python_version < "3.9" # via + # -c requirements/constraints.txt + # -r requirements/base.txt # -r requirements/docs.txt # -r requirements/test.txt - # sphinx -certifi==2021.5.30 + # django + # djangorestframework +beautifulsoup4==4.12.3 + # via + # -r requirements/docs.txt + # -r requirements/test.txt + # pydata-sphinx-theme +certifi==2024.2.2 # via # -r requirements/docs.txt # -r requirements/test.txt # requests -charset-normalizer==2.0.4 +charset-normalizer==3.3.2 # via # -r requirements/docs.txt # -r requirements/test.txt # requests -click==8.0.1 +click==8.1.7 # via # -r requirements/test.txt # click-log # code-annotations # edx-lint -click-log==0.3.2 +click-log==0.4.0 # via # -r requirements/test.txt # edx-lint -code-annotations==1.2.0 +code-annotations==1.8.0 # via # -r requirements/test.txt # edx-lint -coverage==5.5 +coverage[toml]==7.5.1 # via # -r requirements/test.txt # pytest-cov -ddt==1.4.2 +ddt==1.7.2 # via -r requirements/test.txt -django==2.2.24 +dill==0.3.8 + # via + # -r requirements/test.txt + # pylint +django==4.2.13 # via - # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c requirements/common_constraints.txt # -r requirements/base.txt + # -r requirements/docs.txt # -r requirements/test.txt # django-model-utils # djangorestframework - # edx-lint # jsonfield -django-model-utils==4.1.1 +django-model-utils==4.5.1 # via # -r requirements/base.txt + # -r requirements/docs.txt # -r requirements/test.txt -djangorestframework==3.12.4 +djangorestframework==3.15.1 # via # -r requirements/base.txt + # -r requirements/docs.txt # -r requirements/test.txt -docutils==0.16 +docutils==0.19 # via # -r requirements/docs.txt # -r requirements/test.txt + # pydata-sphinx-theme # sphinx - # sphinx-rtd-theme -edx-lint==5.0.0 +edx-lint==5.3.6 # via -r requirements/test.txt -factory-boy==3.2.0 +exceptiongroup==1.2.1 + # via + # -r requirements/test.txt + # pytest +factory-boy==3.3.0 # via -r requirements/test.txt -faker==8.12.0 +faker==25.2.0 # via # -r requirements/test.txt # factory-boy -freezegun==1.1.0 +freezegun==1.5.1 # via -r requirements/test.txt -idna==3.2 +idna==3.7 # via # -r requirements/docs.txt # -r requirements/test.txt # requests -imagesize==1.2.0 +imagesize==1.4.1 # via # -r requirements/docs.txt # -r requirements/test.txt # sphinx -iniconfig==1.1.1 +importlib-metadata==6.11.0 + # via + # -c requirements/common_constraints.txt + # -r requirements/docs.txt + # -r requirements/test.txt + # sphinx +iniconfig==2.0.0 # via # -r requirements/test.txt # pytest -isort==5.9.3 +isort==5.13.2 # via # -r requirements/test.txt # pylint -jinja2==3.0.1 +jinja2==3.1.4 # via # -r requirements/docs.txt # -r requirements/test.txt @@ -113,37 +150,35 @@ jinja2==3.0.1 jsonfield==3.1.0 # via # -r requirements/base.txt + # -r requirements/docs.txt # -r requirements/test.txt -lazy-object-proxy==1.6.0 - # via - # -r requirements/test.txt - # astroid -markupsafe==2.0.1 +markupsafe==2.1.5 # via # -r requirements/docs.txt # -r requirements/test.txt # jinja2 -mccabe==0.6.1 +mccabe==0.7.0 # via # -r requirements/test.txt # pylint -mock==4.0.3 +mock==5.1.0 # via -r requirements/test.txt -packaging==21.0 +packaging==24.0 # via # -r requirements/docs.txt # -r requirements/test.txt + # pydata-sphinx-theme # pytest # sphinx -pbr==5.6.0 +pbr==6.0.0 # via # -r requirements/test.txt # stevedore -platformdirs==2.2.0 +platformdirs==4.2.1 # via # -r requirements/test.txt # pylint -pluggy==0.13.1 +pluggy==1.5.0 # via # -r requirements/test.txt # pytest @@ -152,18 +187,21 @@ pockets==0.9.1 # -r requirements/docs.txt # -r requirements/test.txt # sphinxcontrib-napoleon -py==1.10.0 +pycodestyle==2.11.1 + # via -r requirements/test.txt +pydata-sphinx-theme==0.14.4 # via + # -r requirements/docs.txt # -r requirements/test.txt - # pytest -pycodestyle==2.7.0 - # via -r requirements/test.txt -pygments==2.10.0 + # sphinx-book-theme +pygments==2.18.0 # via # -r requirements/docs.txt # -r requirements/test.txt + # accessible-pygments + # pydata-sphinx-theme # sphinx -pylint==2.10.2 +pylint==3.2.2 # via # -r requirements/test.txt # edx-lint @@ -174,50 +212,44 @@ pylint-celery==0.3 # via # -r requirements/test.txt # edx-lint -pylint-django==2.4.4 +pylint-django==2.5.5 # via # -r requirements/test.txt # edx-lint -pylint-plugin-utils==0.6 +pylint-plugin-utils==0.8.2 # via # -r requirements/test.txt # pylint-celery # pylint-django -pyparsing==2.4.7 - # via - # -r requirements/docs.txt - # -r requirements/test.txt - # packaging -pytest==6.2.4 +pytest==8.2.1 # via # -r requirements/test.txt # pytest-cov # pytest-django -pytest-cov==2.12.1 +pytest-cov==5.0.0 # via -r requirements/test.txt -pytest-django==4.4.0 +pytest-django==4.8.0 # via -r requirements/test.txt -python-dateutil==2.8.2 +python-dateutil==2.9.0.post0 # via # -r requirements/test.txt # faker # freezegun -python-slugify==5.0.2 +python-slugify==8.0.4 # via # -r requirements/test.txt # code-annotations -pytz==2021.1 +pytz==2024.1 # via # -r requirements/base.txt # -r requirements/docs.txt # -r requirements/test.txt # babel - # django -pyyaml==5.4.1 +pyyaml==6.0.1 # via # -r requirements/test.txt # code-annotations -requests==2.26.0 +requests==2.32.2 # via # -r requirements/docs.txt # -r requirements/test.txt @@ -230,21 +262,27 @@ six==1.16.0 # pockets # python-dateutil # sphinxcontrib-napoleon -snowballstemmer==2.1.0 +snowballstemmer==2.2.0 # via # -r requirements/docs.txt # -r requirements/test.txt # sphinx -sphinx==4.1.2 +soupsieve==2.5 + # via + # -r requirements/docs.txt + # -r requirements/test.txt + # beautifulsoup4 +sphinx==6.2.1 # via # -r requirements/docs.txt # -r requirements/test.txt - # sphinx-rtd-theme -sphinx-rtd-theme==0.5.2 + # pydata-sphinx-theme + # sphinx-book-theme +sphinx-book-theme==1.0.1 # via # -r requirements/docs.txt # -r requirements/test.txt -sphinxcontrib-applehelp==1.0.2 +sphinxcontrib-applehelp==1.0.4 # via # -r requirements/docs.txt # -r requirements/test.txt @@ -254,7 +292,7 @@ sphinxcontrib-devhelp==1.0.2 # -r requirements/docs.txt # -r requirements/test.txt # sphinx -sphinxcontrib-htmlhelp==2.0.0 +sphinxcontrib-htmlhelp==2.0.1 # via # -r requirements/docs.txt # -r requirements/test.txt @@ -278,35 +316,46 @@ sphinxcontrib-serializinghtml==1.1.5 # -r requirements/docs.txt # -r requirements/test.txt # sphinx -sqlparse==0.4.1 +sqlparse==0.5.0 # via # -r requirements/base.txt + # -r requirements/docs.txt # -r requirements/test.txt # django -stevedore==3.4.0 +stevedore==5.2.0 # via # -r requirements/test.txt # code-annotations text-unidecode==1.3 # via # -r requirements/test.txt - # faker # python-slugify -toml==0.10.2 +tomli==2.0.1 # via # -r requirements/test.txt + # coverage # pylint # pytest - # pytest-cov -urllib3==1.26.6 +tomlkit==0.12.5 + # via + # -r requirements/test.txt + # pylint +typing-extensions==4.11.0 + # via + # -r requirements/base.txt + # -r requirements/docs.txt + # -r requirements/test.txt + # asgiref + # astroid + # pydata-sphinx-theme + # pylint +urllib3==2.2.1 # via # -r requirements/docs.txt # -r requirements/test.txt # requests -wrapt==1.12.1 +zipp==3.18.2 # via + # -r requirements/docs.txt # -r requirements/test.txt - # astroid - -# The following packages are considered to be unsafe in a requirements file: -# setuptools + # importlib-metadata diff --git a/requirements/docs.in b/requirements/docs.in index 8d985a7..3b11a0c 100644 --- a/requirements/docs.in +++ b/requirements/docs.in @@ -1,5 +1,7 @@ # Requirements for docs +-c constraints.txt -Sphinx>=1.2 -sphinx-rtd-theme>=0.1.5 -sphinxcontrib-napoleon>=0.2.3 +-r base.txt +Sphinx +sphinx-book-theme +sphinxcontrib-napoleon diff --git a/requirements/docs.txt b/requirements/docs.txt index ef60c96..d943bf8 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -1,58 +1,101 @@ # -# This file is autogenerated by pip-compile with python 3.8 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: # # make upgrade # -alabaster==0.7.12 +accessible-pygments==0.0.4 + # via pydata-sphinx-theme +alabaster==0.7.13 # via sphinx -babel==2.9.1 - # via sphinx -certifi==2021.5.30 +asgiref==3.8.1 + # via + # -r requirements/base.txt + # django +babel==2.15.0 + # via + # pydata-sphinx-theme + # sphinx +backports-zoneinfo==0.2.1 ; python_version < "3.9" + # via + # -c requirements/constraints.txt + # -r requirements/base.txt + # django + # djangorestframework +beautifulsoup4==4.12.3 + # via pydata-sphinx-theme +certifi==2024.2.2 # via requests -charset-normalizer==2.0.4 +charset-normalizer==3.3.2 # via requests -docutils==0.16 +django==4.2.13 # via + # -c requirements/common_constraints.txt + # -r requirements/base.txt + # django-model-utils + # djangorestframework + # jsonfield +django-model-utils==4.5.1 + # via -r requirements/base.txt +djangorestframework==3.15.1 + # via -r requirements/base.txt +docutils==0.19 + # via + # pydata-sphinx-theme # sphinx - # sphinx-rtd-theme -idna==3.2 +idna==3.7 # via requests -imagesize==1.2.0 +imagesize==1.4.1 # via sphinx -jinja2==3.0.1 +importlib-metadata==6.11.0 + # via + # -c requirements/common_constraints.txt + # sphinx +jinja2==3.1.4 # via sphinx -markupsafe==2.0.1 +jsonfield==3.1.0 + # via -r requirements/base.txt +markupsafe==2.1.5 # via jinja2 -packaging==21.0 - # via sphinx +packaging==24.0 + # via + # pydata-sphinx-theme + # sphinx pockets==0.9.1 # via sphinxcontrib-napoleon -pygments==2.10.0 - # via sphinx -pyparsing==2.4.7 - # via packaging -pytz==2021.1 - # via babel -requests==2.26.0 +pydata-sphinx-theme==0.14.4 + # via sphinx-book-theme +pygments==2.18.0 + # via + # accessible-pygments + # pydata-sphinx-theme + # sphinx +pytz==2024.1 + # via + # -r requirements/base.txt + # babel +requests==2.32.2 # via sphinx six==1.16.0 # via # pockets # sphinxcontrib-napoleon -snowballstemmer==2.1.0 +snowballstemmer==2.2.0 # via sphinx -sphinx==4.1.2 +soupsieve==2.5 + # via beautifulsoup4 +sphinx==6.2.1 # via # -r requirements/docs.in - # sphinx-rtd-theme -sphinx-rtd-theme==0.5.2 + # pydata-sphinx-theme + # sphinx-book-theme +sphinx-book-theme==1.0.1 # via -r requirements/docs.in -sphinxcontrib-applehelp==1.0.2 +sphinxcontrib-applehelp==1.0.4 # via sphinx sphinxcontrib-devhelp==1.0.2 # via sphinx -sphinxcontrib-htmlhelp==2.0.0 +sphinxcontrib-htmlhelp==2.0.1 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx @@ -62,8 +105,16 @@ sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.5 # via sphinx -urllib3==1.26.6 +sqlparse==0.5.0 + # via + # -r requirements/base.txt + # django +typing-extensions==4.11.0 + # via + # -r requirements/base.txt + # asgiref + # pydata-sphinx-theme +urllib3==2.2.1 # via requests - -# The following packages are considered to be unsafe in a requirements file: -# setuptools +zipp==3.18.2 + # via importlib-metadata diff --git a/requirements/pip-tools.txt b/requirements/pip-tools.txt index 3a172bb..d9186e9 100644 --- a/requirements/pip-tools.txt +++ b/requirements/pip-tools.txt @@ -1,19 +1,33 @@ # -# This file is autogenerated by pip-compile with python 3.8 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: # # make upgrade # -click==8.0.1 +build==1.2.1 # via pip-tools -pep517==0.11.0 +click==8.1.7 # via pip-tools -pip-tools==6.2.0 +importlib-metadata==6.11.0 + # via + # -c requirements/common_constraints.txt + # build +packaging==24.0 + # via build +pip-tools==7.4.1 # via -r requirements/pip-tools.in -tomli==1.2.1 - # via pep517 -wheel==0.37.0 +pyproject-hooks==1.1.0 + # via + # build + # pip-tools +tomli==2.0.1 + # via + # build + # pip-tools +wheel==0.43.0 # via pip-tools +zipp==3.18.2 + # 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 36c04ed..e3ffcc7 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -1,14 +1,14 @@ # -# This file is autogenerated by pip-compile with python 3.8 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: # # make upgrade # -wheel==0.37.0 +wheel==0.43.0 # via -r requirements/pip.in # The following packages are considered to be unsafe in a requirements file: -pip==21.2.4 +pip==24.0 # via -r requirements/pip.in -setuptools==57.4.0 +setuptools==69.5.1 # via -r requirements/pip.in diff --git a/requirements/test.txt b/requirements/test.txt index 32e3dd8..2a69172 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,122 +1,159 @@ # -# This file is autogenerated by pip-compile with python 3.8 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: # # make upgrade # -alabaster==0.7.12 +accessible-pygments==0.0.4 + # via + # -r requirements/docs.txt + # pydata-sphinx-theme +alabaster==0.7.13 # via # -r requirements/docs.txt # sphinx -astroid==2.7.2 +asgiref==3.8.1 + # via + # -r requirements/base.txt + # -r requirements/docs.txt + # django +astroid==3.2.2 # via # pylint # pylint-celery -attrs==21.2.0 - # via pytest -babel==2.9.1 +babel==2.15.0 # via # -r requirements/docs.txt + # pydata-sphinx-theme # sphinx -certifi==2021.5.30 +backports-zoneinfo==0.2.1 ; python_version < "3.9" + # via + # -c requirements/constraints.txt + # -r requirements/base.txt + # -r requirements/docs.txt + # django + # djangorestframework +beautifulsoup4==4.12.3 + # via + # -r requirements/docs.txt + # pydata-sphinx-theme +certifi==2024.2.2 # via # -r requirements/docs.txt # requests -charset-normalizer==2.0.4 +charset-normalizer==3.3.2 # via # -r requirements/docs.txt # requests -click==8.0.1 +click==8.1.7 # via # click-log # code-annotations # edx-lint -click-log==0.3.2 +click-log==0.4.0 # via edx-lint -code-annotations==1.2.0 +code-annotations==1.8.0 # via edx-lint -coverage==5.5 +coverage[toml]==7.5.1 # via pytest-cov -ddt==1.4.2 +ddt==1.7.2 # via -r requirements/test.in +dill==0.3.8 + # via pylint # via - # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + # -c requirements/common_constraints.txt # -r requirements/base.txt + # -r requirements/docs.txt # django-model-utils # djangorestframework - # edx-lint # jsonfield -django-model-utils==4.1.1 - # via -r requirements/base.txt - # via -r requirements/base.txt -docutils==0.16 +django-model-utils==4.5.1 + # via + # -r requirements/base.txt + # -r requirements/docs.txt + # via + # -r requirements/base.txt + # -r requirements/docs.txt +docutils==0.19 # via # -r requirements/docs.txt + # pydata-sphinx-theme # sphinx - # sphinx-rtd-theme -edx-lint==5.0.0 +edx-lint==5.3.6 # via -r requirements/test.in -factory-boy==3.2.0 +exceptiongroup==1.2.1 + # via pytest +factory-boy==3.3.0 # via -r requirements/test.in -faker==8.12.0 +faker==25.2.0 # via factory-boy -freezegun==1.1.0 +freezegun==1.5.1 # via -r requirements/test.in -idna==3.2 +idna==3.7 # via # -r requirements/docs.txt # requests -imagesize==1.2.0 +imagesize==1.4.1 # via # -r requirements/docs.txt # sphinx -iniconfig==1.1.1 +importlib-metadata==6.11.0 + # via + # -c requirements/common_constraints.txt + # -r requirements/docs.txt + # sphinx +iniconfig==2.0.0 # via pytest -isort==5.9.3 +isort==5.13.2 # via # -r requirements/test.in # pylint -jinja2==3.0.1 +jinja2==3.1.4 # via # -r requirements/docs.txt # code-annotations # sphinx jsonfield==3.1.0 - # via -r requirements/base.txt -lazy-object-proxy==1.6.0 - # via astroid -markupsafe==2.0.1 + # via + # -r requirements/base.txt + # -r requirements/docs.txt +markupsafe==2.1.5 # via # -r requirements/docs.txt # jinja2 -mccabe==0.6.1 +mccabe==0.7.0 # via pylint -mock==4.0.3 +mock==5.1.0 # via -r requirements/test.in -packaging==21.0 +packaging==24.0 # via # -r requirements/docs.txt + # pydata-sphinx-theme # pytest # sphinx -pbr==5.6.0 +pbr==6.0.0 # via stevedore -platformdirs==2.2.0 +platformdirs==4.2.1 # via pylint -pluggy==0.13.1 +pluggy==1.5.0 # via pytest pockets==0.9.1 # via # -r requirements/docs.txt # sphinxcontrib-napoleon -py==1.10.0 - # via pytest -pycodestyle==2.7.0 +pycodestyle==2.11.1 # via -r requirements/test.in -pygments==2.10.0 +pydata-sphinx-theme==0.14.4 # via # -r requirements/docs.txt + # sphinx-book-theme +pygments==2.18.0 + # via + # -r requirements/docs.txt + # accessible-pygments + # pydata-sphinx-theme # sphinx -pylint==2.10.2 +pylint==3.2.2 # via # edx-lint # pylint-celery @@ -124,40 +161,35 @@ pylint==2.10.2 # pylint-plugin-utils pylint-celery==0.3 # via edx-lint -pylint-django==2.4.4 +pylint-django==2.5.5 # via edx-lint -pylint-plugin-utils==0.6 +pylint-plugin-utils==0.8.2 # via # pylint-celery # pylint-django -pyparsing==2.4.7 - # via - # -r requirements/docs.txt - # packaging -pytest==6.2.4 +pytest==8.2.1 # via # pytest-cov # pytest-django -pytest-cov==2.12.1 +pytest-cov==5.0.0 # via -r requirements/test.in -pytest-django==4.4.0 +pytest-django==4.8.0 # via -r requirements/test.in -python-dateutil==2.8.2 +python-dateutil==2.9.0.post0 # via # -r requirements/test.in # faker # freezegun -python-slugify==5.0.2 +python-slugify==8.0.4 # via code-annotations -pytz==2021.1 +pytz==2024.1 # via # -r requirements/base.txt # -r requirements/docs.txt # babel - # django -pyyaml==5.4.1 +pyyaml==6.0.1 # via code-annotations -requests==2.26.0 +requests==2.32.2 # via # -r requirements/docs.txt # sphinx @@ -168,17 +200,22 @@ six==1.16.0 # pockets # python-dateutil # sphinxcontrib-napoleon -snowballstemmer==2.1.0 +snowballstemmer==2.2.0 # via # -r requirements/docs.txt # sphinx -sphinx==4.1.2 +soupsieve==2.5 + # via + # -r requirements/docs.txt + # beautifulsoup4 +sphinx==6.2.1 # via # -r requirements/docs.txt - # sphinx-rtd-theme -sphinx-rtd-theme==0.5.2 + # pydata-sphinx-theme + # sphinx-book-theme +sphinx-book-theme==1.0.1 # via -r requirements/docs.txt -sphinxcontrib-applehelp==1.0.2 +sphinxcontrib-applehelp==1.0.4 # via # -r requirements/docs.txt # sphinx @@ -186,7 +223,7 @@ sphinxcontrib-devhelp==1.0.2 # via # -r requirements/docs.txt # sphinx -sphinxcontrib-htmlhelp==2.0.0 +sphinxcontrib-htmlhelp==2.0.1 # via # -r requirements/docs.txt # sphinx @@ -204,27 +241,35 @@ sphinxcontrib-serializinghtml==1.1.5 # via # -r requirements/docs.txt # sphinx -sqlparse==0.4.1 +sqlparse==0.5.0 # via # -r requirements/base.txt + # -r requirements/docs.txt # django -stevedore==3.4.0 +stevedore==5.2.0 # via code-annotations text-unidecode==1.3 + # via python-slugify +tomli==2.0.1 # via - # faker - # python-slugify -toml==0.10.2 - # via + # coverage # pylint # pytest - # pytest-cov -urllib3==1.26.6 +tomlkit==0.12.5 + # via pylint +typing-extensions==4.11.0 + # via + # -r requirements/base.txt + # -r requirements/docs.txt + # asgiref + # astroid + # pydata-sphinx-theme + # pylint +urllib3==2.2.1 # via # -r requirements/docs.txt # requests -wrapt==1.12.1 - # via astroid - -# The following packages are considered to be unsafe in a requirements file: -# setuptools +zipp==3.18.2 + # via + # -r requirements/docs.txt + # importlib-metadata diff --git a/requirements/tox.txt b/requirements/tox.txt index d0691ff..5644b8e 100644 --- a/requirements/tox.txt +++ b/requirements/tox.txt @@ -1,34 +1,38 @@ # -# This file is autogenerated by pip-compile with python 3.8 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: # # make upgrade # -backports.entry-points-selectable==1.1.0 - # via virtualenv -distlib==0.3.2 +cachetools==5.3.3 + # via tox +chardet==5.2.0 + # via tox +colorama==0.4.6 + # via tox +distlib==0.3.8 # via virtualenv -filelock==3.0.12 +filelock==3.14.0 # via # tox # virtualenv -packaging==21.0 - # via tox -platformdirs==2.2.0 - # via virtualenv -pluggy==0.13.1 - # via tox -py==1.10.0 - # via tox -pyparsing==2.4.7 - # via packaging -six==1.16.0 +packaging==24.0 + # via + # pyproject-api + # tox +platformdirs==4.2.1 # via # tox # virtualenv -toml==0.10.2 +pluggy==1.5.0 # via tox -tox==3.24.3 +pyproject-api==1.6.1 + # via tox +tomli==2.0.1 + # via + # pyproject-api + # tox +tox==4.15.0 # via -r requirements/tox.in -virtualenv==20.7.2 +virtualenv==20.26.2 # via tox diff --git a/setup.py b/setup.py index e4dc2cc..b24f401 100644 --- a/setup.py +++ b/setup.py @@ -6,8 +6,6 @@ from setuptools import find_packages, setup -from submissions import __version__ as VERSION - def is_requirement(line): """ @@ -32,20 +30,48 @@ def load_requirements(*requirements_paths): """ # UPDATED VIA SEMGREP - if you need to remove/modify this method remove this line and add a comment specifying why. + # e.g. {"django": "Django", "confluent-kafka": "confluent_kafka[avro]"} + by_canonical_name = {} + + def check_name_consistent(package): + """ + Raise exception if package is named different ways. + + This ensures that packages are named consistently so we can match + constraints to packages. It also ensures that if we require a package + with extras we don't constrain it without mentioning the extras (since + that too would interfere with matching constraints.) + """ + canonical = package.lower().replace('_', '-').split('[')[0] + seen_spelling = by_canonical_name.get(canonical) + if seen_spelling is None: + by_canonical_name[canonical] = package + elif seen_spelling != package: + raise Exception( + f'Encountered both "{seen_spelling}" and "{package}" in requirements ' + 'and constraints files; please use just one or the other.' + ) + requirements = {} constraint_files = set() - # groups "my-package-name<=x.y.z,..." into ("my-package-name", "<=x.y.z,...") - requirement_line_regex = re.compile(r"([a-zA-Z0-9-_.]+)([<>=][^#\s]+)?") + # groups "pkg<=x.y.z,..." into ("pkg", "<=x.y.z,...") + re_package_name_base_chars = r"a-zA-Z0-9\-_." # chars allowed in base package name + # Two groups: name[maybe,extras], and optionally a constraint + requirement_line_regex = re.compile( + r"([%s]+(?:\[[%s,\s]+\])?)([<>=][^#\s]+)?" + % (re_package_name_base_chars, re_package_name_base_chars) + ) def add_version_constraint_or_raise(current_line, current_requirements, add_if_not_present): regex_match = requirement_line_regex.match(current_line) if regex_match: package = regex_match.group(1) version_constraints = regex_match.group(2) + check_name_consistent(package) existing_version_constraints = current_requirements.get(package, None) - # it's fine to add constraints to an unconstrained package, but raise an error if there are already - # constraints in place + # It's fine to add constraints to an unconstrained package, + # but raise an error if there are already constraints in place. if existing_version_constraints and existing_version_constraints != version_constraints: raise BaseException(f'Multiple constraint definitions found for {package}:' f' "{existing_version_constraints}" and "{version_constraints}".' @@ -54,7 +80,8 @@ def add_version_constraint_or_raise(current_line, current_requirements, add_if_n if add_if_not_present or package in current_requirements: current_requirements[package] = version_constraints - # process .in files and store the path to any constraint files that are pulled in + # Read requirements from .in files and store the path to any + # constraint files that are pulled in. for path in requirements_paths: with open(path) as reqs: for line in reqs: @@ -63,7 +90,7 @@ def add_version_constraint_or_raise(current_line, current_requirements, add_if_n 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 and add any new constraints found to existing requirements + # process constraint files: add constraints to existing requirements for constraint_file in constraint_files: with open(constraint_file) as reader: for line in reader: @@ -75,6 +102,23 @@ def add_version_constraint_or_raise(current_line, current_requirements, add_if_n return constrained_requirements +def get_version(*file_paths): + """ + Extract the version string from the file at the given relative path fragments. + """ + filename = os.path.join(os.path.dirname(__file__), *file_paths) + with open(filename, encoding='utf-8') as opened_file: + version_file = opened_file.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.') + + +VERSION = get_version("submissions", "__init__.py") + + if sys.argv[-1] == 'tag': print("Tagging the version on github:") os.system("git tag -a %s -m 'version %s'" % (VERSION, VERSION)) @@ -86,21 +130,21 @@ def add_version_constraint_or_raise(current_line, current_requirements, add_if_n version=VERSION, author='edX', description='An API for creating submissions and scores.', - url='http://github.com/edx/edx-submissions.git', + long_description="An API for creating and scoring submissions for the Open edX platform", + url='http://github.com/openedx/edx-submissions.git', license='AGPL', classifiers=[ 'Development Status :: 3 - Alpha', 'Framework :: Django', - 'Framework :: Django :: 2.2', - 'Framework :: Django :: 3.0', - 'Framework :: Django :: 3.1', - 'Framework :: Django :: 3.2', + 'Framework :: Django :: 4.2', 'Intended Audience :: Developers', 'License :: OSI Approved :: GNU Affero General Public License v3', 'Operating System :: OS Independent', 'Programming Language :: Python', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', ], packages=find_packages(include=['submissions*'], exclude=['*.test', '*.tests']), install_requires=load_requirements('requirements/base.in'), diff --git a/submissions/__init__.py b/submissions/__init__.py index 4885af0..60e7110 100644 --- a/submissions/__init__.py +++ b/submissions/__init__.py @@ -1,2 +1,2 @@ """ API for creating submissions and scores. """ -__version__ = '3.4.0' +__version__ = '3.7.0' diff --git a/submissions/admin.py b/submissions/admin.py index a3bd64d..3e354e9 100644 --- a/submissions/admin.py +++ b/submissions/admin.py @@ -16,18 +16,28 @@ class StudentItemAdminMixin: 'student_item__id' ) + @admin.display( + ordering='student_item__course_id' + ) def course_id(self, obj): return obj.student_item.course_id - course_id.admin_order_field = 'student_item__course_id' + @admin.display( + ordering='student_item__item_id' + ) def item_id(self, obj): return obj.student_item.item_id - item_id.admin_order_field = 'student_item__item_id' + @admin.display( + ordering='student_item__student_id' + ) def student_id(self, obj): return obj.student_item.student_id - student_id.admin_order_field = 'student_item__student_id' + @admin.display( + description='S.I. ID', + ordering='student_item__id', + ) def student_item_id(self, obj): """ Formated student item id. """ url = reverse( @@ -36,10 +46,8 @@ def student_item_id(self, obj): ) return format_html(f'{obj.student_item.id}') - student_item_id.admin_order_field = 'student_item__id' - student_item_id.short_description = 'S.I. ID' - +@admin.register(StudentItem) class StudentItemAdmin(admin.ModelAdmin): list_display = ('id', 'course_id', 'item_type', 'item_id', 'student_id') list_filter = ('item_type',) @@ -47,6 +55,7 @@ class StudentItemAdmin(admin.ModelAdmin): readonly_fields = ('course_id', 'item_type', 'item_id', 'student_id') +@admin.register(Submission) class SubmissionAdmin(admin.ModelAdmin, StudentItemAdminMixin): """ Student Submission Admin View. """ list_display = ( @@ -70,9 +79,7 @@ class SubmissionAdmin(admin.ModelAdmin, StudentItemAdminMixin): def all_scores(self, submission): return "\n".join( - "{}/{} - {}".format( - score.points_earned, score.points_possible, score.created_at - ) + f"{score.points_earned}/{score.points_possible} - {score.created_at}" for score in Score.objects.filter(submission=submission) ) @@ -85,6 +92,7 @@ class SubmissionInlineAdmin(admin.TabularInline, StudentItemAdminMixin): extra = 0 +@admin.register(TeamSubmission) class TeamSubmissionAdmin(admin.ModelAdmin): """ Student Submission Admin View. """ @@ -94,6 +102,7 @@ class TeamSubmissionAdmin(admin.ModelAdmin): inlines = (SubmissionInlineAdmin,) +@admin.register(Score) class ScoreAdmin(admin.ModelAdmin, StudentItemAdminMixin): """ Student Score Admin View. """ list_display = ( @@ -116,6 +125,7 @@ def points(self, score): return f"{score.points_earned}/{score.points_possible}" +@admin.register(ScoreSummary) class ScoreSummaryAdmin(admin.ModelAdmin, StudentItemAdminMixin): """ Student Score Summary Admin View. """ list_display = ( @@ -129,25 +139,22 @@ class ScoreSummaryAdmin(admin.ModelAdmin, StudentItemAdminMixin): ) exclude = ('highest', 'latest') + @admin.display( + description='Highest' + ) def highest_link(self, score_summary): + """Returns highest link""" url = reverse( 'admin:submissions_score_change', args=[score_summary.highest.id] ) return format_html(f'{score_summary.highest}') - highest_link.short_description = 'Highest' - + @admin.display( + description='Latest' + ) def latest_link(self, score_summary): + """Returns latest link""" url = reverse( 'admin:submissions_score_change', args=[score_summary.latest.id] ) return format_html(f'{score_summary.latest}') - - latest_link.short_description = 'Latest' - - -admin.site.register(Score, ScoreAdmin) -admin.site.register(StudentItem, StudentItemAdmin) -admin.site.register(Submission, SubmissionAdmin) -admin.site.register(TeamSubmission, TeamSubmissionAdmin) -admin.site.register(ScoreSummary, ScoreSummaryAdmin) diff --git a/submissions/api.py b/submissions/api.py index c2e011d..1be2a84 100644 --- a/submissions/api.py +++ b/submissions/api.py @@ -110,8 +110,10 @@ def create_submission(student_item_dict, answer, submitted_at=None, attempt_numb try: first_submission = Submission.objects.filter(student_item=student_item_model).first() except DatabaseError as error: - error_message = "An error occurred while filtering submissions for student item: {}".format( - student_item_dict) + error_message = ( + "An error occurred while filtering submissions " + f"for student item: {student_item_dict}" + ) logger.exception(error_message) raise SubmissionInternalError(error_message) from error @@ -140,9 +142,9 @@ def create_submission(student_item_dict, answer, submitted_at=None, attempt_numb return sub_data except DatabaseError as error: - error_message = "An error occurred while creating submission {} for student item: {}".format( - model_kwargs, - student_item_dict + error_message = ( + f"An error occurred while creating submission {model_kwargs} " + f"for student item: {student_item_dict}" ) logger.exception(error_message) raise SubmissionInternalError(error_message) from error @@ -288,7 +290,7 @@ def get_submission_and_student(uuid, read_replica=False): submission = get_submission(uuid, read_replica=read_replica) # Retrieve the student item from the cache - cache_key = "submissions.student_item.{}".format(submission['student_item']) + cache_key = f"submissions.student_item.{submission['student_item']}" try: cached_student_item = cache.get(cache_key) except Exception: # pylint: disable=broad-except @@ -369,8 +371,7 @@ def get_submissions(student_item_dict, limit=None): student_item=student_item_model) except DatabaseError as error: error_message = ( - "Error getting submission request for student item {}" - .format(student_item_dict) + f"Error getting submission request for student item {student_item_dict}" ) logger.exception(error_message) raise SubmissionNotFoundError(error_message) from error @@ -425,7 +426,8 @@ def get_all_submissions(course_id, item_id, item_type, read_replica=True): def get_all_course_submission_information(course_id, item_type, read_replica=True): - """ For the given course, get all student items of the given item type, all the submissions for those itemes, + """ + For the given course, get all student items of the given item type, all the submissions for those itemes, and the latest scores for each item. If a submission was given a score that is not the latest score for the relevant student item, it will still be included but without score. @@ -436,6 +438,7 @@ def get_all_course_submission_information(course_id, item_type, read_replica=Tru Yields: A tuple of three dictionaries representing: + (1) a student item with the following fields: student_id course_id @@ -484,7 +487,8 @@ def get_all_course_submission_information(course_id, item_type, read_replica=Tru def get_top_submissions(course_id, item_id, item_type, number_of_top_scores, use_cache=True, read_replica=True): - """Get a number of top scores for an assessment based on a particular student item + """ + Get a number of top scores for an assessment based on a particular student item This function will return top scores for the piece of assessment. It will consider only the latest and greater than 0 score for a piece of assessment. @@ -505,7 +509,7 @@ def get_top_submissions(course_id, item_id, item_type, number_of_top_scores, use Kwargs: use_cache (bool): If true, check the cache before retrieving querying the database. read_replica (bool): If true, attempt to use the read replica database. - If no read replica is available, use the default database. + If no read replica is available, use the default database. Returns: topscores (dict): The top scores for the assessment for the student item. @@ -541,11 +545,9 @@ def get_top_submissions(course_id, item_id, item_type, number_of_top_scores, use raise SubmissionRequestError(msg=error_msg) # First check the cache (unless caching is disabled) - cache_key = "submissions.top_submissions.{course}.{item}.{type}.{number}".format( - course=course_id, - item=item_id, - type=item_type, - number=number_of_top_scores + cache_key = ( + f"submissions.top_submissions.{course_id}." + f"{item_id}.{item_type}.{number_of_top_scores}" ) top_submissions = cache.get(cache_key) if use_cache else None @@ -564,8 +566,9 @@ def get_top_submissions(course_id, item_id, item_type, number_of_top_scores, use query = _use_read_replica(query) score_summaries = query[:number_of_top_scores] except DatabaseError as error: - msg = "Could not fetch top score summaries for course {}, item {} of type {}".format( - course_id, item_id, item_type + msg = ( + f"Could not fetch top score summaries for course {course_id}, " + f"item {item_id} of type {item_type}" ) logger.exception(msg) raise SubmissionInternalError(msg) from error @@ -689,9 +692,7 @@ def get_scores(course_id, student_id): student_item__student_id=student_id, ).select_related('latest', 'latest__submission', 'student_item') except DatabaseError as error: - msg = "Could not fetch scores for course {}, student {}".format( - course_id, student_id - ) + msg = f"Could not fetch scores for course {course_id}, student {student_id}" logger.exception(msg) raise SubmissionInternalError(msg) from error scores = { @@ -792,20 +793,24 @@ def reset_score(student_id, course_id, item_id, clear_state=False, emit_signal=T except DatabaseError as error: msg = ( "Error occurred while reseting scores for" - " item {item_id} in course {course_id} for student {student_id}" - ).format(item_id=item_id, course_id=course_id, student_id=student_id) + f" item {item_id} in course {course_id} for student {student_id}" + ) logger.exception(msg) raise SubmissionInternalError(msg) from error - else: - msg = "Score reset for item {item_id} in course {course_id} for student {student_id}".format( - item_id=item_id, course_id=course_id, student_id=student_id - ) - logger.info(msg) + logger.info( + "Score reset for item %(item_id)s in course %(course_id)s for student %(student_id)s", + { + 'item_id': item_id, + 'course_id': course_id, + 'student_id': student_id, + } + ) def set_score(submission_uuid, points_earned, points_possible, annotation_creator=None, annotation_type=None, annotation_reason=None): - """Set a score for a particular submission. + """ + Set a score for a particular submission. Sets the score for a particular submission. This score is calculated externally to the API. @@ -816,8 +821,8 @@ def set_score(submission_uuid, points_earned, points_possible, points_possible (int): The total points possible for this particular student item. annotation_creator (str): An optional field for recording who gave this particular score - annotation_type (str): An optional field for recording what type of annotation should be created, - e.g. "staff_override". + annotation_type (str): An optional field for recording what type of + annotation should be created, e.g. "staff_override". annotation_reason (str): An optional field for recording why this score was set to its value. Returns: @@ -847,9 +852,7 @@ def set_score(submission_uuid, points_earned, points_possible, f"No submission matching uuid {submission_uuid}" ) from error except DatabaseError as error: - error_msg = "Could not retrieve submission {}.".format( - submission_uuid - ) + error_msg = f"Could not retrieve submission {submission_uuid}." logger.exception(error_msg) raise SubmissionRequestError(msg=error_msg) from error @@ -910,15 +913,15 @@ def _log_submission(submission, student_item): None """ logger.info( - "Created submission uuid={submission_uuid} for " - "(course_id={course_id}, item_id={item_id}, " - "anonymous_student_id={anonymous_student_id})" - .format( - submission_uuid=submission["uuid"], - course_id=student_item["course_id"], - item_id=student_item["item_id"], - anonymous_student_id=student_item["student_id"] - ) + "Created submission uuid=%(submission_uuid)s for " + "(course_id=%(course_id)s, item_id=%(item_id)s, " + "anonymous_student_id=%(anonymous_student_id)s)", + { + 'submission_uuid': submission["uuid"], + 'course_id': student_item["course_id"], + 'item_id': student_item["item_id"], + 'anonymous_student_id': student_item["student_id"], + } ) @@ -933,8 +936,12 @@ def _log_score(score): None """ logger.info( - "Score of ({}/{}) set for submission {}" - .format(score.points_earned, score.points_possible, score.submission.uuid) + "Score of (%(points_earned)s/%(points_possible)s) set for submission %(uuid)s", + { + 'points_earned': score.points_earned, + 'points_possible': score.points_possible, + 'uuid': score.submission.uuid, + } ) @@ -977,17 +984,30 @@ def _get_or_create_student_item(student_item_dict): ) if not student_item_serializer.is_valid(): logger.error( - "Invalid StudentItemSerializer: errors:{} data:{}".format( - student_item_serializer.errors, - student_item_dict - ) + "Invalid StudentItemSerializer: errors:%(errors)s data:%(data)s", + { + 'errors': student_item_serializer.errors, + 'data': student_item_dict, + } ) raise SubmissionRequestError(field_errors=student_item_serializer.errors) from student_error - return student_item_serializer.save() + try: + # This is required because we currently have automatic request transactions turned on in the LMS. + # Database errors mess up the "atomic" block so we have to "insulate" against them with an + # inner atomic block (https://docs.djangoproject.com/en/4.0/topics/db/transactions/) + with transaction.atomic(): + return student_item_serializer.save() + except IntegrityError as integrity_error: + # In the case where a student item does not exist and multiple calls to this function happen, there + # can be a race condition where the first get has no results, but once the save happens there is already + # a version of this student item. In that case, try just loading again and see if the item exists now. + try: + return StudentItem.objects.get(**student_item_dict) + except StudentItem.DoesNotExist: + pass + raise integrity_error except DatabaseError as error: - error_message = "An error occurred creating student item: {}".format( - student_item_dict - ) + error_message = f"An error occurred creating student item: {student_item_dict}" logger.exception(error_message) raise SubmissionInternalError(error_message) from error diff --git a/submissions/errors.py b/submissions/errors.py index 299c939..12d812b 100644 --- a/submissions/errors.py +++ b/submissions/errors.py @@ -66,8 +66,9 @@ def __repr__(self): """ Show the field errors upon output. """ - return '{}(msg="{}", field_errors={})'.format( - self.__class__.__name__, self.message, self.field_errors # pylint: disable=no-member + return ( + # pylint: disable=no-member + f'{self.__class__.__name__}(msg="{self.message}", field_errors={self.field_errors})' ) diff --git a/submissions/management/commands/analyze_uploaded_file_sizes.py b/submissions/management/commands/analyze_uploaded_file_sizes.py index 877a3f6..f21afd9 100644 --- a/submissions/management/commands/analyze_uploaded_file_sizes.py +++ b/submissions/management/commands/analyze_uploaded_file_sizes.py @@ -72,9 +72,9 @@ def handle(self, min_date, max_date, *args, **options): Analyze ORA file upload submissions and and print tab-limited report """ print('Starting file upload submission report') - arg_echo_str = 'min_date = {}, max_date = {}'.format( - min_date.strftime(DATE_FORMAT), - max_date.strftime(DATE_FORMAT) + arg_echo_str = ( + f'min_date = {min_date.strftime(DATE_FORMAT)}, ' + f'max_date = {max_date.strftime(DATE_FORMAT)}' ) print(arg_echo_str) min_date = self.validate_input_dates(min_date, max_date) @@ -158,9 +158,5 @@ def parse_submission_data_by_course(self, submission_data): def print_row(self, course_id, num_course_users, course_bytes): """ Print a row of the report """ if num_course_users or course_bytes: - print("{course_id}\t{num_users}\t{total_bytes}\t{avg_bytes:.0f}".format( - course_id=course_id, - num_users=num_course_users, - total_bytes=course_bytes, - avg_bytes=course_bytes/num_course_users if num_course_users != 0 else 0 - )) + avg_bytes = course_bytes / num_course_users if num_course_users != 0 else 0 + print(f"{course_id}\t{num_course_users}\t{course_bytes}\t{avg_bytes:.0f}") diff --git a/submissions/migrations/0002_team_submission_optional.py b/submissions/migrations/0002_team_submission_optional.py new file mode 100644 index 0000000..af91822 --- /dev/null +++ b/submissions/migrations/0002_team_submission_optional.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.9 on 2021-11-10 22:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('submissions', '0001_squashed_0005_CreateTeamModel'), + ] + + operations = [ + migrations.AlterField( + model_name='submission', + name='team_submission', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='submissions', + to='submissions.teamsubmission', + ), + ), + ] diff --git a/submissions/migrations/0003_ensure_ascii.py b/submissions/migrations/0003_ensure_ascii.py new file mode 100644 index 0000000..26a516e --- /dev/null +++ b/submissions/migrations/0003_ensure_ascii.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.14 on 2022-08-18 19:01 + +from django.db import migrations +import jsonfield.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('submissions', '0002_team_submission_optional'), + ] + + operations = [ + migrations.AlterField( + model_name='submission', + name='answer', + field=jsonfield.fields.JSONField(blank=True, db_column='raw_answer', dump_kwargs={'ensure_ascii': True}), + ), + ] diff --git a/submissions/models.py b/submissions/models.py index 0e1fc54..57d1d3b 100644 --- a/submissions/models.py +++ b/submissions/models.py @@ -27,15 +27,10 @@ User = auth.get_user_model() # Signal to inform listeners that a score has been changed -score_set = Signal(providing_args=[ - 'points_possible', 'points_earned', 'anonymous_user_id', - 'course_id', 'item_id', 'created_at' -]) +score_set = Signal() # Signal to inform listeners that a score has been reset -score_reset = Signal( - providing_args=['anonymous_user_id', 'course_id', 'item_id', 'created_at'] -) +score_reset = Signal() class AnonymizedUserIDField(models.CharField): @@ -79,15 +74,18 @@ def __repr__(self): @property def student_item_dict(self): - return dict( - student_id=self.student_id, - course_id=self.course_id, - item_id=self.item_id, - item_type=self.item_type, - ) + return { + "student_id": self.student_id, + "course_id": self.course_id, + "item_id": self.item_id, + "item_type": self.item_type, + } def __str__(self): - return "({0.student_id}, {0.course_id}, {0.item_type}, {0.item_id})".format(self) + return ( + f"({self.student_id}, {self.course_id}, " + f"{self.item_type}, {self.item_id})" + ) class Meta: app_label = "submissions" @@ -164,9 +162,9 @@ def get_team_submission_by_uuid(team_submission_uuid): f"No team submission matching uuid {team_submission_uuid}" ) from error except Exception as exc: - err_msg = "Attempt to get team submission for uuid {uuid} caused error: {exc}".format( - uuid=team_submission_uuid, - exc=exc + err_msg = ( + f"Attempt to get team submission for uuid {team_submission_uuid} " + f"caused error: {exc}" ) logger.error(err_msg) raise TeamSubmissionInternalError(err_msg) from exc @@ -181,7 +179,7 @@ def get_team_submission_by_course_item_team(course_id, item_id, team_id): - TeamSubmissionInternalError if there is some other error looking up the team submission. """ - model_query_params = dict(course_id=course_id, item_id=item_id, team_id=team_id) + model_query_params = {"course_id": course_id, "item_id": item_id, "team_id": team_id} query_params_string = "course_id={course_id} item_id={item_id} team_id={team_id}".format(**model_query_params) try: # In the equivalent non-teams api call, we're filtering on student item and then getting first(), @@ -200,9 +198,9 @@ def get_team_submission_by_course_item_team(course_id, item_id, team_id): f"No team submission matching {query_params_string}" ) from error except Exception as exc: - err_msg = "Attempt to get team submission for {params} caused error: {exc}".format( - params=query_params_string, - exc=exc + err_msg = ( + f"Attempt to get team submission for {query_params_string} " + f"caused error: {exc}" ) logger.error(err_msg) raise TeamSubmissionInternalError(err_msg) from exc @@ -226,9 +224,9 @@ def get_team_submission_by_student_item(student_item): f"No team submission matching {student_item}" ) from error except Exception as exc: - err_msg = "Attempt to get team submission for {student_item} caused error: {exc}".format( - student_item=student_item, - exc=exc + err_msg = ( + f"Attempt to get team submission for {student_item} " + f"caused error: {exc}" ) logger.error(err_msg) raise TeamSubmissionInternalError(err_msg) from exc @@ -247,26 +245,23 @@ def get_all_team_submissions_for_course_item(course_id, item_id): item_id=item_id, ).all() except Exception as exc: - query_params_string = "course_id={course_id} item_id={item_id}".format( - course_id=course_id, - item_id=item_id, - ) - err_msg = "Attempt to get team submissions for {params} caused error: {exc}".format( - params=query_params_string, - exc=exc + query_params_string = f"course_id={course_id} item_id={item_id}" + err_msg = ( + f"Attempt to get team submissions for {query_params_string} " + f"caused error: {exc}" ) logger.error(err_msg) raise TeamSubmissionInternalError(err_msg) from exc def __repr__(self): - return repr(dict( - uuid=self.uuid, - submitted_by=self.submitted_by, - attempt_number=self.attempt_number, - submitted_at=self.submitted_at, - created=self.created, - modified=self.modified, - )) + return repr({ + "uuid": self.uuid, + "submitted_by": self.submitted_by, + "attempt_number": self.attempt_number, + "submitted_at": self.submitted_at, + "created": self.created, + "modified": self.modified, + }) def __str__(self): return f"Team Submission {self.uuid}" @@ -324,7 +319,7 @@ class Submission(models.Model): # replacement for TextField that performs JSON serialization/deserialization. # For backwards compatibility, we override the default database column # name so it continues to use `raw_answer`. - answer = JSONField(blank=True, db_column="raw_answer") + answer = JSONField(blank=True, dump_kwargs={'ensure_ascii': True}, db_column="raw_answer") status = models.CharField(max_length=1, choices=STATUS_CHOICES, default=ACTIVE) @@ -332,6 +327,7 @@ class Submission(models.Model): TeamSubmission, related_name='submissions', null=True, + blank=True, db_index=True, on_delete=models.SET_NULL ) @@ -349,14 +345,14 @@ def get_cache_key(sub_uuid): return f"submissions.submission.{sub_uuid}" def __repr__(self): - return repr(dict( - uuid=self.uuid, - student_item=self.student_item, - attempt_number=self.attempt_number, - submitted_at=self.submitted_at, - created_at=self.created_at, - answer=self.answer, - )) + return repr({ + "uuid": self.uuid, + "student_item": self.student_item, + "attempt_number": self.attempt_number, + "submitted_at": self.submitted_at, + "created_at": self.created_at, + "answer": self.answer, + }) def __str__(self): return f"Submission {self.uuid}" @@ -418,13 +414,13 @@ def to_float(self): return float(self.points_earned) / self.points_possible def __repr__(self): - return repr(dict( - student_item=self.student_item, - submission=self.submission, - created_at=self.created_at, - points_earned=self.points_earned, - points_possible=self.points_possible, - )) + return repr({ + "student_item": self.student_item, + "submission": self.submission, + "created_at": self.created_at, + "points_earned": self.points_earned, + "points_possible": self.points_possible, + }) def is_hidden(self): """ @@ -468,7 +464,7 @@ def create_reset_score(cls, student_item): ) def __str__(self): - return "{0.points_earned}/{0.points_possible}".format(self) + return f"{self.points_earned}/{self.points_possible}" class ScoreSummary(models.Model): @@ -525,8 +521,10 @@ def update_score_summary(sender, **kwargs): # pylint: disable=no-self-argument ) except DatabaseError: logger.exception( - "Error while updating score summary for student item {}" - .format(score.student_item) + "Error while updating score summary for student item %(item)s", + { + 'item': score.student_item, + } ) diff --git a/submissions/team_api.py b/submissions/team_api.py index 4465f16..cbd7a41 100644 --- a/submissions/team_api.py +++ b/submissions/team_api.py @@ -13,7 +13,7 @@ TeamSubmissionNotFoundError, TeamSubmissionRequestError ) -from submissions.models import DELETED, StudentItem, TeamSubmission +from submissions.models import DELETED, Submission, TeamSubmission from submissions.serializers import TeamSubmissionSerializer logger = logging.getLogger(__name__) @@ -115,6 +115,9 @@ def create_submission_for_team( 'submitted_by': submitting_user_id, 'attempt_number': attempt_number, } + log_string = f'{model_kwargs} Team Member Ids: {team_member_ids}' + logger.info("Creating team submission for %s", log_string) + if submitted_at: model_kwargs["submitted_at"] = submitted_at @@ -125,9 +128,8 @@ def create_submission_for_team( team_submission = team_submission_serializer.save() _log_team_submission(team_submission_serializer.data) except DatabaseError as exc: - error_message = "An error occurred while creating team submission {}: {}".format( - model_kwargs, - exc + error_message = ( + f"An error occurred while creating team submission {model_kwargs}: {exc}" ) logger.exception(error_message) raise TeamSubmissionInternalError(error_message) from exc @@ -139,25 +141,45 @@ def create_submission_for_team( } students_with_team_submissions = { - submission['student_id'] for submission in get_teammates_with_submissions_from_other_teams( + submission['student_id']: submission['team_id'] + for submission in get_teammates_with_submissions_from_other_teams( course_id, item_id, team_id, team_member_ids ) } + logger.info("[%s] Students with submissions from other teams: %s", log_string, students_with_team_submissions) + for team_member_id in team_member_ids: + logger.info("[%s] Creating individual submission for team member %s", log_string, team_member_id) if team_member_id in students_with_team_submissions: + logger.info( + "[%s] Team member %s already has a submission for team %s. Skipping.", + log_string, + team_member_id, + students_with_team_submissions[team_member_id] + ) continue team_member_student_item_dict = dict(base_student_item_dict) team_member_student_item_dict['student_id'] = team_member_id - _api.create_submission( - team_member_student_item_dict, - answer, - submitted_at=submitted_at, - attempt_number=attempt_number, - team_submission=team_submission - ) + try: + individual_submission = _api.create_submission( + team_member_student_item_dict, + answer, + submitted_at=submitted_at, + attempt_number=attempt_number, + team_submission=team_submission + ) + except Exception as exc: + logger.error( + "[%s] Unable to create individual submission for %s: %s", + log_string, + team_member_id, + str(exc) + ) + raise exc + logger.info("[%s] Created individual submission %s", log_string, individual_submission['uuid']) model_kwargs = { "answer": answer, @@ -178,16 +200,16 @@ def _log_team_submission(team_submission_data): None """ logger.info( - "Created team submission uuid={team_submission_uuid} for " - "(course_id={course_id}, item_id={item_id}, team_id={team_id}) " - "submitted_by={submitted_by}" - .format( - team_submission_uuid=team_submission_data["team_submission_uuid"], - course_id=team_submission_data["course_id"], - item_id=team_submission_data["item_id"], - team_id=team_submission_data["team_id"], - submitted_by=team_submission_data["submitted_by"], - ) + "Created team submission uuid=%(team_submission_uuid)s for " + "(course_id=%(course_id)s, item_id=%(item_id)s, team_id=%(team_id)s) " + "submitted_by=%(submitted_by)s", + { + 'team_submission_uuid': team_submission_data["team_submission_uuid"], + 'course_id': team_submission_data["course_id"], + 'item_id': team_submission_data["item_id"], + 'team_id': team_submission_data["team_id"], + 'submitted_by': team_submission_data["submitted_by"], + } ) @@ -210,19 +232,20 @@ def get_teammates_with_submissions_from_other_teams( Returns: list(dict): [{ 'student_id', 'team_id' }] """ - items = StudentItem.objects.filter( - submission__team_submission__isnull=False + submissions = Submission.objects.filter( + student_item__student_id__in=team_member_ids, + student_item__course_id=course_id, + student_item__item_id=item_id, ).exclude( - submission__team_submission__team_id=team_id - ).filter( - student_id__in=team_member_ids, - course_id=course_id, - item_id=item_id - ).values("student_id", "submission__team_submission__team_id") + team_submission__isnull=True, + ).exclude( + team_submission__team_id=team_id, + ).values("student_item__student_id", "team_submission__team_id") + return [{ - 'student_id': item['student_id'], - 'team_id': item['submission__team_submission__team_id'] - } for item in items] + 'student_id': submission['student_item__student_id'], + 'team_id': submission['team_submission__team_id'] + } for submission in submissions] def get_team_submission(team_submission_uuid): @@ -302,17 +325,16 @@ def get_team_submission_student_ids(team_submission_uuid): if not team_submission_uuid: raise TeamSubmissionNotFoundError() try: - student_ids = StudentItem.objects.filter( - submission__team_submission__uuid=team_submission_uuid - ).order_by( - 'student_id' - ).distinct().values_list( - 'student_id', flat=True + student_ids = TeamSubmission.objects.filter( + uuid=team_submission_uuid + ).values_list( + 'submissions__student_item__student_id', flat=True ) + except DatabaseError as exc: - err_msg = "Attempt to get student ids for team submission {team_submission_uuid} caused error: {exc}".format( - team_submission_uuid=team_submission_uuid, - exc=exc + err_msg = ( + f"Attempt to get student ids for team submission {team_submission_uuid} " + f"caused error: {exc}" ) logger.error(err_msg) raise TeamSubmissionInternalError(err_msg) from exc @@ -327,12 +349,12 @@ def get_team_ids_by_team_submission_uuid(team_submission_uuids): team submission uuids to team id """ values = TeamSubmission.objects.filter( - team_submission_uuid__in=team_submission_uuids + uuid__in=team_submission_uuids ).values( "uuid", "team_id" ) return { - item['uuid']: item['team_id'] + str(item['uuid']): item['team_id'] for item in values } @@ -365,16 +387,16 @@ def set_score(team_submission_uuid, points_earned, points_possible, """ team_submission_dict = get_team_submission(team_submission_uuid) - debug_msg = ( - 'Setting score for team submission uuid {uuid}, child submission uuids {individual_uuids}. ' - '{earned} / {possible}' - ).format( - uuid=team_submission_dict['team_submission_uuid'], - individual_uuids=team_submission_dict['submission_uuids'], - earned=points_earned, - possible=points_possible + logger.info( + 'Setting score for team submission uuid %(uuid)s, child submission uuids %(individual_uuids)s. ' + '%(earned)s / %(possible)s', + { + 'uuid': team_submission_dict['team_submission_uuid'], + 'individual_uuids': team_submission_dict['submission_uuids'], + 'earned': points_earned, + 'possible': points_possible, + } ) - logger.info(debug_msg) with transaction.atomic(): for individual_submission_uuid in team_submission_dict['submission_uuids']: @@ -424,12 +446,13 @@ def reset_scores(team_submission_uuid, clear_state=False): team_submission.save(update_fields=["status"]) except (DatabaseError, SubmissionInternalError) as error: msg = ( - "Error occurred while reseting scores for team submission {team_submission_uuid}" - ).format(team_submission_uuid=team_submission_uuid) + f"Error occurred while reseting scores for team submission {team_submission_uuid}" + ) logger.exception(msg) raise TeamSubmissionInternalError(msg) from error - else: - msg = "Score reset for team submission {team_submission_uuid}".format( - team_submission_uuid=team_submission_uuid - ) - logger.info(msg) + logger.info( + "Score reset for team submission %(team_submission_uuid)s", + { + 'team_submission_uuid': team_submission_uuid, + } + ) diff --git a/submissions/tests/test_api.py b/submissions/tests/test_api.py index d0e24d3..c5049d0 100644 --- a/submissions/tests/test_api.py +++ b/submissions/tests/test_api.py @@ -8,28 +8,29 @@ import ddt import pytz from django.core.cache import cache -from django.db import DatabaseError, connection, transaction +from django.db import DatabaseError, IntegrityError, connection, transaction from django.test import TestCase from django.utils.timezone import now from freezegun import freeze_time from submissions import api +from submissions.errors import SubmissionInternalError from submissions.models import ScoreAnnotation, ScoreSummary, StudentItem, Submission, score_set from submissions.serializers import StudentItemSerializer -STUDENT_ITEM = dict( - student_id="Tim", - course_id="Demo_Course", - item_id="item_one", - item_type="Peer_Submission", -) +STUDENT_ITEM = { + "student_id": "Tim", + "course_id": "Demo_Course", + "item_id": "item_one", + "item_type": "Peer_Submission", +} -SECOND_STUDENT_ITEM = dict( - student_id="Alice", - course_id="Demo_Course", - item_id="item_one", - item_type="Peer_Submission", -) +SECOND_STUDENT_ITEM = { + "student_id": "Alice", + "course_id": "Demo_Course", + "item_id": "item_one", + "item_type": "Peer_Submission", +} ANSWER_ONE = "this is my answer!" ANSWER_TWO = "this is my other answer!" @@ -760,12 +761,12 @@ def test_get_student_ids_by_submission_uuid(self): def submit(course_id, item_id, student_ids): result_dict = {} for student_id in student_ids: - student_item = dict( - course_id=course_id, - item_id=item_id, - student_id=student_id, - item_type='test_get_student_ids_by_submission_uuid' - ) + student_item = { + "course_id": course_id, + "item_id": item_id, + "student_id": student_id, + "item_type": 'test_get_student_ids_by_submission_uuid' + } submission_uuid = api.create_submission(student_item, ANSWER_ONE)['uuid'] result_dict[submission_uuid] = student_id return result_dict @@ -829,3 +830,36 @@ def submit(course_id, item_id, student_ids): ), item_3_expected_result ) + + def test_get_or_create_student_item_race_condition__item_created(self): + """ + Test for a race condition in _get_or_create_student_item where the item does not exist when + we check first, but has been created by the time we try to save, raising an IntegrityError. + + Test for the case where the second call to get succeeds. + """ + mock_item = mock.Mock() + with mock.patch.object(StudentItem.objects, "get") as mock_get_item: + with mock.patch.object(StudentItemSerializer, "save", side_effect=IntegrityError): + mock_get_item.side_effect = [ + StudentItem.DoesNotExist, + mock_item + ] + self.assertEqual( + api._get_or_create_student_item(STUDENT_ITEM), # pylint: disable=protected-access + mock_item + ) + + def test_get_or_create_student_item_race_condition__item_not_created(self): + """ + Test for a race condition in _get_or_create_student_item where the item does not exist when + we check first, but has been created by the time we try to save, raising an IntegrityError. + + Test for the case where the second call does not return an item, so the caught IntegrityError was something + else and should be re-raised. + """ + with mock.patch.object(StudentItem.objects, "get") as mock_get_item: + with mock.patch.object(StudentItemSerializer, "save", side_effect=IntegrityError): + mock_get_item.side_effect = StudentItem.DoesNotExist + with self.assertRaisesMessage(SubmissionInternalError, "An error occurred creating student item"): + api._get_or_create_student_item(STUDENT_ITEM) # pylint: disable=protected-access diff --git a/submissions/tests/test_team_api.py b/submissions/tests/test_team_api.py index af8e441..bc0c39d 100644 --- a/submissions/tests/test_team_api.py +++ b/submissions/tests/test_team_api.py @@ -289,19 +289,45 @@ def test_get_teammates_with_submissions_from_other_teams(self): # Should get 1 entry for each of the default users self.assertEqual(len(external_submissions), 4) - - def check_submission(submission, user): - self.assertEqual( - submission, + for student_id in self.student_ids: + self.assertIn( { - 'student_id': self.anonymous_user_id_map[user], + 'student_id': student_id, 'team_id': TEAM_1_ID - } + }, + external_submissions + ) + + def test_get_teammates_with_submissions_from_other_teams__cancelled(self): + # Make a team submission with default users, under TEAM_1 + team_submission = self._make_team_submission( + attempt_number=1, + course_id=COURSE_ID, + item_id=ITEM_1_ID, + team_id=TEAM_1_ID, + status=None, + create_submissions=True + ) + + # Simulate resetting student state for the team, which causes the submissions to be deleted + team_api.reset_scores(team_submission.uuid, clear_state=True) + + team_submission.refresh_from_db() + self.assertEqual(team_submission.status, DELETED) + for individual_submission in team_submission.submissions.all(): + self.assertEqual(individual_submission.status, DELETED) + + # Now, everyone has moved to a new team, but their old submission was deleted, so no one should be listed + with self.assertNumQueries(1): + external_submissions = team_api.get_teammates_with_submissions_from_other_teams( + COURSE_ID, + ITEM_1_ID, + TEAM_2_ID, + self.student_ids ) - check_submission(external_submissions[0], self.user_1) - check_submission(external_submissions[1], self.user_2) - check_submission(external_submissions[2], self.user_3) - check_submission(external_submissions[3], self.user_4) + + # Returns no one, since the submission was cancelled + self.assertEqual(external_submissions, []) def test_get_team_submission(self): """ @@ -613,18 +639,36 @@ def test_get_team_submission_student_ids(self): for student_id in submission_2_student_ids: self._make_individual_submission(student_id, team_submission=team_submission_2) + with self.assertNumQueries(1): + team_1_ids = team_api.get_team_submission_student_ids(str(team_submission.uuid)) + + team_2_ids = team_api.get_team_submission_student_ids(str(team_submission_2.uuid)) + # Assert that each team submission's uuid returns the correct student_ids - self.assertEqual( - team_api.get_team_submission_student_ids(str(team_submission.uuid)), - self.student_ids - ) - self.assertEqual( - team_api.get_team_submission_student_ids(str(team_submission_2.uuid)), - submission_2_student_ids - ) + self.assertEqual(team_1_ids, self.student_ids) + self.assertEqual(team_2_ids, submission_2_student_ids) def test_get_team_submission_student_ids__no_team_submission(self): with self.assertRaises(TeamSubmissionNotFoundError): team_api.get_team_submission_student_ids('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee') with self.assertRaises(TeamSubmissionNotFoundError): team_api.get_team_submission_student_ids(None) + + def test_get_team_ids_by_team_submission_uuid(self): + team_submissions = [ + TeamSubmissionFactory.create() for _ in range(5) + ] + assert team_api.get_team_ids_by_team_submission_uuid([]) == {} + + actual = team_api.get_team_ids_by_team_submission_uuid([ + team_submissions[0].uuid, + team_submissions[1].uuid, + team_submissions[4].uuid, + ]) + expected = { + str(team_submissions[0].uuid): team_submissions[0].team_id, + str(team_submissions[1].uuid): team_submissions[1].team_id, + str(team_submissions[4].uuid): team_submissions[4].team_id, + } + + assert expected == actual diff --git a/submissions/views.py b/submissions/views.py index 95c4f87..69e03a4 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -3,7 +3,7 @@ import logging from django.contrib.auth.decorators import login_required -from django.shortcuts import render_to_response +from django.shortcuts import render from submissions.api import SubmissionRequestError, get_submissions @@ -30,16 +30,16 @@ def get_submissions_for_student_item(request, course_id, student_id, item_id): student item. """ - student_item_dict = dict( - course_id=course_id, - student_id=student_id, - item_id=item_id, - ) - context = dict(**student_item_dict) + student_item_dict = { + "course_id": course_id, + "student_id": student_id, + "item_id": item_id, + } + context = {**student_item_dict} try: submissions = get_submissions(student_item_dict) context["submissions"] = submissions except SubmissionRequestError: context["error"] = "The specified student item was not found." - return render_to_response('submissions.html', context) + return render(request, 'submissions.html', context) diff --git a/tox.ini b/tox.ini index 54d07cf..c4785c6 100644 --- a/tox.ini +++ b/tox.ini @@ -1,34 +1,31 @@ [tox] -envlist = py38-django{22,30,31,32}-drf{12, latest}, quality, docs +envlist = py{38,311,312}-django{42}-drf{315,latest}, quality, docs [testenv] -setenv= +setenv = DJANGO_SETTINGS_MODULE = settings -deps = +deps = -r{toxinidir}/requirements/test.txt - django22: Django>=2.2,<2.3 - django30: Django>=3.0,<3.1 - django31: Django>=3.1,<3.2 - django32: Django>=3.2,<4.0 - drf311: djangorestframework<3.12.0 - drf312: djangorestframework<3.13.0 + django42: Django>=4.2,<4.3 + drf315: djangorestframework<3.16 drflatest: djangorestframework commands = python -Wd -m pytest {posargs} [testenv:quality] -setenv= +setenv = DJANGO_SETTINGS_MODULE=settings -whitelist_externals = +allowlist_externals = make -deps = +deps = -r{toxinidir}/requirements/dev.txt -commands = +commands = make test_quality [testenv:docs] deps = + setuptools -r{toxinidir}/requirements/dev.txt -commands = +commands = python setup.py build_sphinx