diff --git a/.annotation_safe_list.yml b/.annotation_safe_list.yml index 62eaaa7..4babccc 100644 --- a/.annotation_safe_list.yml +++ b/.annotation_safe_list.yml @@ -8,34 +8,54 @@ # ".. choice_annotation:": foo, bar, baz admin.LogEntry: - ".. no_pii:": "This model has no PII" + ".. no_pii:": This model has no PII auth.Group: - ".. no_pii:": "This model has no PII" + ".. no_pii:": This model has no PII auth.Permission: - ".. no_pii:": "This model has no PII" + ".. no_pii:": This model has no PII auth.User: - ".. pii": "This model minimally contains a username, password, and email" - ".. pii_types": "username, email_address, password" - ".. pii_retirement": "consumer_api" + ".. pii": This model minimally contains a username, password, and email + ".. pii_types": username, email_address, password + ".. pii_retirement": consumer_api contenttypes.ContentType: - ".. no_pii:": "This model has no PII" + ".. no_pii:": This model has no PII +completion.BlockCompletion: + ".. no_pii:": This model has no PII +completion_aggregator.Aggregator: + ".. no_pii:": This model has no PII +completion_aggregator.CacheGroupInvalidation: + ".. no_pii:": This model has no PII +completion_aggregator.StaleCompletion: + ".. pii": This model contains a username; the entries are regularly cleaned up (usually every hour) +django_celery_beat.ClockedSchedule: + ".. no_pii:": This model has no PII +django_celery_beat.CrontabSchedule: + ".. no_pii:": This model has no PII +django_celery_beat.IntervalSchedule: + ".. no_pii:": This model has no PII +django_celery_beat.PeriodicTask: + ".. no_pii:": This model has no PII +django_celery_beat.PeriodicTasks: + ".. no_pii:": This model has no PII +django_celery_beat.SolarSchedule: + ".. no_pii:": This model has no PII sessions.Session: - ".. no_pii:": "This model has no PII" + ".. no_pii:": This model has no PII social_django.Association: - ".. no_pii:": "This model has no PII" + ".. no_pii:": This model has no PII social_django.Code: - ".. pii:": "Email address" + ".. pii:": Email address ".. pii_types:": other ".. pii_retirement:": local_api social_django.Nonce: - ".. no_pii:": "This model has no PII" + ".. no_pii:": This model has no PII social_django.Partial: - ".. no_pii:": "This model has no PII" + ".. no_pii:": This model has no PII social_django.UserSocialAuth: - ".. no_pii:": "This model has no PII" + ".. no_pii:": This model has no PII waffle.Flag: - ".. no_pii:": "This model has no PII" + ".. no_pii:": This model has no PII waffle.Sample: - ".. no_pii:": "This model has no PII" + ".. no_pii:": This model has no PII waffle.Switch: - ".. no_pii:": "This model has no PII" + ".. no_pii:": This model has no PII diff --git a/.coveragerc b/.coveragerc index b7c8bd2..b16b1f1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -8,3 +8,5 @@ omit = *admin.py */static/* */templates/* + */tests/* + */settings/* diff --git a/.editorconfig b/.editorconfig index 5cf5aed..789b285 100644 --- a/.editorconfig +++ b/.editorconfig @@ -94,7 +94,4 @@ trim_trailing_whitespace = false [COMMIT_EDITMSG] max_line_length = 72 -[*.rst] -max_line_length = 79 - # f2f02689fced7a2e0c62c2f9803184114dc2ae4b diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c62dc88..9d6aaad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,7 @@ on: branches: [main] pull_request: branches: - - '**' + - '**' jobs: @@ -19,27 +19,31 @@ jobs: toxenv: [quality, docs, pii_check, django32, django40, package] steps: - - uses: actions/checkout@v3 - - name: setup python - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Install pip - run: pip install -r requirements/pip.txt - - - name: Install Dependencies - run: pip install -r requirements/ci.txt - - - name: Run Tests - env: - TOXENV: ${{ matrix.toxenv }} - run: tox - - - name: Run coverage - if: matrix.python-version == '3.8' && matrix.toxenv == 'django32' - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - flags: unittests - fail_ci_if_error: true + - uses: actions/checkout@v3 + - name: setup python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: setup graphviz + if: matrix.toxenv == 'docs' + uses: ts-graphviz/setup-graphviz@v1 + + - name: Install pip + run: pip install -r requirements/pip.txt + + - name: Install Dependencies + run: pip install -r requirements/ci.txt + + - name: Run Tests + env: + TOXENV: ${{ matrix.toxenv }} + run: tox + + - name: Run coverage + if: matrix.python-version == '3.8' && matrix.toxenv == 'django32' + uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: unittests + fail_ci_if_error: true diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index b3d2dbe..8e36925 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -5,7 +5,7 @@ on: branches: [main] pull_request: branches: - - '**' + - '**' jobs: lint: diff --git a/.github/workflows/upgrade-python-requirements.yml b/.github/workflows/upgrade-python-requirements.yml index 61bfc00..84125bb 100644 --- a/.github/workflows/upgrade-python-requirements.yml +++ b/.github/workflows/upgrade-python-requirements.yml @@ -2,7 +2,7 @@ name: Upgrade Python Requirements on: schedule: - - cron: "0 2 * * 1" + - cron: 0 2 * * 1 workflow_dispatch: inputs: branch: diff --git a/.gitignore b/.gitignore index f8ab970..ca27db4 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ pip-log.txt coverage.xml htmlcov/ pii_report/ +default.db diff --git a/.pii_annotations.yml b/.pii_annotations.yml index 7da8f3c..d224255 100644 --- a/.pii_annotations.yml +++ b/.pii_annotations.yml @@ -7,29 +7,29 @@ annotations: "pii_group": - ".. pii:": - ".. pii_types:": - choices: - - id # Unique identifier for the user which is shared across systems - - name # Used for any part of the user's name - - username - - password - - location # Used for any part of any type address or country stored - - phone_number # Used for phone or fax numbers - - email_address - - birth_date # Used for any part of a stored birth date - - ip # IP address - - external_service # Used for external service ids or links such as social media links or usernames, website links, etc. - - biography # Any type of free-form biography field - - gender - - sex - - image - - video - - other + choices: + - id # Unique identifier for the user which is shared across systems + - name # Used for any part of the user's name + - username + - password + - location # Used for any part of any type address or country stored + - phone_number # Used for phone or fax numbers + - email_address + - birth_date # Used for any part of a stored birth date + - ip # IP address + - external_service # Used for external service ids or links such as social media links or usernames, website links, etc. + - biography # Any type of free-form biography field + - gender + - sex + - image + - video + - other - ".. pii_retirement:": - choices: - - retained # Intentionally kept for legal reasons - - local_api # An API exists in this repository for retiring this information - - consumer_api # The data's consumer must implement an API for retiring this information - - third_party # A third party API exists to retire this data + choices: + - retained # Intentionally kept for legal reasons + - local_api # An API exists in this repository for retiring this information + - consumer_api # The data's consumer must implement an API for retiring this information + - third_party # A third party API exists to retire this data extensions: python: - py diff --git a/.readthedocs.yml b/.readthedocs.yml index 0f029d2..a9b8da6 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -9,7 +9,17 @@ version: 2 sphinx: configuration: docs/conf.py +build: + os: ubuntu-22.04 + tools: + python: "3.8" + apt_packages: + - graphviz + python: - version: 3.8 install: - requirements: requirements/doc.txt + +formats: + - epub + - pdf diff --git a/.yamllint.yml b/.yamllint.yml new file mode 100644 index 0000000..21d1d1e --- /dev/null +++ b/.yamllint.yml @@ -0,0 +1,22 @@ +extends: default + +ignore: | + .pytest_cache/ + .ruff_cache/ + .tox/ + pii_report/ + venv/ +rules: + indentation: + spaces: consistent + check-multi-line-strings: true + line-length: disable + document-start: disable + quoted-strings: + quote-type: any + required: only-when-needed + braces: + forbid: non-empty + level: warning + truthy: + check-keys: false diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2d559ca..a2d4874 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,10 +16,10 @@ Unreleased * -0.1.0 – 2023-08-17 +0.1.0 – 2023-09-17 ********************************************** Added ===== -* First release on PyPI. +* Initial implementation of the certificates app. diff --git a/Makefile b/Makefile index 3bf6b7e..95b6ac5 100644 --- a/Makefile +++ b/Makefile @@ -63,8 +63,9 @@ piptools: ## install pinned version of pip-compile and pip-sync requirements: piptools ## install development environment requirements pip-sync -q requirements/dev.txt requirements/private.* -lint: ## lint all Python files +lint: ## lint all files black . + yamllint . test: clean ## run tests in the current virtualenv pytest @@ -72,10 +73,8 @@ test: clean ## run tests in the current virtualenv diff_cover: test ## find diff lines that need test coverage diff-cover coverage.xml -test-all: quality pii_check ## run tests on every supported Python/Django combination - tox - tox -e docs - tox -e package +test-all: ## run all tests + tox --parallel validate: quality pii_check test ## run tests and quality checks diff --git a/README.rst b/README.rst index 23b2a15..a91f4f2 100644 --- a/README.rst +++ b/README.rst @@ -1,107 +1,24 @@ openedx-certificates -############################# - -.. note:: - - This README was auto-generated. Maintainer: please review its contents and - update all relevant sections. Instructions to you are marked with - "PLACEHOLDER" or "TODO". Update or remove those sections, and remove this - note when you are done. +#################### |pypi-badge| |ci-badge| |codecov-badge| |doc-badge| |pyversions-badge| -|license-badge| |status-badge| +|license-badge| |status-badge| |visualization-badge| Purpose ******* A pluggable service for preparing Open edX certificates. -TODO: The ``README.rst`` file should start with a brief description of the repository and its purpose. -It should be described in the context of other repositories under the ``openedx`` -organization. It should make clear where this fits in to the overall Open edX -codebase and should be oriented towards people who are new to the Open edX -project. - -Getting Started -*************** - -Developing -========== - -One Time Setup --------------- -.. code-block:: - - # Clone the repository - git clone git@github.com:open-craft/openedx-certificates.git - cd openedx-certificates - - # Set up a virtualenv with the same name as the repo and activate it - # Here's how you might do that if you have virtualenvwrapper setup. - mkvirtualenv -p python3.8 openedx-certificates - - -Every time you develop something in this repo ---------------------------------------------- -.. code-block:: - - # Activate the virtualenv - # Here's how you might do that if you're using virtualenvwrapper. - workon openedx-certificates - - # Grab the latest code - git checkout main - git pull - - # Install/update the dev requirements - make requirements - - # Run the tests and quality checks (to verify the status before you make any changes) - make validate - - # Make a new branch for your changes - git checkout -b / - - # Using your favorite editor, edit the code to make your change. - vim ... - - # Run your new tests - pytest ./path/to/new/tests - - # Run all the tests and quality checks - make validate - - # Commit all your changes - git commit ... - git push - - # Open a PR and ask for review. - -Deploying -========= - -TODO: How can a new user go about deploying this component? Is it just a few -commands? Is there a larger how-to that should be linked here? +Documentation +************* -PLACEHOLDER: For details on how to deploy this component, see the `deployment how-to`_ +Start by going through `the documentation`_. -.. _deployment how-to: https://docs.openedx.org/projects/openedx-certificates/how-tos/how-to-deploy-this-component.html +.. _the documentation: https://openedx-certificates.readthedocs.io/en/latest Getting Help ************ -Documentation -============= - -PLACEHOLDER: Start by going through `the documentation`_. If you need more help see below. - -.. _the documentation: https://docs.openedx.org/projects/openedx-certificates - -(TODO: `Set up documentation `_) - -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. @@ -151,6 +68,8 @@ All community members are expected to follow the `Open edX Code of Conduct`_. People ****** +.. TODO: Add the maintainers. + 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. @@ -175,7 +94,7 @@ Please do not report security issues in public. Please email security@openedx.or :alt: Codecov .. |doc-badge| image:: https://readthedocs.org/projects/openedx-certificates/badge/?version=latest - :target: https://docs.openedx.org/projects/openedx-certificates + :target: https://openedx-certificates.readthedocs.io/en/latest :alt: Documentation .. |pyversions-badge| image:: https://img.shields.io/pypi/pyversions/openedx-certificates.svg @@ -188,3 +107,8 @@ Please do not report security issues in public. Please email security@openedx.or .. |status-badge| image:: https://img.shields.io/badge/Status-Experimental-yellow :alt: Status + +.. https://githubnext.com/projects/repo-visualization/ +.. |visualization-badge| image:: https://img.shields.io/badge/Repo%20Visualization-8A2BE2 + :target: https://mango-dune-07a8b7110.1.azurestaticapps.net/?repo=open-craft/openedx-certificates + :alt: Visualization diff --git a/catalog-info.yaml b/catalog-info.yaml index 4b1a33c..9f51a02 100644 --- a/catalog-info.yaml +++ b/catalog-info.yaml @@ -4,8 +4,8 @@ apiVersion: backstage.io/v1alpha1 kind: "" metadata: - name: 'openedx-certificates' - description: "A pluggable service for preparing Open edX certificates." + name: openedx-certificates + description: A pluggable service for preparing Open edX certificates. annotations: # (Optional) Annotation keys and values can be whatever you want. # We use it in Open edX repos to have a comma-separated list of GitHub user @@ -21,12 +21,12 @@ spec: type: '' # (Required) Acceptable Lifecycle Values: experimental, production, deprecated - lifecycle: 'experimental' + lifecycle: experimental # (Optional) The value can be the name of any known component. - subcomponentOf: '' + subcomponentOf: # (Optional) An array of different components or resources. dependsOn: - - '' - - '' + - + - diff --git a/codecov.yml b/codecov.yml index 4da4768..16e67ae 100644 --- a/codecov.yml +++ b/codecov.yml @@ -2,11 +2,11 @@ coverage: status: project: default: - enabled: yes - target: auto + enabled: true + target: 80% patch: default: - enabled: yes - target: 100% + enabled: true + target: 80% comment: false diff --git a/docs/conf.py b/docs/conf.py index 70cc4c4..8049003 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -67,6 +67,7 @@ def get_version(*file_paths): 'sphinx.ext.intersphinx', 'sphinx.ext.ifconfig', 'sphinx.ext.napoleon', + 'sphinx.ext.graphviz', ] # A list of warning types to suppress arbitrary warning messages. diff --git a/docs/decisions/0001-purpose-of-this-repo.rst b/docs/decisions/0001-purpose-of-this-repo.rst index d67cb89..fb05eb5 100644 --- a/docs/decisions/0001-purpose-of-this-repo.rst +++ b/docs/decisions/0001-purpose-of-this-repo.rst @@ -4,54 +4,133 @@ Status ****** -**Draft** - -.. TODO: When ready, update the status from Draft to Provisional or Accepted. +**Accepted** .. Standard statuses + - **Draft** if the decision is newly proposed and in active discussion - **Provisional** if the decision is still preliminary and in experimental phase - **Accepted** *(date)* once it is agreed upon - **Superseded** *(date)* with a reference to its replacement if a later ADR changes or reverses the decision - If an ADR has Draft status and the PR is under review, you can either use the intended final status (e.g. Provisional, Accepted, etc.), or you can clarify both the current and intended status using something like the following: "Draft (=> Provisional)". Either of these options is especially useful if the merged status is not intended to be Accepted. + If an ADR has Draft status and the PR is under review, you can either use the intended final status + (e.g. Provisional, Accepted, etc.), or you can clarify both the current and intended status using something like the + following: "Draft (=> Provisional)". Either of these options is especially useful if the merged status is not + intended to be Accepted. Context ******* -TODO: Add context of what led to the creation of this repo. +We want to issue the certificates and badges for students participating in the courses. + +The present workflow used for the certificates is quite complex because we generate them based on the data pulled from +the Open edX databases (MySQL and MongoDB). The purpose of this repository is to implement a service closely connected +with Open edX. + +We want to support the following certificate types: + +#. Certificate of achievement + We grant it when a student receives a passing grade in both of the following: + + #. Course assignments (excluding the final exam). + #. The final exam. + +#. Certificate of completion + We grant it when a student receives the required percentage of the completion checkmarks in the course. + +#. Badge of achievement/completion (temporary name) + Almost identical to the certificates of achievement/completion. The only difference is that we award them for + completing :ref:`Lessons `. + +#. Pathway certificate + We grant it when a student receives a passing grade in all courses/lessons in the :ref:`Pathway `. + +#. Achievement (temporary name) + Similar to the `Badges`_, recently removed from Open edX. Example criteria: + + #. A student received a passing grade on the course assignments (excluding the final exam). + #. A student received a passing grade on the final exam. + #. A student received all completion checkmarks in the course. + #. A student has posted a comment in the forum. + + +Other notes: -.. This section describes the forces at play, including technological, political, social, and project local. These forces are probably in tension, and should be called out as such. The language in this section is value-neutral. It is simply describing facts. +#. We do not need to pull data in real time. This service can retrieve data periodically, but the frequency should be + configurable per course. +#. We need to design an interface for configuring these certificates per course. The goal is to make it as simple as + possible for the course authors. It will be designed in a future iteration. + +.. _Badges: https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/enable_badging.html + +.. This section describes the forces at play, including technological, political, social, and project local. + These forces are probably in tension, and should be called out as such. The language in this section is + value-neutral. It is simply describing facts. Decision ******** -We will create a repository... - -TODO: Clearly state how the context above led to the creation of this repo. +#. We will implement the certificates mechanism as a Django app plugin. +#. This plugin will be installed on the same server as the Open edX instance. It will use the same database as the + Open edX instance to optimize the performance. This decision is made to minimize the latency that would be + introduced by querying web APIs. +#. The goal is to rely only on the public Python APIs and avoid direct access to the database or models. This is to + minimize breaking changes in the next Open edX releases. +#. The details of this plugin's architecture are described in the :doc:`0002-architecture` document. .. This section describes our response to these forces. It is stated in full sentences, with active voice. "We will …" Consequences ************ -TODO: Add what other things will change as a result of creating this repo. +We will stop using the built-in certificates mechanism. The goal of this repository is to have as little dependencies +from the core ``edx-platform`` as possible to make it easier to maintain and upgrade. -.. This section describes the resulting context, after applying the decision. All consequences should be listed here, not just the "positive" ones. A particular decision may have positive, negative, and neutral consequences, but all of them affect the team and project in the future. +.. This section describes the resulting context, after applying the decision. All consequences should be listed here, + not just the "positive" ones. A particular decision may have positive, negative, and neutral consequences, but all of + them affect the team and project in the future. Rejected Alternatives ********************* -TODO: If applicable, list viable alternatives to creating this new repo and give reasons for why they were rejected. If not applicable, remove section. +We considered: + +#. Using the certificates built into Open edX. + However, we need to rework them significantly to meet the described requirements. Given the plans to migrate + the course certificates to the `credentials`_ service, maintaining this approach could take a lot of work. +#. Using the `credentials`_ service. + Currently, this service supports only Programs. It means that it is tightly coupled to the `course-discovery`_ IDA. + We will use :ref:`Pathway ` instead of Programs to remove this dependency and gain more flexibility. + Therefore, the `credentials`_ service would also require significant reworks to support the necessary features. +#. Using the existing implementation of the `Badges`_. + This code was not maintained for a long time and was recently removed from Open edX. + +.. _credentials: https://github.com/openedx/credentials +.. _course-discovery: https://github.com/openedx/course-discovery .. This section lists alternate options considered, described briefly, with pros and cons. -References -********** +Definitions +*********** + +#. **Course** + + .. _course: + + A standard Open edX course. Also referred to as a Course XBlock or a ``CourseBlock``. + +#. **Lesson** + + .. _lesson: + + A course that consists of a single section. The `section-to-course`_ extension extracts this section from a "full" + course. + +#. **Pathway** -TODO: If applicable, add any references. If not applicable, remove section. + .. _pathway: -.. (Optional) List any additional references here that would be useful to the future reader. See `Documenting Architecture Decisions`_ and `OEP-19 on ADRs`_ for further input. + A similar concept to Open edX Programs but handled by an Open edX plugin. Once we complete the Pathway planning + phase, we will update this definition and add a link. -.. _Documenting Architecture Decisions: https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions -.. _OEP-19 on ADRs: https://open-edx-proposals.readthedocs.io/en/latest/best-practices/oep-0019-bp-developer-documentation.html#adrs +.. _section-to-course: https://github.com/open-craft/section-to-course/ diff --git a/docs/decisions/0002-architecture.rst b/docs/decisions/0002-architecture.rst new file mode 100644 index 0000000..2c7b885 --- /dev/null +++ b/docs/decisions/0002-architecture.rst @@ -0,0 +1,119 @@ +0002 Architecture +################# + +.. TODO: This document will be moved to a plugin repo once we have a plugin architecture. + +Status +****** + +**Provisional** + + +Context +******* + +#. This Django app generates and shows user certificates. +#. The models should store certificate configurations. The certificate types will vary between different course types. + The available course types are: + + #. :ref:`Course `. + #. :ref:`Lesson `. + #. :ref:`Pathway `. + +#. This Django app uses `celerybeat`_ to periodically retrieve data from the external API. Retrieving this data is + pluggable - it means that other developers can develop a Python package and install it to have a custom ways to + retrieve data from different APIs. +#. If a user matches the criteria, the certificates will be generated from a PDF template (stored in the assets model). + The PDF will be uploaded to S3, and the link will be sent to the user. The generation process should also be pluggable - it means + that other developers can develop a Python package and install it to have custom ways to generate certificates. + +.. _celerybeat: https://django-celery-beat.readthedocs.io/en/latest/ + + +Decision +******** + +.. graphviz:: + + digraph G { + node [shape=box, style=filled, fillcolor=gray95] + edge [fontcolor=black, color=black] + + subgraph cluster_0 { + label = "Open edX"; + style=filled; + color=lightgrey; + + // Resources + LMS; + } + + subgraph cluster_1 { + label = "openedx-certificates"; + style=filled; + color=lightgrey; + + // Resources/models + CertificateType [label="ExternalCertificateType"] + CourseConfiguration [label="ExternalCertificateCourseConfiguration"] + Certificate [label="ExternalCertificate"] + Asset [label="ExternalCertificateAsset"] + PeriodicTask + Schedule + + // Processes + Processing [shape=ellipse] + Generation [shape=ellipse] + + // DB relations + edge [fontcolor=black, color=gray50] + CertificateType -> CourseConfiguration [dir=back, headlabel="0..*", taillabel="1 "] + CourseConfiguration -> PeriodicTask [dir=both, headlabel="1 ", taillabel="1"] + + // Non-DB relations + edge [fontcolor=black, color=blue] + CourseConfiguration -> Generation + Asset -> Generation + PeriodicTask -> Schedule + + // Processes + edge [fontcolor=black, color=red] + Schedule -> Processing [label="trigger"] + Processing -> Generation [label="provide elgible users"] + Generation -> Certificate [label="generate certificates"] + } + + + // Processes involving external APIs. + edge [fontcolor=black, color=red] + Processing -> LMS [label="pull data", dir=forward] + + } + + +User stories +************ + +TODO: Move this to the docs. + +As an Instructor, I want to enable certificate generation for a course. +======================================================================= + +To do this, I should: + +#. Visit course certificate admin page. +#. Create a new entry with a course ID, certificate type and an "Enabled" toggle. +#. Internally, each of these entries will be a cron task. This way, we can set individual certificate generation schedules. + It means that an Instructor can schedule generating different certificates for the same course at different times. + +Once done, the celery cron will be scheduled to run at the specified time. The celery task will: + +#. Retrieve data from the external API. +#. Check which users are eligible for a certificate. +#. Generate certificates for the eligible users. + + +Questions: + +#. Should we use course's start/end date to gate cert generation? +#. Maybe we could disable the cron task when the course is closed? diff --git a/docs/getting_started.rst b/docs/getting_started.rst index cec5418..fd0870e 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -1,18 +1,76 @@ Getting Started ############### +Developing +********** + If you have not already done so, create/activate a `virtualenv`_. Unless otherwise stated, assume all terminal code below is executed within the virtualenv. .. _virtualenv: https://virtualenvwrapper.readthedocs.org/en/latest/ +One Time Setup +============== +.. code-block:: bash + + # Clone the repository + git clone git@github.com:open-craft/openedx-certificates.git + cd openedx-certificates + + # Set up a virtualenv with the same name as the repo and activate it + # Here's how you might do that if you have virtualenvwrapper setup. + mkvirtualenv -p python3.8 openedx-certificates + + # Install project dependencies + make requirements -Install dependencies -******************** -Dependencies can be installed via the command below. +Every time you develop something in this repo +============================================= .. code-block:: bash - $ make requirements + # Activate the virtualenv + # Here's how you might do that if you're using virtualenvwrapper. + workon openedx-certificates + + # Grab the latest code + git checkout main + git pull + + # Install/update the dev requirements + make requirements + + # Run the tests and quality checks (to verify the status before you make any changes) + make validate + + # Make a new branch for your changes + git checkout -b / + + # Using your favorite editor, edit the code to make your change. + vim ... + + # Run your new tests + pytest ./path/to/new/tests + + # Run all the tests and quality checks + make validate + + # Commit all your changes + git commit ... + git push + + # Open a PR and ask for review. + + +Deploying +********* + +TODO: Document this. + +Ansible Playbooks +================= +If you still use the `configuration`_ repository to deploy your Open edX instance, set +``EDXAPP_ENABLE_CELERY_BEAT: true`` to enable the Celery beat service. Without this, periodic tasks will not be run. +.. _configuration: https://github.com/openedx/configuration diff --git a/docs/quickstarts/images/assets.png b/docs/quickstarts/images/assets.png new file mode 100644 index 0000000..0d970c0 Binary files /dev/null and b/docs/quickstarts/images/assets.png differ diff --git a/docs/quickstarts/images/course_config.png b/docs/quickstarts/images/course_config.png new file mode 100644 index 0000000..6e8f505 Binary files /dev/null and b/docs/quickstarts/images/course_config.png differ diff --git a/docs/quickstarts/images/course_schedule.png b/docs/quickstarts/images/course_schedule.png new file mode 100644 index 0000000..ec77360 Binary files /dev/null and b/docs/quickstarts/images/course_schedule.png differ diff --git a/docs/quickstarts/images/type_achievement.png b/docs/quickstarts/images/type_achievement.png new file mode 100644 index 0000000..1dbf8a9 Binary files /dev/null and b/docs/quickstarts/images/type_achievement.png differ diff --git a/docs/quickstarts/images/type_completion.png b/docs/quickstarts/images/type_completion.png new file mode 100644 index 0000000..d68c670 Binary files /dev/null and b/docs/quickstarts/images/type_completion.png differ diff --git a/docs/quickstarts/index.rst b/docs/quickstarts/index.rst index e3f408f..4ab5d8f 100644 --- a/docs/quickstarts/index.rst +++ b/docs/quickstarts/index.rst @@ -1,2 +1,71 @@ Quick Start ########### + +Diagram +======= + +See the following diagram for a quick overview of the certificate generation process: + +.. graphviz:: + + digraph G { + CertificateType [shape=box, color="black", label="Certificate Type\n\nProvides reusable configuration by storing the:\n- retrieval function\n- generation function\n- custom options"] + CertificateCourseConfiguration [shape=box, color="black", label="Certificate Course Configuration\n\n1. Stores option overrides.\n2.Defines custom schedules for certificate generations."] + RetrievalFunc [shape=ellipse, color="blue", label="retrieval_func\n\nA function that retrieves information\n about learners eligible for the certificate.\nIt defines the criteria for getting a certificate."] + GenerationFunc [shape=ellipse, color="blue", label="generation_func\n\nA function that defines how the certificate\ngeneration process looks like\n(e.g., it creates a PDF file)."] + Certificate [shape=box, color="black", label="Certificate\n\nThe generated certificate."] + + CertificateCourseConfiguration -> RetrievalFunc [label="runs"] + RetrievalFunc -> GenerationFunc [label="sends data to"] + CertificateType -> CertificateCourseConfiguration [label="provides default options"] + GenerationFunc -> Certificate [label="generates"] + } + +Preparations +============ + +1. Go to ``Django admin -> Openedx_Certificates``. +2. Go to the ``External certificate assets`` section and add your certificate template. + You should also add all the assets that are used in the template (images, fonts, etc.). + + .. image:: ./images/assets.png + +3. Create a new certificate type in the ``External certificate types`` section. + Certificate types are reusable and can be used for multiple courses. + Example of creating a certificate type. + + a. To create a certificate of completion, use the ``retrieve_course_completions`` + retrieval function. Ignore the "Custom options" for now. Click the + "Save and continue editing" button instead. You will see the description of all + optional parameters here. + + .. image:: ./images/type_completion.png + + You can add a custom option to specify the minimum completion required to + receive the certificate. For example, if you want to issue a certificate only + to students who achieved a completion of 80% or higher, you can add a custom + option with the name ``required_completion`` and the value ``0.8``. + b. To create a certificate of achievement, use the ``retrieve_subsection_grades``. + The process is similar to the one described above. The customization options + for minimum grade are a bit more complex, so make sure to read the description + of the retrieval function. The generation function options are identical to + the ones for the certificate of completion. + + .. image:: ./images/type_achievement.png + +4. Configure the certificate type for a course in the ``External certificates course + configurations`` section. You can also specify the custom options here to override + the ones specified in the certificate type. For example, you can specify a different + minimum completion for a specific course. Or, you can use a different certificate + template for a specific course. + + .. image:: ./images/course_config.png + +5. Once you press the "Save and continue editing" button, you will see the "Generate + certificates" button. Press it to generate certificates for all students who meet + the requirements. +6. You can also create a scheduled task to generate certificates automatically. + On the course configuration page, you will see the "Associated periodic tasks" + section. Here, you can set a custom schedule for generating certificates. + + .. image:: ./images/course_schedule.png diff --git a/manage.py b/manage.py old mode 100644 new mode 100755 diff --git a/openedx_certificates/__init__.py b/openedx_certificates/__init__.py index dcbbdd2..de41cfc 100644 --- a/openedx_certificates/__init__.py +++ b/openedx_certificates/__init__.py @@ -1,5 +1,3 @@ -""" -A pluggable service for preparing Open edX certificates. -""" +"""A pluggable service for preparing Open edX certificates.""" __version__ = '0.1.0' diff --git a/openedx_certificates/admin.py b/openedx_certificates/admin.py new file mode 100644 index 0000000..7db995c --- /dev/null +++ b/openedx_certificates/admin.py @@ -0,0 +1,254 @@ +"""Admin page configuration for the openedx-certificates app.""" + +from __future__ import annotations + +import importlib +import inspect +from typing import TYPE_CHECKING, Generator + +from django import forms +from django.contrib import admin +from django.core.exceptions import ValidationError +from django.utils.html import format_html +from django_object_actions import DjangoObjectActions, action +from django_reverse_admin import ReverseModelAdmin +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey + +from .models import ( + ExternalCertificate, + ExternalCertificateAsset, + ExternalCertificateCourseConfiguration, + ExternalCertificateType, +) +from .tasks import generate_certificates_for_course_task + +if TYPE_CHECKING: # pragma: no cover + from django.http import HttpRequest + from django_celery_beat.models import IntervalSchedule + + +class DocstringOptionsMixin: + """A mixin to add the docstring of the function to the help text of the function field.""" + + @staticmethod + def _get_docstring_custom_options(func: str) -> str: + """ + Get the docstring of the function and return the "Options:" section. + + :param func: The function to get the docstring for. + :returns: The "Options:" section of the docstring. + """ + try: + docstring = ( + 'Custom options:' + + inspect.getdoc( + getattr( + importlib.import_module(func.rsplit('.', 1)[0]), + func.rsplit('.', 1)[1], + ), + ).split("Options:")[1] + ) + except IndexError: + docstring = ( + 'Custom options are not documented for this function. If you selected a different function, ' + 'you need to save your changes to see an updated docstring.' + ) + # Use pre to preserve the newlines and indentation. + return f'
{docstring}
' + + +class ExternalCertificateTypeAdminForm(forms.ModelForm, DocstringOptionsMixin): + """Generate a list of available functions for the function fields.""" + + retrieval_func = forms.ChoiceField(choices=[]) + generation_func = forms.ChoiceField(choices=[]) + + @staticmethod + def _available_functions(module: str, prefix: str) -> Generator[tuple[str, str], None, None]: + """ + Import a module and return all functions in it that start with a specific prefix. + + :param module: The name of the module to import. + :param prefix: The prefix of the function names to return. + + :return: A tuple containing the functions that start with the prefix in the module. + """ + # TODO: Implement plugin support for the functions. + _module = importlib.import_module(module) + return ( + (f'{obj.__module__}.{name}', f'{obj.__module__}.{name}') + for name, obj in inspect.getmembers(_module, inspect.isfunction) + if name.startswith(prefix) + ) + + def __init__(self, *args, **kwargs): + """Initializes the choices for the retrieval and generation function selection fields.""" + super().__init__(*args, **kwargs) + self.fields['retrieval_func'].choices = self._available_functions( + 'openedx_certificates.processors', + 'retrieve_', + ) + if self.instance.retrieval_func: + self.fields['retrieval_func'].help_text = self._get_docstring_custom_options(self.instance.retrieval_func) + self.fields['generation_func'].choices = self._available_functions( + 'openedx_certificates.generators', + 'generate_', + ) + if self.instance.generation_func: + self.fields['generation_func'].help_text = self._get_docstring_custom_options(self.instance.generation_func) + + class Meta: # noqa: D106 + model = ExternalCertificateType + fields = '__all__' # noqa: DJ007 + + +@admin.register(ExternalCertificateType) +class ExternalCertificateTypeAdmin(admin.ModelAdmin): # noqa: D101 + form = ExternalCertificateTypeAdminForm + list_display = ('name', 'retrieval_func', 'generation_func') + + +@admin.register(ExternalCertificateAsset) +class ExternalCertificateAssetAdmin(admin.ModelAdmin): # noqa: D101 + list_display = ('description', 'asset_slug') + prepopulated_fields = {"asset_slug": ("description",)} # noqa: RUF012 + + +class ExternalCertificateCourseConfigurationForm(forms.ModelForm, DocstringOptionsMixin): # noqa: D101 + class Meta: # noqa: D106 + model = ExternalCertificateCourseConfiguration + fields = ('course_id', 'certificate_type', 'custom_options') + + def __init__(self, *args, **kwargs): + """Initializes the choices for the retrieval and generation function selection fields.""" + super().__init__(*args, **kwargs) + options = '' + + if self.instance and getattr(self.instance, 'certificate_type', None): + if self.instance.certificate_type.generation_func: + generation_options = self._get_docstring_custom_options(self.instance.certificate_type.generation_func) + options += generation_options.replace('Custom options:', '\nGeneration options:') + if self.instance.certificate_type.retrieval_func: + retrieval_options = self._get_docstring_custom_options(self.instance.certificate_type.retrieval_func) + options += retrieval_options.replace('Custom options:', '\nRetrieval options:') + + self.fields['custom_options'].help_text += options + + def clean_course_id(self) -> CourseKey: + """Validate the course_id field.""" + course_id = self.cleaned_data.get('course_id') + try: + CourseKey.from_string(course_id) + except InvalidKeyError as exc: + msg = "Invalid course ID format. The correct format is 'course-v1:{Organization}+{Course}+{Run}'." + raise ValidationError(msg) from exc + return course_id + + +@admin.register(ExternalCertificateCourseConfiguration) +class ExternalCertificateCourseConfigurationAdmin(DjangoObjectActions, ReverseModelAdmin): + """ + Admin page for the course-specific certificate configuration for each certificate type. + + It manages the associations between configuration and its corresponding periodic task. + The reverse inline provides a way to manage the periodic task from the configuration page. + """ + + form = ExternalCertificateCourseConfigurationForm + inline_type = 'stacked' + inline_reverse = [ # noqa: RUF012 + ( + 'periodic_task', + {'fields': ['enabled', 'interval', 'crontab', 'clocked', 'start_time', 'expires', 'one_off']}, + ), + ] + list_display = ('course_id', 'certificate_type', 'enabled', 'interval') + search_fields = ('course_id', 'certificate_type__name') + list_filter = ('course_id', 'certificate_type') + + def get_inline_instances( + self, + request: HttpRequest, + obj: ExternalCertificateCourseConfiguration = None, + ) -> list[admin.ModelAdmin]: + """ + Hide inlines on the "Add" view in Django admin, and show them on the "Change" view. + + It differentiates "add" and change "view" based on the requested path because the `obj` parameter can be `None` + in the "Change" view when rendering the inlines. + + :param request: HttpRequest object + :param obj: The object being changed, None for add view + :return: A list of InlineModelAdmin instances to be rendered for add/changing an object + """ + return super().get_inline_instances(request, obj) if '/add/' not in request.path else [] + + def enabled(self, obj: ExternalCertificateCourseConfiguration) -> bool: + """Return the 'enabled' status of the periodic task.""" + return obj.periodic_task.enabled + + enabled.boolean = True + + # noinspection PyMethodMayBeStatic + def interval(self, obj: ExternalCertificateCourseConfiguration) -> IntervalSchedule: + """Return the interval of the certificate generation task.""" + return obj.periodic_task.interval + + def get_readonly_fields(self, _request: HttpRequest, obj: ExternalCertificateCourseConfiguration = None) -> tuple: + """Make the course_id field read-only.""" + if obj: # editing an existing object + return *self.readonly_fields, 'course_id', 'certificate_type' + return self.readonly_fields + + @action(label="Generate certificates") + def generate_certificates(self, _request: HttpRequest, obj: ExternalCertificateCourseConfiguration): + """ + Custom action to generate certificates for the current ExternalCertificateCourse instance. + + Args: + _request: The request object. + obj: The ExternalCertificateCourse instance. + """ + generate_certificates_for_course_task.delay(obj.id) + + change_actions = ('generate_certificates',) + + +@admin.register(ExternalCertificate) +class ExternalCertificateAdmin(admin.ModelAdmin): # noqa: D101 + list_display = ( + 'user_id', + 'user_full_name', + 'course_id', + 'certificate_type', + 'status', + 'url', + 'created', + 'modified', + ) + readonly_fields = ( + 'user_id', + 'created', + 'modified', + 'user_full_name', + 'course_id', + 'certificate_type', + 'status', + 'url', + 'legacy_id', + 'generation_task_id', + ) + + def get_form(self, request: HttpRequest, obj: ExternalCertificate | None = None, **kwargs) -> forms.ModelForm: + """Hide the download_url field.""" + form = super().get_form(request, obj, **kwargs) + form.base_fields['download_url'].widget = forms.HiddenInput() + return form + + # noinspection PyMethodMayBeStatic + def url(self, obj: ExternalCertificate) -> str: + """Display the download URL as a clickable link.""" + if obj.download_url: + return format_html("{url}", url=obj.download_url) + return "-" diff --git a/openedx_certificates/apps.py b/openedx_certificates/apps.py index da04290..dc65faf 100644 --- a/openedx_certificates/apps.py +++ b/openedx_certificates/apps.py @@ -1,13 +1,23 @@ -""" -openedx_certificates Django application initialization. -""" +"""openedx_certificates Django application initialization.""" + +from __future__ import annotations + +from typing import ClassVar from django.apps import AppConfig class OpenedxCertificatesConfig(AppConfig): - """ - Configuration for the openedx_certificates Django application. - """ + """Configuration for the openedx_certificates Django application.""" name = 'openedx_certificates' + + # https://edx.readthedocs.io/projects/edx-django-utils/en/latest/plugins/how_tos/how_to_create_a_plugin_app.html + plugin_app: ClassVar[dict[str, dict[str, dict]]] = { + 'settings_config': { + 'lms.djangoapp': { + 'common': {'relative_path': 'settings.common'}, + 'production': {'relative_path': 'settings.production'}, + }, + }, + } diff --git a/openedx_certificates/compat.py b/openedx_certificates/compat.py new file mode 100644 index 0000000..58c5620 --- /dev/null +++ b/openedx_certificates/compat.py @@ -0,0 +1,87 @@ +""" +Proxies and compatibility code for edx-platform features. + +This module moderates access to all edx-platform features allowing for cross-version compatibility code. +It also simplifies running tests outside edx-platform's environment by stubbing these functions in unit tests. +""" +from __future__ import annotations + +from contextlib import contextmanager +from datetime import datetime +from typing import TYPE_CHECKING + +import pytz +from celery import Celery +from django.conf import settings + +if TYPE_CHECKING: # pragma: no cover + from django.contrib.auth.models import User + from opaque_keys.edx.keys import CourseKey + + +def get_celery_app() -> Celery: + """Get Celery app to reuse configuration and queues.""" + if getattr(settings, "TESTING", False): + # We can ignore this in the testing environment. + return Celery(task_always_eager=True) + + # noinspection PyUnresolvedReferences,PyPackageRequirements + from lms import CELERY_APP + + return CELERY_APP # pragma: no cover + + +def get_course_grading_policy(course_id: CourseKey) -> dict: + """Get the course grading policy from Open edX.""" + # noinspection PyUnresolvedReferences,PyPackageRequirements + from xmodule.modulestore.django import modulestore + + return modulestore().get_course(course_id).grading_policy["GRADER"] + + +def get_course_name(course_id: CourseKey) -> str: + """Get the course name from Open edX.""" + # noinspection PyUnresolvedReferences,PyPackageRequirements + from openedx.core.djangoapps.content.learning_sequences.api import get_course_outline + + course_outline = get_course_outline(course_id) + return (course_outline and course_outline.title) or str(course_id) + + +def get_course_enrollments(course_id: CourseKey) -> list[User]: + """Get the course enrollments from Open edX.""" + # noinspection PyUnresolvedReferences,PyPackageRequirements + from common.djangoapps.student.models import CourseEnrollment + + enrollments = CourseEnrollment.objects.filter(course_id=course_id, is_active=True).select_related('user') + return [enrollment.user for enrollment in enrollments] + + +@contextmanager +def prefetch_course_grades(course_id: CourseKey, users: list[User]): + """Prefetch the course grades from Open edX.""" + # noinspection PyUnresolvedReferences,PyPackageRequirements + from lms.djangoapps.grades.api import clear_prefetched_course_grades, prefetch_course_grades + + prefetch_course_grades(course_id, users) + try: + yield + finally: + clear_prefetched_course_grades(course_id) + + +def get_course_grade_factory(): # noqa: ANN201 + """Get the course grade factory from Open edX.""" + # noinspection PyUnresolvedReferences,PyPackageRequirements + from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory + + return CourseGradeFactory() + + +def get_localized_certificate_date() -> str: + """Get the localized date from Open edX.""" + # noinspection PyUnresolvedReferences,PyPackageRequirements + from common.djangoapps.util.date_utils import strftime_localized + + date = datetime.now(pytz.timezone(settings.TIME_ZONE)) + return strftime_localized(date, settings.CERTIFICATE_DATE_FORMAT) diff --git a/openedx_certificates/exceptions.py b/openedx_certificates/exceptions.py new file mode 100644 index 0000000..95effe9 --- /dev/null +++ b/openedx_certificates/exceptions.py @@ -0,0 +1,9 @@ +"""Custom exceptions for the openedx-certificates app.""" + + +class AssetNotFoundError(Exception): + """Raised when the asset_slug is not found in the ExternalCertificateAsset model.""" + + +class CertificateGenerationError(Exception): + """Raised when the certificate generation Celery task fails.""" diff --git a/openedx_certificates/generators.py b/openedx_certificates/generators.py new file mode 100644 index 0000000..28ec3fb --- /dev/null +++ b/openedx_certificates/generators.py @@ -0,0 +1,219 @@ +""" +This module provides functions to generate certificates. + +The functions prefixed with `generate_` are automatically detected by the admin page and are used to generate the +certificates for the users. + +We will move this module to an external repository (a plugin). +""" + +from __future__ import annotations + +import io +import logging +import secrets +from typing import TYPE_CHECKING, Any + +from django.conf import settings +from django.core.files.base import ContentFile +from django.core.files.storage import FileSystemStorage, default_storage +from pypdf import PdfReader, PdfWriter +from pypdf.constants import UserAccessPermissions +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +from reportlab.pdfgen import canvas + +from openedx_certificates.compat import get_course_name, get_localized_certificate_date +from openedx_certificates.models import ExternalCertificateAsset + +log = logging.getLogger(__name__) + +if TYPE_CHECKING: # pragma: no cover + from uuid import UUID + + from django.contrib.auth.models import User + from opaque_keys.edx.keys import CourseKey + + +def _get_user_name(user: User) -> str: + """ + Retrieve the user's name. + + :param user: The user to generate the certificate for. + :return: Username. + """ + return user.profile.name or f"{user.first_name} {user.last_name}" + + +def _register_font(options: dict[str, Any]) -> str: + """ + Register a custom font if specified in options. If not specified, use the default font (Helvetica). + + :param options: A dictionary containing the font. + :returns: The font name. + """ + if font := options.get('font'): + pdfmetrics.registerFont(TTFont(font, ExternalCertificateAsset.get_asset_by_slug(font))) + + return font or 'Helvetica' + + +def _write_text_on_template(template: any, font: str, username: str, course_name: str, options: dict[str, Any]) -> any: + """ + Prepare a new canvas and write the user and course name onto it. + + :param template: Pdf template. + :param font: Font name. + :param username: The name of the user to generate the certificate for. + :param course_name: The name of the course the learner completed. + :param options: A dictionary documented in the `generate_pdf_certificate` function. + :returns: A canvas with written data. + """ + + def hex_to_rgb(hex_color: str) -> tuple[float, float, float]: + """ + Convert a hexadecimal color code to an RGB tuple with floating-point values. + + :param hex_color: A hexadecimal color string, which can start with '#' and be either 3 or 6 characters long. + :returns: A tuple representing the RGB color as (red, green, blue), with each value ranging from 0.0 to 1.0. + """ + hex_color = hex_color.lstrip('#') + # Expand shorthand form (e.g. "158" to "115588") + if len(hex_color) == 3: + hex_color = ''.join([c * 2 for c in hex_color]) + + # noinspection PyTypeChecker + return tuple(int(hex_color[i : i + 2], 16) / 255 for i in range(0, 6, 2)) + + template_width, template_height = template.mediabox[2:] + pdf_canvas = canvas.Canvas(io.BytesIO(), pagesize=(template_width, template_height)) + + # Write the learner name. + pdf_canvas.setFont(font, 32) + name_color = options.get('name_color', '#000') + pdf_canvas.setFillColorRGB(*hex_to_rgb(name_color)) + + name_x = (template_width - pdf_canvas.stringWidth(username)) / 2 + name_y = options.get('name_y', 290) + pdf_canvas.drawString(name_x, name_y, username) + + # Write the course name. + pdf_canvas.setFont(font, 28) + course_name_color = options.get('course_name_color', '#000') + pdf_canvas.setFillColorRGB(*hex_to_rgb(course_name_color)) + + course_name_y = options.get('course_name_y', 220) + course_name_line_height = 28 * 1.1 + + # Split the course name into lines and write each of them in the center of the template. + for line_number, line in enumerate(course_name.split('\n')): + line_x = (template_width - pdf_canvas.stringWidth(line)) / 2 + line_y = course_name_y - (line_number * course_name_line_height) + pdf_canvas.drawString(line_x, line_y, line) + + # Write the issue date. + issue_date = get_localized_certificate_date() + pdf_canvas.setFont(font, 12) + issue_date_color = options.get('issue_date_color', '#000') + pdf_canvas.setFillColorRGB(*hex_to_rgb(issue_date_color)) + + issue_date_x = (template_width - pdf_canvas.stringWidth(issue_date)) / 2 + issue_date_y = options.get('issue_date_y', 120) + pdf_canvas.drawString(issue_date_x, issue_date_y, issue_date) + + return pdf_canvas + + +def _save_certificate(certificate: PdfWriter, certificate_uuid: UUID) -> str: + """ + Save the final PDF file to BytesIO and upload it using Django default storage. + + :param certificate: Pdf certificate. + :param certificate_uuid: The UUID of the certificate. + :returns: The URL of the saved certificate. + """ + # Save the final PDF file to BytesIO. + output_path = f'external_certificates/{certificate_uuid}.pdf' + + view_print_extract_permission = ( + UserAccessPermissions.PRINT + | UserAccessPermissions.PRINT_TO_REPRESENTATION + | UserAccessPermissions.EXTRACT_TEXT_AND_GRAPHICS + ) + certificate.encrypt('', secrets.token_hex(32), permissions_flag=view_print_extract_permission, algorithm='AES-256') + + pdf_bytes = io.BytesIO() + certificate.write(pdf_bytes) + pdf_bytes.seek(0) # Rewind to start. + # Upload with Django default storage. + certificate_file = ContentFile(pdf_bytes.read()) + # Delete the file if it already exists. + if default_storage.exists(output_path): + default_storage.delete(output_path) + default_storage.save(output_path, certificate_file) + if isinstance(default_storage, FileSystemStorage): + url = f"{settings.LMS_ROOT_URL}{settings.MEDIA_URL}{output_path}" + else: + url = default_storage.url(output_path) + + if custom_domain := getattr(settings, 'CERTIFICATES_CUSTOM_DOMAIN', None): + url = f"{custom_domain}/{certificate_uuid}.pdf" + + return url + + +def generate_pdf_certificate(course_id: CourseKey, user: User, certificate_uuid: UUID, options: dict[str, Any]) -> str: + """ + Generate a PDF certificate. + + :param course_id: The ID of the course the learner completed. + :param user: The user to generate the certificate for. + :param certificate_uuid: The UUID of the certificate to generate. + :param options: The custom options for the certificate. + :returns: The URL of the saved certificate. + + Options: + - template: The path to the PDF template file. + - template_two_lines: The path to the PDF template file for two-line course names. + A two-line course name is specified by using a semicolon as a separator. + - font: The name of the font to use. + - name_y: The Y coordinate of the name on the certificate (vertical position on the template). + - name_color: The color of the name on the certificate (hexadecimal color code). + - course_name: Specify the course name to use instead of the course Display Name retrieved from Open edX. + - course_name_y: The Y coordinate of the course name on the certificate (vertical position on the template). + - course_name_color: The color of the course name on the certificate (hexadecimal color code). + - issue_date_y: The Y coordinate of the issue date on the certificate (vertical position on the template). + - issue_date_color: The color of the issue date on the certificate (hexadecimal color code). + """ + log.info("Starting certificate generation for user %s", user.id) + + username = _get_user_name(user) + course_name = options.get('course_name') or get_course_name(course_id) + + # Get template from the ExternalCertificateAsset. + # HACK: We support two-line strings by using a semicolon as a separator. + if ';' in course_name and (template_path := options.get('template_two_lines')): + template_file = ExternalCertificateAsset.get_asset_by_slug(template_path) + course_name = course_name.replace(';', '\n') + else: + template_file = ExternalCertificateAsset.get_asset_by_slug(options['template']) + + font = _register_font(options) + + # Load the PDF template. + with template_file.open('rb') as template_file: + template = PdfReader(template_file).pages[0] + + certificate = PdfWriter() + + # Create a new canvas, prepare the page and write the data + pdf_canvas = _write_text_on_template(template, font, username, course_name, options) + + overlay_pdf = PdfReader(io.BytesIO(pdf_canvas.getpdfdata())) + template.merge_page(overlay_pdf.pages[0]) + certificate.add_page(template) + + url = _save_certificate(certificate, certificate_uuid) + + log.info("Certificate saved to %s", url) + return url diff --git a/openedx_certificates/migrations/0001_initial.py b/openedx_certificates/migrations/0001_initial.py new file mode 100644 index 0000000..4193fcb --- /dev/null +++ b/openedx_certificates/migrations/0001_initial.py @@ -0,0 +1,85 @@ +# Generated by Django 3.2.23 on 2023-11-14 15:54 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import jsonfield.fields +import model_utils.fields +import opaque_keys.edx.django.models +import openedx_certificates.models +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('django_celery_beat', '0018_improve_crontab_helptext'), + ] + + operations = [ + migrations.CreateModel( + name='ExternalCertificateAsset', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('description', models.CharField(blank=True, help_text='Description of the asset.', max_length=255)), + ('asset', models.FileField(help_text='Asset file. It could be a PDF template, image or font file.', max_length=255, upload_to=openedx_certificates.models.ExternalCertificateAsset.template_assets_path)), + ('asset_slug', models.SlugField(help_text="Asset's unique slug. We can reference the asset in templates using this value.", max_length=255, unique=True)), + ], + options={ + 'get_latest_by': 'created', + }, + ), + migrations.CreateModel( + name='ExternalCertificateType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('name', models.CharField(help_text='Name of the certificate type.', max_length=255, unique=True)), + ('retrieval_func', models.CharField(help_text='A name of the function to retrieve eligible users.', max_length=200)), + ('generation_func', models.CharField(help_text='A name of the function to generate certificates.', max_length=200)), + ('custom_options', jsonfield.fields.JSONField(blank=True, default=dict, help_text='Custom options for the functions.')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='ExternalCertificate', + fields=[ + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Auto-generated UUID of the certificate', primary_key=True, serialize=False)), + ('user_id', models.IntegerField(help_text='ID of the user receiving the certificate')), + ('user_full_name', models.CharField(help_text='User receiving the certificate', max_length=255)), + ('course_id', opaque_keys.edx.django.models.CourseKeyField(help_text='ID of a course for which the certificate was issued', max_length=255)), + ('certificate_type', models.CharField(help_text='Type of the certificate', max_length=255)), + ('status', models.CharField(choices=[('generating', 'Generating'), ('available', 'Available'), ('error', 'Error'), ('invalidated', 'Invalidated')], default='generating', help_text='Status of the certificate generation task', max_length=32)), + ('download_url', models.URLField(blank=True, help_text='URL of the generated certificate PDF (e.g., to S3)')), + ('legacy_id', models.IntegerField(help_text='Legacy ID of the certificate imported from another system', null=True)), + ('generation_task_id', models.CharField(help_text='Task ID from the Celery queue', max_length=255)), + ], + options={ + 'unique_together': {('user_id', 'course_id', 'certificate_type')}, + }, + ), + migrations.CreateModel( + name='ExternalCertificateCourseConfiguration', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('course_id', opaque_keys.edx.django.models.CourseKeyField(help_text='The ID of the course.', max_length=255)), + ('custom_options', jsonfield.fields.JSONField(blank=True, default=dict, help_text='Custom options for the functions. If specified, they are merged with the options defined in the certificate type.')), + ('certificate_type', models.ForeignKey(help_text='Associated certificate type.', on_delete=django.db.models.deletion.CASCADE, to='openedx_certificates.externalcertificatetype')), + ('periodic_task', models.OneToOneField(help_text='Associated periodic task.', on_delete=django.db.models.deletion.CASCADE, to='django_celery_beat.periodictask')), + ], + options={ + 'unique_together': {('course_id', 'certificate_type')}, + }, + ), + ] diff --git a/openedx_certificates/migrations/__init__.py b/openedx_certificates/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/openedx_certificates/models.py b/openedx_certificates/models.py index 2d34161..775092d 100644 --- a/openedx_certificates/models.py +++ b/openedx_certificates/models.py @@ -1,5 +1,382 @@ -""" -Database models for openedx_certificates. -""" -# from django.db import models -# from model_utils.models import TimeStampedModel +"""Database models for openedx_certificates.""" +from __future__ import annotations + +import json +import logging +import uuid +from importlib import import_module +from pathlib import Path +from typing import TYPE_CHECKING + +import jsonfield +from django.conf import settings +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models.signals import post_delete +from django.dispatch import receiver +from django.utils.translation import gettext_lazy as _ +from django_celery_beat.models import IntervalSchedule, PeriodicTask +from edx_ace import Message, Recipient, ace +from model_utils.models import TimeStampedModel +from opaque_keys.edx.django.models import CourseKeyField + +from openedx_certificates.compat import get_course_name +from openedx_certificates.exceptions import AssetNotFoundError, CertificateGenerationError + +if TYPE_CHECKING: # pragma: no cover + from django.core.files import File + from django.db.models import QuerySet + + +log = logging.getLogger(__name__) + + +class ExternalCertificateType(TimeStampedModel): + """ + Model to store global certificate configurations for each type. + + .. no_pii: + """ + + name = models.CharField(max_length=255, unique=True, help_text=_('Name of the certificate type.')) + retrieval_func = models.CharField(max_length=200, help_text=_('A name of the function to retrieve eligible users.')) + generation_func = models.CharField(max_length=200, help_text=_('A name of the function to generate certificates.')) + custom_options = jsonfield.JSONField(default=dict, blank=True, help_text=_('Custom options for the functions.')) + + # TODO: Document how to add custom functions to the certificate generation pipeline. + + def __str__(self): + """Get a string representation of this model's instance.""" + return self.name + + def clean(self): + """Ensure that the `retrieval_func` and `generation_func` exist.""" + for func_field in ['retrieval_func', 'generation_func']: + func_path = getattr(self, func_field) + try: + module_path, func_name = func_path.rsplit('.', 1) + module = import_module(module_path) + getattr(module, func_name) # Will raise AttributeError if the function does not exist. + except ValueError as exc: + raise ValidationError({func_field: "Function path must be in format 'module.function_name'."}) from exc + except (ImportError, AttributeError) as exc: + raise ValidationError( + {func_field: f"The function {func_path} could not be found. Please provide a valid path."}, + ) from exc + + +class ExternalCertificateCourseConfiguration(TimeStampedModel): + """ + Model to store course-specific certificate configurations for each certificate type. + + .. no_pii: + """ + + course_id = CourseKeyField(max_length=255, help_text=_('The ID of the course.')) + certificate_type = models.ForeignKey( + ExternalCertificateType, + on_delete=models.CASCADE, + help_text=_('Associated certificate type.'), + ) + periodic_task = models.OneToOneField( + PeriodicTask, + on_delete=models.CASCADE, + help_text=_('Associated periodic task.'), + ) + custom_options = jsonfield.JSONField( + default=dict, + blank=True, + help_text=_( + 'Custom options for the functions. If specified, they are merged with the options defined in the ' + 'certificate type.', + ), + ) + + class Meta: # noqa: D106 + unique_together = (('course_id', 'certificate_type'),) + + def __str__(self): # noqa: D105 + return f'{self.certificate_type.name} in {self.course_id}' + + def save(self, *args, **kwargs): + """Create a new PeriodicTask every time a new ExternalCertificateCourseConfiguration is created.""" + from openedx_certificates.tasks import generate_certificates_for_course_task as task # Avoid circular imports. + + # Use __wrapped__ to get the original function, as the task is wrapped by the @app.task decorator. + task_path = f"{task.__wrapped__.__module__}.{task.__wrapped__.__name__}" + + if self._state.adding: + schedule, created = IntervalSchedule.objects.get_or_create(every=10, period=IntervalSchedule.DAYS) + self.periodic_task = PeriodicTask.objects.create( + enabled=False, + interval=schedule, + name=f'{self.certificate_type} in {self.course_id}', + task=task_path, + ) + + super().save(*args, **kwargs) + + # Update the task on each save to prevent it from getting out of sync (e.g., after changing a task definition). + self.periodic_task.task = task_path + # Update the args of the PeriodicTask to include the ID of the ExternalCertificateCourseConfiguration. + self.periodic_task.args = json.dumps([self.id]) + self.periodic_task.save() + + # Replace the return type with `QuerySet[Self]` after migrating to Python 3.10+. + @classmethod + def get_enabled_configurations(cls) -> QuerySet[ExternalCertificateCourseConfiguration]: + """ + Get the list of enabled configurations. + + :return: A list of ExternalCertificateCourseConfiguration objects. + """ + return ExternalCertificateCourseConfiguration.objects.filter(periodic_task__enabled=True) + + def generate_certificates(self): + """This method allows manual certificate generation from the Django admin.""" + user_ids = self.get_eligible_user_ids() + log.info("The following users are eligible in %s: %s", self.course_id, user_ids) + filtered_user_ids = self.filter_out_user_ids_with_certificates(user_ids) + log.info("The filtered users eligible in %s: %s", self.course_id, filtered_user_ids) + for user_id in filtered_user_ids: + self.generate_certificate_for_user(user_id) + + def filter_out_user_ids_with_certificates(self, user_ids: list[int]) -> list[int]: + """ + Filter out user IDs that already have a certificate for this course and certificate type. + + :param user_ids: A list of user IDs to filter. + :return: A list of user IDs that either: + 1. Do not have a certificate for this course and certificate type. + 2. Have such a certificate with an error status. + """ + users_ids_with_certificates = ExternalCertificate.objects.filter( + models.Q(course_id=self.course_id), + models.Q(certificate_type=self.certificate_type), + ~(models.Q(status=ExternalCertificate.Status.ERROR)), + ).values_list('user_id', flat=True) + + filtered_user_ids_set = set(user_ids) - set(users_ids_with_certificates) + return list(filtered_user_ids_set) + + def get_eligible_user_ids(self) -> list[int]: + """ + Get the list of eligible learners for the given course. + + :return: A list of user IDs. + """ + func_path = self.certificate_type.retrieval_func + module_path, func_name = func_path.rsplit('.', 1) + module = import_module(module_path) + func = getattr(module, func_name) + + custom_options = {**self.certificate_type.custom_options, **self.custom_options} + return func(self.course_id, custom_options) + + def generate_certificate_for_user(self, user_id: int, celery_task_id: int = 0): + """ + Celery task for processing a single user's certificate. + + This function retrieves an ExternalCertificateCourse object based on course_id and certificate_type_id, + retrieves the data using the retrieval_func specified in the associated ExternalCertificateType object, + and passes this data to the function specified in the generation_func field. + + Args: + user_id: The ID of the user to process the certificate for. + celery_task_id (optional): The ID of the Celery task that is running this function. + """ + user = get_user_model().objects.get(id=user_id) + # Use the name from the profile if it is not empty. Otherwise, use the first and last name. + # We check if the profile exists because it is absent in unit tests. + user_full_name = getattr(getattr(user, 'profile', None), 'name', f"{user.first_name} {user.last_name}") + custom_options = {**self.certificate_type.custom_options, **self.custom_options} + + try: + certificate = ExternalCertificate.objects.get( + user_id=user_id, + course_id=self.course_id, + certificate_type=self.certificate_type.name, + ) + certificate.user_full_name = user_full_name + certificate.status = ExternalCertificate.Status.GENERATING + certificate.generation_task_id = celery_task_id + certificate.save() + except ExternalCertificate.DoesNotExist: + certificate = ExternalCertificate.objects.create( + user_id=user_id, + user_full_name=user_full_name, + course_id=self.course_id, + certificate_type=self.certificate_type.name, + status=ExternalCertificate.Status.GENERATING, + generation_task_id=celery_task_id, + ) + + try: + generation_module_name, generation_func_name = self.certificate_type.generation_func.rsplit('.', 1) + generation_module = import_module(generation_module_name) + generation_func = getattr(generation_module, generation_func_name) + + # Run the functions. We do not validate them here, as they are validated in the model's clean() method. + certificate.download_url = generation_func(self.course_id, user, certificate.uuid, custom_options) + certificate.status = ExternalCertificate.Status.AVAILABLE + certificate.save() + except Exception as exc: # noqa: BLE001 + certificate.status = ExternalCertificate.Status.ERROR + certificate.save() + msg = f'Failed to generate the {certificate.uuid=} for {user_id=} with {self.id=}.' + raise CertificateGenerationError(msg) from exc + else: + # TODO: In the future, we want to check this before generating the certificate. + # Perhaps we could even include this in a processor to optimize it. + if user.is_active and user.has_usable_password(): + certificate.send_email() + + +# noinspection PyUnusedLocal +@receiver(post_delete, sender=ExternalCertificateCourseConfiguration) +def post_delete_periodic_task(sender, instance, *_args, **_kwargs): # noqa: ANN001, ARG001 + """Delete the associated periodic task when the object is deleted.""" + if instance.periodic_task: + instance.periodic_task.delete() + + +class ExternalCertificate(TimeStampedModel): + """ + Model to represent each individual certificate awarded to a user for a course. + + This model contains information about the related course, the user who earned the certificate, + the download URL for the certificate PDF, and the associated certificate generation task. + + .. note:: The ID field is not a conventional auto-incrementing integer, but a value + that allows for old certificates with custom IDs. + + .. pii: The User's name is stored in this model. + .. pii_types: id, name + .. pii_retirement: retained + """ + + class Status(models.TextChoices): + """Status of the certificate generation task.""" + + GENERATING = 'generating', _('Generating') + AVAILABLE = 'available', _('Available') + ERROR = 'error', _('Error') + INVALIDATED = 'invalidated', _('Invalidated') + + uuid = models.UUIDField( + primary_key=True, + default=uuid.uuid4, + editable=False, + help_text=_('Auto-generated UUID of the certificate'), + ) + user_id = models.IntegerField(help_text=_('ID of the user receiving the certificate')) + user_full_name = models.CharField(max_length=255, help_text=_('User receiving the certificate')) + course_id = CourseKeyField(max_length=255, help_text=_('ID of a course for which the certificate was issued')) + certificate_type = models.CharField(max_length=255, help_text=_('Type of the certificate')) + status = models.CharField( + max_length=32, + choices=Status.choices, + default=Status.GENERATING, + help_text=_('Status of the certificate generation task'), + ) + download_url = models.URLField(blank=True, help_text=_('URL of the generated certificate PDF (e.g., to S3)')) + legacy_id = models.IntegerField(null=True, help_text=_('Legacy ID of the certificate imported from another system')) + generation_task_id = models.CharField(max_length=255, help_text=_('Task ID from the Celery queue')) + + class Meta: # noqa: D106 + unique_together = (('user_id', 'course_id', 'certificate_type'),) + + def __str__(self): # noqa: D105 + return f"{self.certificate_type} for {self.user_full_name} in {self.course_id}" + + def send_email(self): + """Send a certificate link to the student.""" + course_name = get_course_name(self.course_id) + user = get_user_model().objects.get(id=self.user_id) + msg = Message( + name="certificate_generated", + app_label="openedx_certificates", + recipient=Recipient(lms_user_id=user.id, email_address=user.email), + language='en', + context={ + 'certificate_link': self.download_url, + 'course_name': course_name, + 'platform_name': settings.PLATFORM_NAME, + }, + ) + ace.send(msg) + + +class ExternalCertificateAsset(TimeStampedModel): + """ + A set of assets to be used in custom certificate templates. + + This model stores assets used during certificate generation process, such as PDF templates, images, fonts. + + .. no_pii: + """ + + def template_assets_path(self, filename: str) -> str: + """ + Delete the file if it already exists and returns the certificate template asset file path. + + :param filename: File to upload. + :return path: Path of asset file e.g. `certificate_template_assets/1/filename`. + """ + name = Path('external_certificate_template_assets') / str(self.id) / filename + fullname = Path(settings.MEDIA_ROOT) / name + if fullname.exists(): + fullname.unlink() + return str(name) + + description = models.CharField( + max_length=255, + null=False, + blank=True, + help_text=_('Description of the asset.'), + ) + asset = models.FileField( + max_length=255, + upload_to=template_assets_path, + help_text=_('Asset file. It could be a PDF template, image or font file.'), + ) + asset_slug = models.SlugField( + max_length=255, + unique=True, + null=False, + help_text=_('Asset\'s unique slug. We can reference the asset in templates using this value.'), + ) + + class Meta: # noqa: D106 + get_latest_by = 'created' + + def __str__(self): # noqa: D105 + return f'{self.asset.url}' + + def save(self, *args, **kwargs): + """If the object is being created, save the asset first, then save the object.""" + if self._state.adding: + asset_image = self.asset + self.asset = None + super().save(*args, **kwargs) + self.asset = asset_image + + super().save(*args, **kwargs) + + @classmethod + def get_asset_by_slug(cls, asset_slug: str) -> File: + """ + Fetch a certificate template asset by its slug from the database. + + :param asset_slug: The slug of the asset to be retrieved. + :returns: The file associated with the asset slug. + :raises AssetNotFound: If no asset exists with the provided slug in the ExternalCertificateAsset database model. + """ + try: + template_asset = cls.objects.get(asset_slug=asset_slug) + asset = template_asset.asset + except cls.DoesNotExist as exc: + msg = f'Asset with slug {asset_slug} does not exist.' + raise AssetNotFoundError(msg) from exc + return asset diff --git a/openedx_certificates/processors.py b/openedx_certificates/processors.py new file mode 100644 index 0000000..d080f00 --- /dev/null +++ b/openedx_certificates/processors.py @@ -0,0 +1,224 @@ +""" +This module contains processors for certificate criteria. + +The functions prefixed with `retrieve_` are automatically detected by the admin page and are used to retrieve the +IDs of the users that meet the criteria for the certificate type. + +We will move this module to an external repository (a plugin). +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from completion_aggregator.api.v1.views import CompletionDetailView +from django.contrib.auth import get_user_model +from rest_framework.request import Request +from rest_framework.test import APIRequestFactory + +from openedx_certificates.compat import ( + get_course_enrollments, + get_course_grade_factory, + get_course_grading_policy, + prefetch_course_grades, +) + +if TYPE_CHECKING: # pragma: no cover + from django.contrib.auth.models import User + from opaque_keys.edx.keys import CourseKey + from rest_framework.views import APIView + + +log = logging.getLogger(__name__) + + +def _get_category_weights(course_id: CourseKey) -> dict[str, float]: + """ + Retrieve the course grading policy and return the weight of each category. + + :param course_id: The course ID to get the grading policy for. + :returns: A dictionary with the weight of each category. + """ + log.debug('Getting the course grading policy.') + grading_policy = get_course_grading_policy(course_id) + log.debug('Finished getting the course grading policy.') + + # Calculate the total weight of the non-exam categories + log.debug(grading_policy) + + category_weight_ratios = {category['type'].lower(): category['weight'] for category in grading_policy} + + log.debug(category_weight_ratios) + return category_weight_ratios + + +def _get_grades_by_format(course_id: CourseKey, users: list[User]) -> dict[int, dict[str, int]]: + """ + Get the grades for each user, categorized by assignment types. + + :param course_id: The course ID. + :param users: The users to get the grades for. + :returns: A dictionary with the grades for each user, categorized by assignment types. + """ + log.debug('Getting the grades for each user.') + + grades = {} + + with prefetch_course_grades(course_id, users): + course_grade_factory = get_course_grade_factory() + + for user in users: + grades[user.id] = {} + course_grade = course_grade_factory.read(user, course_key=course_id) + for assignment_type, subsections in course_grade.graded_subsections_by_format().items(): + assignment_earned = 0 + assignment_possible = 0 + log.debug(subsections) + for subsection in subsections.values(): + assignment_earned += subsection.graded_total.earned + assignment_possible += subsection.graded_total.possible + grade = (assignment_earned / assignment_possible) * 100 if assignment_possible > 0 else 0 + grades[user.id][assignment_type.lower()] = grade + + log.debug('Finished getting the grades for each user.') + return grades + + +def _are_grades_passing_criteria( + user_grades: dict[str, float], + required_grades: dict[str, float], + category_weights: dict[str, float], +) -> bool: + """ + Determine whether the user received passing grades in all required categories. + + :param user_grades: The grades of the user, divided by category. + :param required_grades: The required grades for each category. + :param category_weights: The weight of each category. + :returns: Whether the user received passing grades in all required categories. + :raises ValueError: If a category weight is not found. + """ + # If user does not have a grade for a category (except for the "total" category), it means that they did not + # attempt it. Therefore, they should not be eligible for the certificate. + if not all(category in user_grades for category in required_grades if category != 'total'): + return False + + total_score = 0 + for category, score in user_grades.items(): + if score < required_grades.get(category, 0): + return False + + if category not in category_weights: + msg = "Category weight '%s' was not found in the course grading policy." + raise ValueError(msg, category) + total_score += score * category_weights[category] + + return total_score >= required_grades.get('total', 0) + + +def retrieve_subsection_grades(course_id: CourseKey, options: dict[str, Any]) -> list[int]: + """ + Retrieve the users that have passing grades in all required categories. + + :param course_id: The course ID. + :param options: The custom options for the certificate. + + Options: + - required_grades: A dictionary of required grades for each category, where the keys are the category names and + the values are the minimum required grades. The grades are percentages, so they should be in the range [0, 1]. + See the following example:: + + { + "required_grades": { + "Homework": 0.4, + "Exam": 0.9, + "Total": 0.8 + } + } + + It means that the user must receive at least 40% in the Homework category and 90% in the Exam category. + The "Total" key is a special value used to specify the minimum required grade for all categories in the course. + Let's assume that we have the following grading policy (the percentages are the weights of each category): + 1. Homework: 20% + 2. Lab: 10% + 3. Exam: 70% + The grades for the Total category will be calculated as follows: + total_grade = (homework_grade * 0.2) + (lab_grade * 0.1) + (exam_grade * 0.7) + """ + required_grades: dict[str, int] = options['required_grades'] + required_grades = {key.lower(): value * 100 for key, value in required_grades.items()} + + users = get_course_enrollments(course_id) + grades = _get_grades_by_format(course_id, users) + log.debug(grades) + weights = _get_category_weights(course_id) + + eligible_users = [] + for user_id, user_grades in grades.items(): + if _are_grades_passing_criteria(user_grades, required_grades, weights): + eligible_users.append(user_id) + + return eligible_users + + +def _prepare_request_to_completion_aggregator(course_id: CourseKey, query_params: dict, url: str) -> APIView: + """ + Prepare a request to the Completion Aggregator API. + + :param course_id: The course ID. + :param query_params: The query parameters to use in the request. + :param url: The URL to use in the request. + :returns: The view with the prepared request. + """ + log.debug('Preparing the request for retrieving the completion.') + + # The URL does not matter, as we do not retrieve any data from the path. + django_request = APIRequestFactory().get(url, query_params) + django_request.course_id = course_id + drf_request = Request(django_request) # convert django.core.handlers.wsgi.WSGIRequest to DRF request + + view = CompletionDetailView() + view.request = drf_request + + # HACK: Bypass the API permissions. + staff_user = get_user_model().objects.filter(is_staff=True).first() + view._effective_user = staff_user # noqa: SLF001 + + log.debug('Finished preparing the request for retrieving the completion.') + return view + + +def retrieve_course_completions(course_id: CourseKey, options: dict[str, Any]) -> list[int]: + """ + Retrieve the course completions for all users through the Completion Aggregator API. + + Options: + - required_completion: The minimum required completion percentage. The default value is 0.9. + """ + # If it turns out to be too slow, we can: + # 1. Modify the Completion Aggregator to emit a signal/event when a user achieves a certain completion threshold. + # 2. Get this data from the `Aggregator` model. Filter by `aggregation name == 'course'`, `course_key`, `percent`. + + required_completion = options.get('required_completion', 0.9) + + url = f'/completion-aggregator/v1/course/{course_id}/' + query_params = {'page_size': 1000, 'page': 1} + + # TODO: Extract the logic of this view into an API. The current approach is very hacky. + view = _prepare_request_to_completion_aggregator(course_id, query_params.copy(), url) + completions = [] + + while True: + # noinspection PyUnresolvedReferences + response = view.get(view.request, str(course_id)) + log.debug(response.data) + completions.extend( + res['username'] for res in response.data['results'] if res['completion']['percent'] >= required_completion + ) + if not response.data['pagination']['next']: + break + query_params['page'] += 1 + view = _prepare_request_to_completion_aggregator(course_id, query_params.copy(), url) + + return list(get_user_model().objects.filter(username__in=completions).values_list('id', flat=True)) diff --git a/openedx_certificates/settings/__init__.py b/openedx_certificates/settings/__init__.py new file mode 100644 index 0000000..355c51b --- /dev/null +++ b/openedx_certificates/settings/__init__.py @@ -0,0 +1 @@ +"""App-specific settings.""" diff --git a/openedx_certificates/settings/common.py b/openedx_certificates/settings/common.py new file mode 100644 index 0000000..ec7424f --- /dev/null +++ b/openedx_certificates/settings/common.py @@ -0,0 +1,8 @@ +"""App-specific settings for all environments.""" +from django.conf import Settings + + +def plugin_settings(settings: Settings): + """Add `django_celery_beat` to `INSTALLED_APPS`.""" + if 'django_celery_beat' not in settings.INSTALLED_APPS: + settings.INSTALLED_APPS += ('django_celery_beat',) diff --git a/openedx_certificates/settings/production.py b/openedx_certificates/settings/production.py new file mode 100644 index 0000000..430e6cf --- /dev/null +++ b/openedx_certificates/settings/production.py @@ -0,0 +1,12 @@ +"""App-specific settings for production environments.""" +from django.conf import Settings + + +def plugin_settings(settings: Settings): + """ + Use the database scheduler for Celery Beat. + + The default scheduler is celery.beat.PersistentScheduler, which stores the schedule in a local file. It does not + work in a multi-server environment, so we use the database scheduler instead. + """ + settings.CELERYBEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler' diff --git a/openedx_certificates/tasks.py b/openedx_certificates/tasks.py new file mode 100644 index 0000000..e3a5565 --- /dev/null +++ b/openedx_certificates/tasks.py @@ -0,0 +1,61 @@ +"""Asynchronous Celery tasks.""" + +from __future__ import annotations + +import logging + +from openedx_certificates.compat import get_celery_app +from openedx_certificates.models import ExternalCertificateCourseConfiguration + +app = get_celery_app() +log = logging.getLogger(__name__) + + +@app.task +def generate_certificate_for_user_task(course_config_id: int, user_id: int): + """ + Celery task for processing a single user's certificate. + + This function retrieves an ExternalCertificateCourse object based on course_id and certificate_type_id, + retrieves the data using the retrieval_func specified in the associated ExternalCertificateType object, + and passes this data to the function specified in the generation_func field. + + :param course_config_id: The ID of the ExternalCertificateCourseConfiguration object to process. + :param user_id: The ID of the user to process the certificate for. + """ + course_config = ExternalCertificateCourseConfiguration.objects.get(id=course_config_id) + course_config.generate_certificate_for_user(user_id, generate_certificate_for_user_task.request.id) + + +@app.task +def generate_certificates_for_course_task(course_config_id: int): + """ + Celery task for processing a single course's certificates. + + This function retrieves an ExternalCertificateCourse object based on course_id and certificate_type_id, + retrieves the data using the retrieval_func specified in the associated ExternalCertificateType object, + and passes this data to the function specified in the generation_func field. + + :param course_config_id: The ID of the ExternalCertificateCourseConfiguration object to process. + """ + course_config = ExternalCertificateCourseConfiguration.objects.get(id=course_config_id) + user_ids = course_config.get_eligible_user_ids() + log.info("The following users are eligible in %s: %s", course_config.course_id, user_ids) + filtered_user_ids = course_config.filter_out_user_ids_with_certificates(user_ids) + log.info("The filtered users eligible in %s: %s", course_config.course_id, filtered_user_ids) + + for user_id in filtered_user_ids: + generate_certificate_for_user_task.delay(course_config_id, user_id) + + +@app.task +def generate_all_certificates_task(): + """ + Celery task for initiating the processing of certificates for all enabled courses. + + This function fetches all enabled ExternalCertificateCourse objects, + and initiates a separate Celery task (process_certificate_for_course) for each of them. + """ + course_config_ids = ExternalCertificateCourseConfiguration.get_enabled_configurations().values_list('id', flat=True) + for config_id in course_config_ids: + generate_certificates_for_course_task.delay(config_id) diff --git a/openedx_certificates/templates/openedx_certificates/edx_ace/certificate_generated/email/body.html b/openedx_certificates/templates/openedx_certificates/edx_ace/certificate_generated/email/body.html new file mode 100644 index 0000000..03919fa --- /dev/null +++ b/openedx_certificates/templates/openedx_certificates/edx_ace/certificate_generated/email/body.html @@ -0,0 +1,22 @@ +{% load i18n %}{% autoescape off %} + +

+ {% blocktrans %}Thank you for your participation in {{ course_name }} at {{ platform_name }}!{% endblocktrans %} +

+ +

+ {% blocktrans %}We are happy to inform you that you have earned a certificate. You should feel very proud of the work you have done in this course. We congratulate you on your efforts and your learning.{% endblocktrans %} +

+ +

+ {% trans "To view and download your certificate, please click on the following link:" %} +

+

View and download your certificate

+ +
+
+ {% blocktrans %}Thank you for choosing {{ platform_name }} for your learning journey. We look forward to seeing you in more courses in the future.{% endblocktrans %} +
+
+ +{% endautoescape %} diff --git a/openedx_certificates/templates/openedx_certificates/edx_ace/certificate_generated/email/body.txt b/openedx_certificates/templates/openedx_certificates/edx_ace/certificate_generated/email/body.txt new file mode 100644 index 0000000..8332a4b --- /dev/null +++ b/openedx_certificates/templates/openedx_certificates/edx_ace/certificate_generated/email/body.txt @@ -0,0 +1,13 @@ +{% load i18n %}{% autoescape off %} + +{% blocktrans %}Thank you for your participation in {{ course_name }} at {{ platform_name }}!{% endblocktrans %} + +{% blocktrans %}We are happy to inform you that you have earned a certificate. You should feel very proud of the work you have done in this course. We congratulate you on your efforts and your learning.{% endblocktrans %} + +{% trans "To view and download your certificate, please click on the following link:" %} + +{{ certificate_link }} + +{% blocktrans %}Thank you for choosing {{ platform_name }} for your learning journey. We look forward to seeing you in more courses in the future.{% endblocktrans %} + +{% endautoescape %} diff --git a/openedx_certificates/templates/openedx_certificates/edx_ace/certificate_generated/email/from_name.txt b/openedx_certificates/templates/openedx_certificates/edx_ace/certificate_generated/email/from_name.txt new file mode 100644 index 0000000..dcbc23c --- /dev/null +++ b/openedx_certificates/templates/openedx_certificates/edx_ace/certificate_generated/email/from_name.txt @@ -0,0 +1 @@ +{{ platform_name }} diff --git a/openedx_certificates/templates/openedx_certificates/edx_ace/certificate_generated/email/head.html b/openedx_certificates/templates/openedx_certificates/edx_ace/certificate_generated/email/head.html new file mode 100644 index 0000000..e69de29 diff --git a/openedx_certificates/templates/openedx_certificates/edx_ace/certificate_generated/email/subject.txt b/openedx_certificates/templates/openedx_certificates/edx_ace/certificate_generated/email/subject.txt new file mode 100644 index 0000000..66b5074 --- /dev/null +++ b/openedx_certificates/templates/openedx_certificates/edx_ace/certificate_generated/email/subject.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans trimmed %}{{ course_name }} - Certificate{% endblocktrans %} +{% endautoescape %} diff --git a/openedx_certificates/urls.py b/openedx_certificates/urls.py index b6c8226..77c6601 100644 --- a/openedx_certificates/urls.py +++ b/openedx_certificates/urls.py @@ -1,10 +1,8 @@ -""" -URLs for openedx_certificates. -""" -# from django.urls import re_path -# from django.views.generic import TemplateView +"""URLs for openedx_certificates.""" +# from django.urls import re_path # noqa: ERA001, RUF100 +# from django.views.generic import TemplateView # noqa: ERA001, RUF100 urlpatterns = [ # TODO: Fill in URL patterns and views here. - # re_path(r'', TemplateView.as_view(template_name="openedx_certificates/base.html")), + # re_path(r'', TemplateView.as_view(template_name="openedx_certificates/base.html")), # noqa: ERA001, RUF100 ] diff --git a/openedx_certificates/views.py b/openedx_certificates/views.py new file mode 100644 index 0000000..4578657 --- /dev/null +++ b/openedx_certificates/views.py @@ -0,0 +1 @@ +"""TODO.""" diff --git a/pylintrc b/pylintrc deleted file mode 100644 index 4e9dcce..0000000 --- a/pylintrc +++ /dev/null @@ -1,390 +0,0 @@ -# *************************** -# ** DO NOT EDIT THIS FILE ** -# *************************** -# -# This file was generated by edx-lint: https://github.com/openedx/edx-lint -# -# If you want to change this file, you have two choices, depending on whether -# you want to make a local change that applies only to this repo, or whether -# you want to make a central change that applies to all repos using edx-lint. -# -# Note: If your pylintrc file is simply out-of-date relative to the latest -# pylintrc in edx-lint, ensure you have the latest edx-lint installed -# and then follow the steps for a "LOCAL CHANGE". -# -# LOCAL CHANGE: -# -# 1. Edit the local pylintrc_tweaks file to add changes just to this -# repo's file. -# -# 2. Run: -# -# $ edx_lint write pylintrc -# -# 3. This will modify the local file. Submit a pull request to get it -# checked in so that others will benefit. -# -# -# CENTRAL CHANGE: -# -# 1. Edit the pylintrc file in the edx-lint repo at -# https://github.com/openedx/edx-lint/blob/master/edx_lint/files/pylintrc -# -# 2. install the updated version of edx-lint (in edx-lint): -# -# $ pip install . -# -# 3. Run (in edx-lint): -# -# $ edx_lint write pylintrc -# -# 4. Make a new version of edx_lint, submit and review a pull request with the -# pylintrc update, and after merging, update the edx-lint version and -# publish the new version. -# -# 5. In your local repo, install the newer version of edx-lint. -# -# 6. Run: -# -# $ edx_lint write pylintrc -# -# 7. This will modify the local file. Submit a pull request to get it -# checked in so that others will benefit. -# -# -# -# -# -# STAY AWAY FROM THIS FILE! -# -# -# -# -# -# SERIOUSLY. -# -# ------------------------------ -# Generated by edx-lint version: 5.3.4 -# ------------------------------ -[MASTER] -ignore = migrations -persistent = yes -load-plugins = edx_lint.pylint,pylint_django,pylint_celery - -[MESSAGES CONTROL] -enable = - blacklisted-name, - line-too-long, - - abstract-class-instantiated, - abstract-method, - access-member-before-definition, - anomalous-backslash-in-string, - anomalous-unicode-escape-in-string, - arguments-differ, - assert-on-tuple, - assigning-non-slot, - 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, - duplicate-except, - duplicate-key, - expression-not-assigned, - format-combined-specification, - format-needs-mapping, - function-redefined, - global-variable-undefined, - import-error, - import-self, - inconsistent-mro, - inherit-non-class, - init-is-generator, - invalid-all-object, - invalid-format-index, - invalid-length-returned, - invalid-sequence-index, - invalid-slice-index, - invalid-slots-object, - invalid-slots, - invalid-unary-operand-type, - logging-too-few-args, - 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, - no-member, - no-method-argument, - no-name-in-module, - no-self-argument, - no-value-for-parameter, - non-iterator-returned, - non-parent-method-called, - nonexistent-operator, - not-a-mapping, - not-an-iterable, - not-callable, - not-context-manager, - not-in-loop, - pointless-statement, - pointless-string-statement, - 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, - super-init-not-called, - super-method-not-called, - syntax-error, - test-inherits-tests, - too-few-format-args, - too-many-format-args, - too-many-function-args, - translation-of-non-string, - truncated-format-string, - undefined-all-variable, - undefined-loop-variable, - 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, - unused-format-string-key, - used-before-assignment, - using-constant-test, - yield-outside-function, - - astroid-error, - fatal, - method-check-failed, - parse-error, - raw-checker-failed, - - empty-docstring, - invalid-characters-in-docstring, - missing-docstring, - wrong-spelling-in-comment, - wrong-spelling-in-docstring, - - unused-argument, - unused-import, - unused-variable, - - eval-used, - exec-used, - - bad-classmethod-argument, - bad-mcs-classmethod-argument, - bad-mcs-method-argument, - bare-except, - broad-except, - consider-iterating-dictionary, - consider-using-enumerate, - global-at-module-level, - global-variable-not-assigned, - literal-used-as-attribute, - logging-format-interpolation, - logging-not-lazy, - multiple-imports, - multiple-statements, - no-classmethod-decorator, - no-staticmethod-decorator, - protected-access, - redundant-unittest-assert, - reimported, - simplifiable-if-statement, - simplifiable-range, - singleton-comparison, - superfluous-parens, - unidiomatic-typecheck, - unnecessary-lambda, - unnecessary-pass, - unnecessary-semicolon, - unneeded-not, - useless-else-on-loop, - wrong-assert-type, - - deprecated-method, - deprecated-module, - - too-many-boolean-expressions, - too-many-nested-blocks, - too-many-statements, - - wildcard-import, - wrong-import-order, - wrong-import-position, - - missing-final-newline, - mixed-line-endings, - trailing-newlines, - trailing-whitespace, - unexpected-line-ending-format, - - bad-inline-option, - bad-option-value, - deprecated-pragma, - unrecognized-inline-option, - useless-suppression, -disable = - bad-indentation, - broad-exception-raised, - consider-using-f-string, - duplicate-code, - file-ignored, - fixme, - global-statement, - invalid-name, - locally-disabled, - no-else-return, - suppressed-message, - too-few-public-methods, - too-many-ancestors, - too-many-arguments, - too-many-branches, - too-many-instance-attributes, - too-many-lines, - too-many-locals, - too-many-public-methods, - too-many-return-statements, - ungrouped-imports, - unspecified-encoding, - unused-wildcard-import, - use-maxsplit-arg, - - feature-toggle-needs-doc, - illegal-waffle-usage, - - logging-fstring-interpolation, - invalid-name, - django-not-configured, - consider-using-with, - bad-option-value, - -[REPORTS] -output-format = text -reports = no -score = no - -[BASIC] -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]+$ -function-rgx = ([a-z_][a-z0-9_]{2,40}|test_[a-z0-9_]+)$ -method-rgx = ([a-z_][a-z0-9_]{2,40}|setUp|set[Uu]pClass|tearDown|tear[Dd]ownClass|assert[A-Z]\w*|maxDiff|test_[a-z0-9_]+)$ -attr-rgx = [a-z_][a-z0-9_]{2,30}$ -argument-rgx = [a-z_][a-z0-9_]{2,30}$ -variable-rgx = [a-z_][a-z0-9_]{2,30}$ -class-attribute-rgx = ([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ -inlinevar-rgx = [A-Za-z_][A-Za-z0-9_]*$ -good-names = f,i,j,k,db,ex,Run,_,__ -bad-names = foo,bar,baz,toto,tutu,tata -no-docstring-rgx = __.*__$|test_.+|setUp$|setUpClass$|tearDown$|tearDownClass$|Meta$ -docstring-min-length = 5 - -[FORMAT] -max-line-length = 120 -ignore-long-lines = ^\s*(# )?((?)|(\.\. \w+: .*))$ -single-line-if-stmt = no -max-module-lines = 1000 -indent-string = ' ' - -[MISCELLANEOUS] -notes = FIXME,XXX,TODO - -[SIMILARITIES] -min-similarity-lines = 4 -ignore-comments = yes -ignore-docstrings = yes -ignore-imports = no - -[TYPECHECK] -ignore-mixin-members = yes -ignored-classes = SQLObject -unsafe-load-any-extension = yes -generated-members = - REQUEST, - acl_users, - aq_parent, - objects, - DoesNotExist, - can_read, - can_write, - get_url, - size, - content, - status_code, - create, - build, - fields, - tag, - org, - course, - category, - name, - revision, - _meta, - -[VARIABLES] -init-import = no -dummy-variables-rgx = _|dummy|unused|.*_unused -additional-builtins = - -[CLASSES] -defining-attr-methods = __init__,__new__,setUp -valid-classmethod-first-arg = cls -valid-metaclass-classmethod-first-arg = mcs - -[DESIGN] -max-args = 5 -ignored-argument-names = _.* -max-locals = 15 -max-returns = 6 -max-branches = 12 -max-statements = 50 -max-parents = 7 -max-attributes = 7 -min-public-methods = 2 -max-public-methods = 20 - -[IMPORTS] -deprecated-modules = regsub,TERMIOS,Bastion,rexec -import-graph = -ext-import-graph = -int-import-graph = - -[EXCEPTIONS] -overgeneral-exceptions = builtins.Exception - -# f9938a0048db870de9b3db6ba3d2a79f5c48d553 diff --git a/pylintrc_tweaks b/pylintrc_tweaks deleted file mode 100644 index 7b6eb35..0000000 --- a/pylintrc_tweaks +++ /dev/null @@ -1,11 +0,0 @@ -# pylintrc tweaks for use with edx_lint. -[MASTER] -ignore = migrations -load-plugins = edx_lint.pylint,pylint_django,pylint_celery - -[MESSAGES CONTROL] -disable+= - invalid-name, - django-not-configured, - consider-using-with, - bad-option-value, diff --git a/pyproject.toml b/pyproject.toml index 852eab2..3a230cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,138 @@ +[tool.coverage.run] +include = ['openedx_certificates/**'] +omit = ['*/migrations/*', 'tests/*'] +plugins = ['django_coverage_plugin'] + +[tool.ruff] +line-length = 120 +exclude = ['migrations', 'docs', 'manage.py'] +select = [ + 'F', # Pyflakes + 'E', # Pycodestyle (errors) + 'W', # Pycodestyle (warnings) + 'C90', # mccabe + 'I', # isort + 'N', # pep8-naming + 'D', # pydocstyle + 'UP', # pyupgrade + 'YTT', # flake8-2020 + 'ANN', # flake8-annotations + 'ASYNC',# flake8-async + # 'TRIO', # flake8-trio + 'S', # flake8-bandit + 'BLE', # flake8-blind-except + 'FBT', # flake8-boolean-trap + 'B', # flake8-bugbear + 'A', # flake8-builtins + 'COM', # flake8-commas + # 'CPY', # flake8-copyright + 'C4', # flake8-comprehensions + 'DTZ', # flake8-datetimez + 'T10', # flake8-debugger + 'DJ', # flake8-django + 'EM', # flake8-errmsg + 'EXE', # flake8-executable + 'FA', # flake8-future-annotations + 'ISC', # flake8-implicit-str-concat + 'ICN', # flake8-import-conventions + 'G', # flake8-logging-format + 'INP', # flake8-no-pep420 + 'PIE', # flake8-pie + 'T20', # flake8-print + 'PYI', # flake8-pyi + 'PT', # flake8-pytest-style + 'Q', # flake8-quotes + 'RSE', # flake8-raise + 'RET', # flake8-return + 'SLF', # flake8-self + 'SLOT', # flake8-slots + 'SIM', # flake8-simplify + 'TID', # flake8-tidy-imports + 'TCH', # flake8-type-checking + 'INT', # flake8-gettext + 'ARG', # flake8-unused-arguments + 'PTH', # flake8-use-pathlib + 'TD', # flake8-todos + # 'FIX', # flake8-fixme + 'ERA', # eradicate + 'PD', # pandas-vet + 'PGH', # pygrep-hooks + 'PL', # Pylint + 'TRY', # tryceratops + 'FLY', # flynt + 'NPY', # NumPy-specific rules + 'AIR', # Airflow + 'PERF', # Perflint + # 'FURB', # refurb + # 'LOG', # flake8-logging + 'RUF', # Ruff-specific rules +] +ignore = [ + 'ANN002', # missing-type-args + 'ANN003', # missing-type-kwargs + 'ANN101', # missing-type-self + 'ANN102', # missing-type-cls + 'ANN204', # missing-return-type-special-method + 'D203', # one-blank-line-before-class + 'D212', # multi-line-summary-first-line (incompatible with D213) + 'Q000', # bad-quotes-inline-string + 'TD002', # missing-t\odo-author + 'TD003', # missing-t\odo-link + 'TRY003', # raise-vanilla-args +] +target-version = 'py38' + +[tool.ruff.per-file-ignores] +'tests/*' = [ + 'S101', # assert + 'ANN205', # missing-return-type-static-method + 'INP001', # implicit-namespace-package + 'SLF001', # private-member-access + 'RUF018', # assignment-in-assert +] + +[tool.ruff.flake8-annotations] +suppress-none-returning = true + +[tool.ruff.flake8-builtins] +builtins-ignorelist = ['list'] + +# Consider instead of Q000. +#[tool.ruff.flake8-quotes] +#inline-quotes = 'single' + +[tool.ruff.pydocstyle] +convention = "google" + +[tool.ruff.pylint] +allow-magic-value-types = ['int', 'str'] + [tool.black] line-length = 120 target-version = ['py38'] skip_string_normalization = true +exclude = ''' +/( + \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | _build + | buck-out + | build + | dist + | migrations +)/ +''' -[tool.ruff] -line-length = 120 +[tool.pytest.ini_options] +filterwarnings = [ + # https://github.com/openedx/completion/pull/259 + "ignore:'completion' defines default_app_config:django.utils.deprecation.RemovedInDjango41Warning", + "ignore:pkg_resources is deprecated as an API:DeprecationWarning", + "ignore:Deprecated call to `pkg_resources.declare_namespace.*sphinxcontrib:DeprecationWarning", +] +DJANGO_SETTINGS_MODULE = "test_settings" +addopts = "--cov openedx_certificates --cov tests --cov-report term-missing --cov-report xml" +norecursedirs = ".* docs requirements site-packages" diff --git a/requirements/base.in b/requirements/base.in index 15d3577..7978064 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -1,5 +1,16 @@ # Core requirements for using this application -c constraints.txt -Django # Web application framework -django-model-utils # Provides TimeStampedModel abstract base class +django # Web application framework +django-model-utils # Provides TimeStampedModel abstract base class +edx-opaque-keys # Create and introspect Course and XBlock identities +celery # Distributed task queue +django-celery-beat # Periodic task scheduler +django_reverse_admin # Provides reverse inlines in the admin interface +djangorestframework # RESTful API framework +django-object-actions # Provides actions on objects in the admin interface +# TODO: Extract these to a plugin. +pypdf # PDF manipulation library +reportlab # PDF generation library +openedx-completion-aggregator # Completion aggregation service +edx_ace # Messaging library diff --git a/requirements/base.txt b/requirements/base.txt index 7282fc4..229dfdd 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -4,18 +4,262 @@ # # make upgrade # +amqp==5.2.0 + # via kombu +appdirs==1.4.4 + # via fs asgiref==3.7.2 # via django -django==3.2.20 +attrs==23.2.0 + # via edx-ace +backports-zoneinfo[tzdata]==0.2.1 + # via + # celery + # django-celery-beat + # django-timezone-field + # kombu +billiard==4.2.0 + # via celery +celery==5.3.6 + # via + # -r requirements/base.in + # django-celery-beat + # edx-celeryutils + # event-tracking + # openedx-completion-aggregator +certifi==2023.11.17 + # via requests +cffi==1.16.0 + # via + # cryptography + # pynacl +chardet==5.2.0 + # via reportlab +charset-normalizer==3.3.2 + # via requests +click==8.1.7 + # via + # celery + # click-didyoumean + # click-plugins + # click-repl + # code-annotations + # edx-django-utils +click-didyoumean==0.3.0 + # via celery +click-plugins==1.1.1 + # via celery +click-repl==0.3.0 + # via celery +code-annotations==1.5.0 + # via edx-toggles +cron-descriptor==1.4.0 + # via django-celery-beat +cryptography==41.0.7 + # via pyjwt +django==3.2.23 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/base.in + # django-celery-beat + # django-crum # django-model-utils + # django-timezone-field + # django-waffle + # djangorestframework + # drf-jwt + # edx-ace + # edx-celeryutils + # edx-completion + # edx-django-utils + # edx-drf-extensions + # edx-toggles + # event-tracking + # jsonfield + # openedx-completion-aggregator +django-celery-beat==2.5.0 + # via -r requirements/base.in +django-crum==0.7.9 + # via + # edx-django-utils + # edx-toggles django-model-utils==4.3.1 + # via + # -r requirements/base.in + # edx-celeryutils + # edx-completion + # openedx-completion-aggregator +django-object-actions==4.2.0 # via -r requirements/base.in -pytz==2023.3 - # via django +django-reverse-admin==2.9.6 + # via -r requirements/base.in +django-timezone-field==6.1.0 + # via django-celery-beat +django-waffle==4.1.0 + # via + # edx-django-utils + # edx-drf-extensions + # edx-toggles +djangorestframework==3.14.0 + # via + # -r requirements/base.in + # drf-jwt + # edx-completion + # edx-drf-extensions + # openedx-completion-aggregator +drf-jwt==1.19.2 + # via edx-drf-extensions +edx-ace==1.7.0 + # via -r requirements/base.in +edx-celeryutils==1.2.3 + # via openedx-completion-aggregator +edx-completion==4.4.0 + # via openedx-completion-aggregator +edx-django-utils==5.9.0 + # via + # edx-drf-extensions + # edx-toggles + # event-tracking +edx-drf-extensions==9.1.2 + # via edx-completion +edx-opaque-keys==2.5.1 + # via + # -r requirements/base.in + # edx-completion + # edx-drf-extensions + # openedx-completion-aggregator +edx-toggles==5.1.0 + # via + # edx-completion + # openedx-completion-aggregator +event-tracking==2.2.0 + # via edx-completion +fs==2.4.16 + # via xblock +idna==3.6 + # via requests +jinja2==3.1.2 + # via code-annotations +jsonfield==3.1.0 + # via edx-celeryutils +kombu==5.3.4 + # via celery +lxml==5.1.0 + # via xblock +mako==1.3.0 + # via xblock +markupsafe==2.1.3 + # via + # jinja2 + # mako + # xblock +newrelic==9.4.0 + # via edx-django-utils +openedx-completion-aggregator==4.0.3 + # via -r requirements/base.in +pbr==6.0.0 + # via stevedore +pillow==10.2.0 + # via reportlab +prompt-toolkit==3.0.43 + # via click-repl +psutil==5.9.7 + # via edx-django-utils +pycparser==2.21 + # via cffi +pyjwt[crypto]==2.8.0 + # via + # drf-jwt + # edx-drf-extensions + # pyjwt +pymongo==3.13.0 + # via + # edx-opaque-keys + # event-tracking +pynacl==1.5.0 + # via edx-django-utils +pypdf==3.17.4 + # via -r requirements/base.in +python-crontab==3.0.0 + # via django-celery-beat +python-dateutil==2.8.2 + # via + # celery + # edx-ace + # python-crontab + # xblock +python-slugify==8.0.1 + # via code-annotations +pytz==2023.3.post1 + # via + # django + # djangorestframework + # edx-completion + # event-tracking + # xblock +pyyaml==6.0.1 + # via + # code-annotations + # xblock +reportlab==4.0.9 + # via -r requirements/base.in +requests==2.31.0 + # via + # edx-drf-extensions + # sailthru-client +sailthru-client==2.2.3 + # via edx-ace +semantic-version==2.10.0 + # via edx-drf-extensions +simplejson==3.19.2 + # via + # sailthru-client + # xblock +six==1.16.0 + # via + # edx-ace + # event-tracking + # fs + # openedx-completion-aggregator + # python-dateutil sqlparse==0.4.4 # via django -typing-extensions==4.7.1 - # via asgiref +stevedore==5.1.0 + # via + # code-annotations + # edx-ace + # edx-django-utils + # edx-opaque-keys +text-unidecode==1.3 + # via python-slugify +typing-extensions==4.9.0 + # via + # asgiref + # edx-opaque-keys + # kombu + # pypdf +tzdata==2023.4 + # via + # backports-zoneinfo + # celery + # django-celery-beat +urllib3==2.1.0 + # via requests +vine==5.1.0 + # via + # amqp + # celery + # kombu +wcwidth==0.2.13 + # via prompt-toolkit +web-fragments==2.1.0 + # via xblock +webob==1.8.7 + # via xblock +xblock==1.9.1 + # via + # edx-completion + # openedx-completion-aggregator + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/requirements/ci.in b/requirements/ci.in index 3797849..3586cbe 100644 --- a/requirements/ci.in +++ b/requirements/ci.in @@ -3,4 +3,3 @@ -c constraints.txt tox # Virtualenv management for tests -tox-battery # Makes tox aware of requirements file changes diff --git a/requirements/ci.txt b/requirements/ci.txt index 6b50e71..7d1eff5 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -4,30 +4,36 @@ # # make upgrade # -distlib==0.3.7 +cachetools==5.3.2 + # via tox +chardet==5.2.0 + # via tox +colorama==0.4.6 + # via tox +distlib==0.3.8 # via virtualenv -filelock==3.12.2 +filelock==3.13.1 # via # tox # virtualenv -packaging==23.1 - # via tox -platformdirs==3.10.0 - # via virtualenv -pluggy==1.2.0 - # via tox -py==1.11.0 +packaging==23.2 + # via + # pyproject-api + # tox +platformdirs==3.11.0 + # via + # -c requirements/constraints.txt + # tox + # virtualenv +pluggy==1.3.0 # via tox -six==1.16.0 +pyproject-api==1.6.1 # via tox tomli==2.0.1 - # via tox -tox==3.28.0 # via - # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt - # -r requirements/ci.in - # tox-battery -tox-battery==0.6.2 + # pyproject-api + # tox +tox==4.11.4 # via -r requirements/ci.in -virtualenv==20.24.3 +virtualenv==20.25.0 # via tox diff --git a/requirements/constraints.txt b/requirements/constraints.txt index d91704b..6786d1d 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -10,3 +10,6 @@ # Common constraints for edx repos -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt + +# Currently, `virtualenv` and `black` use different versions of `platformdirs`. +platformdirs<4.0.0 diff --git a/requirements/dev.txt b/requirements/dev.txt index cbe408b..d992571 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -4,133 +4,329 @@ # # make upgrade # +amqp==5.2.0 + # via + # -r requirements/quality.txt + # kombu +appdirs==1.4.4 + # via + # -r requirements/quality.txt + # fs asgiref==3.7.2 # via # -r requirements/quality.txt # django -astroid==2.15.6 +attrs==23.2.0 # via # -r requirements/quality.txt - # pylint - # pylint-celery -black==23.7.0 + # edx-ace +backports-zoneinfo[tzdata]==0.2.1 + # via + # -r requirements/quality.txt + # backports-zoneinfo + # celery + # django-celery-beat + # django-timezone-field + # kombu +billiard==4.2.0 + # via + # -r requirements/quality.txt + # celery +black==23.12.1 # via -r requirements/quality.txt -build==0.10.0 +build==1.0.3 # via # -r requirements/pip-tools.txt # pip-tools +cachetools==5.3.2 + # via + # -r requirements/ci.txt + # tox +celery==5.3.6 + # via + # -r requirements/quality.txt + # django-celery-beat + # edx-celeryutils + # event-tracking + # openedx-completion-aggregator +certifi==2023.11.17 + # via + # -r requirements/quality.txt + # requests +cffi==1.16.0 + # via + # -r requirements/quality.txt + # cryptography + # pynacl chardet==5.2.0 - # via diff-cover + # via + # -r requirements/ci.txt + # -r requirements/quality.txt + # diff-cover + # reportlab + # tox +charset-normalizer==3.3.2 + # via + # -r requirements/quality.txt + # requests click==8.1.7 # via # -r requirements/pip-tools.txt # -r requirements/quality.txt # black - # click-log + # celery + # click-didyoumean + # click-plugins + # click-repl # code-annotations - # edx-lint + # edx-django-utils # pip-tools -click-log==0.4.0 +click-didyoumean==0.3.0 + # via + # -r requirements/quality.txt + # celery +click-plugins==1.1.1 # via # -r requirements/quality.txt - # edx-lint + # celery +click-repl==0.3.0 + # via + # -r requirements/quality.txt + # celery code-annotations==1.5.0 # via # -r requirements/quality.txt - # edx-lint -coverage[toml]==7.3.0 + # edx-toggles +colorama==0.4.6 + # via + # -r requirements/ci.txt + # tox +coverage[toml]==7.4.0 # via # -r requirements/quality.txt + # coverage + # django-coverage-plugin # pytest-cov -diff-cover==7.7.0 - # via -r requirements/dev.in -dill==0.3.7 +cron-descriptor==1.4.0 + # via + # -r requirements/quality.txt + # django-celery-beat +cryptography==41.0.7 # via # -r requirements/quality.txt - # pylint -distlib==0.3.7 + # pyjwt +diff-cover==8.0.2 + # via -r requirements/dev.in +distlib==0.3.8 # via # -r requirements/ci.txt # virtualenv -django==3.2.20 +dj-inmemorystorage==2.1.0 + # via -r requirements/quality.txt +django==3.2.23 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/quality.txt + # dj-inmemorystorage + # django-celery-beat + # django-crum # django-model-utils + # django-timezone-field + # django-waffle + # djangorestframework + # drf-jwt + # edx-ace + # edx-celeryutils + # edx-completion + # edx-django-utils + # edx-drf-extensions # edx-i18n-tools + # edx-toggles + # event-tracking + # jsonfield + # openedx-completion-aggregator +django-celery-beat==2.5.0 + # via -r requirements/quality.txt +django-coverage-plugin==3.1.0 + # via -r requirements/quality.txt +django-crum==0.7.9 + # via + # -r requirements/quality.txt + # edx-django-utils + # edx-toggles django-model-utils==4.3.1 + # via + # -r requirements/quality.txt + # edx-celeryutils + # edx-completion + # openedx-completion-aggregator +django-object-actions==4.2.0 # via -r requirements/quality.txt -edx-i18n-tools==1.1.0 - # via -r requirements/dev.in -edx-lint==5.3.4 +django-reverse-admin==2.9.6 + # via -r requirements/quality.txt +django-timezone-field==6.1.0 + # via + # -r requirements/quality.txt + # django-celery-beat +django-waffle==4.1.0 + # via + # -r requirements/quality.txt + # edx-django-utils + # edx-drf-extensions + # edx-toggles +djangorestframework==3.14.0 + # via + # -r requirements/quality.txt + # drf-jwt + # edx-completion + # edx-drf-extensions + # openedx-completion-aggregator +drf-jwt==1.19.2 + # via + # -r requirements/quality.txt + # edx-drf-extensions +edx-ace==1.7.0 # via -r requirements/quality.txt -exceptiongroup==1.1.3 +edx-celeryutils==1.2.3 + # via + # -r requirements/quality.txt + # openedx-completion-aggregator +edx-completion==4.4.0 + # via + # -r requirements/quality.txt + # openedx-completion-aggregator +edx-django-utils==5.9.0 + # via + # -r requirements/quality.txt + # edx-drf-extensions + # edx-toggles + # event-tracking +edx-drf-extensions==9.1.2 + # via + # -r requirements/quality.txt + # edx-completion +edx-i18n-tools==1.3.0 + # via -r requirements/dev.in +edx-opaque-keys==2.5.1 + # via + # -r requirements/quality.txt + # edx-completion + # edx-drf-extensions + # openedx-completion-aggregator +edx-toggles==5.1.0 + # via + # -r requirements/quality.txt + # edx-completion + # openedx-completion-aggregator +event-tracking==2.2.0 + # via + # -r requirements/quality.txt + # edx-completion +exceptiongroup==1.2.0 # via # -r requirements/quality.txt # pytest -filelock==3.12.2 +factory-boy==3.3.0 + # via -r requirements/quality.txt +faker==22.1.0 + # via + # -r requirements/quality.txt + # factory-boy +filelock==3.13.1 # via # -r requirements/ci.txt # tox # virtualenv -iniconfig==2.0.0 +fs==2.4.16 # via # -r requirements/quality.txt - # pytest -isort==5.12.0 + # xblock +idna==3.6 # via # -r requirements/quality.txt - # pylint + # requests +importlib-metadata==7.0.1 + # via + # -r requirements/pip-tools.txt + # build +iniconfig==2.0.0 + # via + # -r requirements/quality.txt + # pytest jinja2==3.1.2 # via # -r requirements/quality.txt # code-annotations # diff-cover -lazy-object-proxy==1.9.0 +jsonfield==3.1.0 # via # -r requirements/quality.txt - # astroid -markupsafe==2.1.3 + # edx-celeryutils +kombu==5.3.4 # via # -r requirements/quality.txt - # jinja2 -mccabe==0.7.0 + # celery +lxml==5.1.0 + # via + # -r requirements/quality.txt + # edx-i18n-tools + # xblock +mako==1.3.0 # via # -r requirements/quality.txt - # pylint + # xblock +markupsafe==2.1.3 + # via + # -r requirements/quality.txt + # jinja2 + # mako + # xblock mypy-extensions==1.0.0 # via # -r requirements/quality.txt # black -packaging==23.1 +newrelic==9.4.0 + # via + # -r requirements/quality.txt + # edx-django-utils +openedx-completion-aggregator==4.0.3 + # via -r requirements/quality.txt +packaging==23.2 # via # -r requirements/ci.txt # -r requirements/pip-tools.txt # -r requirements/quality.txt # black # build + # pyproject-api # pytest # tox -path==16.7.1 +path==16.9.0 # via edx-i18n-tools -pathspec==0.11.2 +pathspec==0.12.1 # via # -r requirements/quality.txt # black -pbr==5.11.1 + # yamllint +pbr==6.0.0 # via # -r requirements/quality.txt # stevedore +pillow==10.2.0 + # via + # -r requirements/quality.txt + # reportlab pip-tools==7.3.0 # via -r requirements/pip-tools.txt -platformdirs==3.10.0 +platformdirs==3.11.0 # via + # -c requirements/constraints.txt # -r requirements/ci.txt # -r requirements/quality.txt # black - # pylint + # tox # virtualenv -pluggy==1.2.0 +pluggy==1.3.0 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -139,74 +335,116 @@ pluggy==1.2.0 # tox polib==1.2.0 # via edx-i18n-tools -py==1.11.0 +prompt-toolkit==3.0.43 # via - # -r requirements/ci.txt - # tox -pycodestyle==2.11.0 - # via -r requirements/quality.txt -pydocstyle==6.3.0 - # via -r requirements/quality.txt -pygments==2.16.1 - # via diff-cover -pylint==2.17.5 + # -r requirements/quality.txt + # click-repl +psutil==5.9.7 # via # -r requirements/quality.txt - # edx-lint - # pylint-celery - # pylint-django - # pylint-plugin-utils -pylint-celery==0.3 + # edx-django-utils +pycparser==2.21 # via # -r requirements/quality.txt - # edx-lint -pylint-django==2.5.3 + # cffi +pygments==2.17.2 + # via diff-cover +pyjwt[crypto]==2.8.0 # via # -r requirements/quality.txt - # edx-lint -pylint-plugin-utils==0.8.2 + # drf-jwt + # edx-drf-extensions + # pyjwt +pymongo==3.13.0 # via # -r requirements/quality.txt - # pylint-celery - # pylint-django + # edx-opaque-keys + # event-tracking +pynacl==1.5.0 + # via + # -r requirements/quality.txt + # edx-django-utils +pypdf==3.17.4 + # via -r requirements/quality.txt +pyproject-api==1.6.1 + # via + # -r requirements/ci.txt + # tox pyproject-hooks==1.0.0 # via # -r requirements/pip-tools.txt # build -pytest==7.4.0 +pytest==7.4.4 # via # -r requirements/quality.txt # pytest-cov # pytest-django pytest-cov==4.1.0 # via -r requirements/quality.txt -pytest-django==4.5.2 +pytest-django==4.7.0 # via -r requirements/quality.txt +python-crontab==3.0.0 + # via + # -r requirements/quality.txt + # django-celery-beat +python-dateutil==2.8.2 + # via + # -r requirements/quality.txt + # celery + # edx-ace + # faker + # python-crontab + # xblock python-slugify==8.0.1 # via # -r requirements/quality.txt # code-annotations -pytz==2023.3 +pytz==2023.3.post1 # via # -r requirements/quality.txt # django + # djangorestframework + # edx-completion + # event-tracking + # xblock pyyaml==6.0.1 # via # -r requirements/quality.txt # code-annotations # edx-i18n-tools -ruff==0.0.285 + # xblock + # yamllint +reportlab==4.0.9 # via -r requirements/quality.txt -six==1.16.0 +requests==2.31.0 # via - # -r requirements/ci.txt # -r requirements/quality.txt - # edx-lint - # tox -snowballstemmer==2.2.0 + # edx-drf-extensions + # sailthru-client +ruff==0.1.11 + # via -r requirements/quality.txt +sailthru-client==2.2.3 # via # -r requirements/quality.txt - # pydocstyle + # edx-ace +semantic-version==2.10.0 + # via + # -r requirements/quality.txt + # edx-drf-extensions +simplejson==3.19.2 + # via + # -r requirements/quality.txt + # sailthru-client + # xblock +six==1.16.0 + # via + # -r requirements/quality.txt + # dj-inmemorystorage + # edx-ace + # event-tracking + # fs + # openedx-completion-aggregator + # python-dateutil sqlparse==0.4.4 # via # -r requirements/quality.txt @@ -215,6 +453,9 @@ stevedore==5.1.0 # via # -r requirements/quality.txt # code-annotations + # edx-ace + # edx-django-utils + # edx-opaque-keys text-unidecode==1.3 # via # -r requirements/quality.txt @@ -228,40 +469,68 @@ tomli==2.0.1 # build # coverage # pip-tools - # pylint + # pyproject-api # pyproject-hooks # pytest # tox -tomlkit==0.12.1 - # via - # -r requirements/quality.txt - # pylint -tox==3.28.0 - # via - # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt - # -r requirements/ci.txt - # tox-battery -tox-battery==0.6.2 +tox==4.11.4 # via -r requirements/ci.txt -typing-extensions==4.7.1 +typing-extensions==4.9.0 # via # -r requirements/quality.txt # asgiref - # astroid # black - # pylint -virtualenv==20.24.3 + # edx-opaque-keys + # faker + # kombu + # pypdf +tzdata==2023.4 + # via + # -r requirements/quality.txt + # backports-zoneinfo + # celery + # django-celery-beat +urllib3==2.1.0 + # via + # -r requirements/quality.txt + # requests +vine==5.1.0 + # via + # -r requirements/quality.txt + # amqp + # celery + # kombu +virtualenv==20.25.0 # via # -r requirements/ci.txt # tox -wheel==0.41.2 +wcwidth==0.2.13 + # via + # -r requirements/quality.txt + # prompt-toolkit +web-fragments==2.1.0 + # via + # -r requirements/quality.txt + # xblock +webob==1.8.7 + # via + # -r requirements/quality.txt + # xblock +wheel==0.42.0 # via # -r requirements/pip-tools.txt # pip-tools -wrapt==1.15.0 +xblock==1.9.1 # via # -r requirements/quality.txt - # astroid + # edx-completion + # openedx-completion-aggregator +yamllint==1.33.0 + # via -r requirements/quality.txt +zipp==3.17.0 + # via + # -r requirements/pip-tools.txt + # importlib-metadata # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/requirements/doc.txt b/requirements/doc.txt index 085e524..23e6b4d 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -8,45 +8,165 @@ accessible-pygments==0.0.4 # via pydata-sphinx-theme alabaster==0.7.13 # via sphinx +amqp==5.2.0 + # via + # -r requirements/test.txt + # kombu +appdirs==1.4.4 + # via + # -r requirements/test.txt + # fs asgiref==3.7.2 # via # -r requirements/test.txt # django -babel==2.12.1 +attrs==23.2.0 + # via + # -r requirements/test.txt + # edx-ace +babel==2.14.0 # via # pydata-sphinx-theme # sphinx +backports-zoneinfo[tzdata]==0.2.1 + # via + # -r requirements/test.txt + # backports-zoneinfo + # celery + # django-celery-beat + # django-timezone-field + # kombu beautifulsoup4==4.12.2 # via pydata-sphinx-theme -bleach==6.0.0 - # via readme-renderer -build==0.10.0 +billiard==4.2.0 + # via + # -r requirements/test.txt + # celery +build==1.0.3 # via -r requirements/doc.in -certifi==2023.7.22 - # via requests -cffi==1.15.1 - # via cryptography -charset-normalizer==3.2.0 - # via requests +celery==5.3.6 + # via + # -r requirements/test.txt + # django-celery-beat + # edx-celeryutils + # event-tracking + # openedx-completion-aggregator +certifi==2023.11.17 + # via + # -r requirements/test.txt + # requests +cffi==1.16.0 + # via + # -r requirements/test.txt + # cryptography + # pynacl +chardet==5.2.0 + # via + # -r requirements/test.txt + # reportlab +charset-normalizer==3.3.2 + # via + # -r requirements/test.txt + # requests click==8.1.7 # via # -r requirements/test.txt + # celery + # click-didyoumean + # click-plugins + # click-repl # code-annotations + # edx-django-utils +click-didyoumean==0.3.0 + # via + # -r requirements/test.txt + # celery +click-plugins==1.1.1 + # via + # -r requirements/test.txt + # celery +click-repl==0.3.0 + # via + # -r requirements/test.txt + # celery code-annotations==1.5.0 - # via -r requirements/test.txt -coverage[toml]==7.3.0 # via # -r requirements/test.txt + # edx-toggles +coverage[toml]==7.4.0 + # via + # -r requirements/test.txt + # coverage + # django-coverage-plugin # pytest-cov -cryptography==41.0.3 - # via secretstorage -django==3.2.20 +cron-descriptor==1.4.0 + # via + # -r requirements/test.txt + # django-celery-beat +cryptography==41.0.7 + # via + # -r requirements/test.txt + # pyjwt + # secretstorage +dj-inmemorystorage==2.1.0 + # via -r requirements/test.txt +django==3.2.23 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/test.txt + # dj-inmemorystorage + # django-celery-beat + # django-crum # django-model-utils + # django-timezone-field + # django-waffle + # djangorestframework + # drf-jwt + # edx-ace + # edx-celeryutils + # edx-completion + # edx-django-utils + # edx-drf-extensions + # edx-toggles + # event-tracking + # jsonfield + # openedx-completion-aggregator +django-celery-beat==2.5.0 + # via -r requirements/test.txt +django-coverage-plugin==3.1.0 + # via -r requirements/test.txt +django-crum==0.7.9 + # via + # -r requirements/test.txt + # edx-django-utils + # edx-toggles django-model-utils==4.3.1 + # via + # -r requirements/test.txt + # edx-celeryutils + # edx-completion + # openedx-completion-aggregator +django-object-actions==4.2.0 + # via -r requirements/test.txt +django-reverse-admin==2.9.6 # via -r requirements/test.txt +django-timezone-field==6.1.0 + # via + # -r requirements/test.txt + # django-celery-beat +django-waffle==4.1.0 + # via + # -r requirements/test.txt + # edx-django-utils + # edx-drf-extensions + # edx-toggles +djangorestframework==3.14.0 + # via + # -r requirements/test.txt + # drf-jwt + # edx-completion + # edx-drf-extensions + # openedx-completion-aggregator doc8==1.1.1 # via -r requirements/doc.in docutils==0.19 @@ -56,20 +176,72 @@ docutils==0.19 # readme-renderer # restructuredtext-lint # sphinx -exceptiongroup==1.1.3 +drf-jwt==1.19.2 + # via + # -r requirements/test.txt + # edx-drf-extensions +edx-ace==1.7.0 + # via -r requirements/test.txt +edx-celeryutils==1.2.3 + # via + # -r requirements/test.txt + # openedx-completion-aggregator +edx-completion==4.4.0 + # via + # -r requirements/test.txt + # openedx-completion-aggregator +edx-django-utils==5.9.0 + # via + # -r requirements/test.txt + # edx-drf-extensions + # edx-toggles + # event-tracking +edx-drf-extensions==9.1.2 + # via + # -r requirements/test.txt + # edx-completion +edx-opaque-keys==2.5.1 + # via + # -r requirements/test.txt + # edx-completion + # edx-drf-extensions + # openedx-completion-aggregator +edx-toggles==5.1.0 + # via + # -r requirements/test.txt + # edx-completion + # openedx-completion-aggregator +event-tracking==2.2.0 + # via + # -r requirements/test.txt + # edx-completion +exceptiongroup==1.2.0 # via # -r requirements/test.txt # pytest -idna==3.4 - # via requests +factory-boy==3.3.0 + # via -r requirements/test.txt +faker==22.1.0 + # via + # -r requirements/test.txt + # factory-boy +fs==2.4.16 + # via + # -r requirements/test.txt + # xblock +idna==3.6 + # via + # -r requirements/test.txt + # requests imagesize==1.4.1 # via sphinx -importlib-metadata==6.8.0 +importlib-metadata==7.0.1 # via + # build # keyring # sphinx # twine -importlib-resources==6.0.1 +importlib-resources==6.1.1 # via keyring iniconfig==2.0.0 # via @@ -86,40 +258,80 @@ jinja2==3.1.2 # -r requirements/test.txt # code-annotations # sphinx -keyring==24.2.0 +jsonfield==3.1.0 + # via + # -r requirements/test.txt + # edx-celeryutils +keyring==24.3.0 # via twine +kombu==5.3.4 + # via + # -r requirements/test.txt + # celery +lxml==5.1.0 + # via + # -r requirements/test.txt + # xblock +mako==1.3.0 + # via + # -r requirements/test.txt + # xblock markdown-it-py==3.0.0 # via rich markupsafe==2.1.3 # via # -r requirements/test.txt # jinja2 + # mako + # xblock mdurl==0.1.2 # via markdown-it-py -more-itertools==10.1.0 +more-itertools==10.2.0 # via jaraco-classes -packaging==23.1 +newrelic==9.4.0 + # via + # -r requirements/test.txt + # edx-django-utils +nh3==0.2.15 + # via readme-renderer +openedx-completion-aggregator==4.0.3 + # via -r requirements/test.txt +packaging==23.2 # via # -r requirements/test.txt # build # pydata-sphinx-theme # pytest # sphinx -pbr==5.11.1 +pbr==6.0.0 # via # -r requirements/test.txt # stevedore +pillow==10.2.0 + # via + # -r requirements/test.txt + # reportlab pkginfo==1.9.6 # via twine -pluggy==1.2.0 +pluggy==1.3.0 # via # -r requirements/test.txt # pytest +prompt-toolkit==3.0.43 + # via + # -r requirements/test.txt + # click-repl +psutil==5.9.7 + # via + # -r requirements/test.txt + # edx-django-utils pycparser==2.21 - # via cffi -pydata-sphinx-theme==0.13.3 + # via + # -r requirements/test.txt + # cffi +pydata-sphinx-theme==0.14.4 # via sphinx-book-theme -pygments==2.16.1 +pygments==2.17.2 # via # accessible-pygments # doc8 @@ -127,35 +339,74 @@ pygments==2.16.1 # readme-renderer # rich # sphinx +pyjwt[crypto]==2.8.0 + # via + # -r requirements/test.txt + # drf-jwt + # edx-drf-extensions + # pyjwt +pymongo==3.13.0 + # via + # -r requirements/test.txt + # edx-opaque-keys + # event-tracking +pynacl==1.5.0 + # via + # -r requirements/test.txt + # edx-django-utils +pypdf==3.17.4 + # via -r requirements/test.txt pyproject-hooks==1.0.0 # via build -pytest==7.4.0 +pytest==7.4.4 # via # -r requirements/test.txt # pytest-cov # pytest-django pytest-cov==4.1.0 # via -r requirements/test.txt -pytest-django==4.5.2 +pytest-django==4.7.0 # via -r requirements/test.txt +python-crontab==3.0.0 + # via + # -r requirements/test.txt + # django-celery-beat +python-dateutil==2.8.2 + # via + # -r requirements/test.txt + # celery + # edx-ace + # faker + # python-crontab + # xblock python-slugify==8.0.1 # via # -r requirements/test.txt # code-annotations -pytz==2023.3 +pytz==2023.3.post1 # via # -r requirements/test.txt # babel # django + # djangorestframework + # edx-completion + # event-tracking + # xblock pyyaml==6.0.1 # via # -r requirements/test.txt # code-annotations -readme-renderer==41.0 + # xblock +readme-renderer==42.0 # via twine +reportlab==4.0.9 + # via -r requirements/test.txt requests==2.31.0 # via + # -r requirements/test.txt + # edx-drf-extensions # requests-toolbelt + # sailthru-client # sphinx # twine requests-toolbelt==1.0.0 @@ -164,15 +415,35 @@ restructuredtext-lint==1.4.0 # via doc8 rfc3986==2.0.0 # via twine -rich==13.5.2 +rich==13.7.0 # via twine +sailthru-client==2.2.3 + # via + # -r requirements/test.txt + # edx-ace secretstorage==3.3.3 # via keyring +semantic-version==2.10.0 + # via + # -r requirements/test.txt + # edx-drf-extensions +simplejson==3.19.2 + # via + # -r requirements/test.txt + # sailthru-client + # xblock six==1.16.0 - # via bleach + # via + # -r requirements/test.txt + # dj-inmemorystorage + # edx-ace + # event-tracking + # fs + # openedx-completion-aggregator + # python-dateutil snowballstemmer==2.2.0 # via sphinx -soupsieve==2.4.1 +soupsieve==2.5 # via beautifulsoup4 sphinx==6.2.1 # via @@ -202,6 +473,9 @@ stevedore==5.1.0 # -r requirements/test.txt # code-annotations # doc8 + # edx-ace + # edx-django-utils + # edx-opaque-keys text-unidecode==1.3 # via # -r requirements/test.txt @@ -216,19 +490,54 @@ tomli==2.0.1 # pytest twine==4.0.2 # via -r requirements/doc.in -typing-extensions==4.7.1 +typing-extensions==4.9.0 # via # -r requirements/test.txt # asgiref + # edx-opaque-keys + # faker + # kombu # pydata-sphinx-theme + # pypdf # rich -urllib3==2.0.4 +tzdata==2023.4 + # via + # -r requirements/test.txt + # backports-zoneinfo + # celery + # django-celery-beat +urllib3==2.1.0 # via + # -r requirements/test.txt # requests # twine -webencodings==0.5.1 - # via bleach -zipp==3.16.2 +vine==5.1.0 + # via + # -r requirements/test.txt + # amqp + # celery + # kombu +wcwidth==0.2.13 + # via + # -r requirements/test.txt + # prompt-toolkit +web-fragments==2.1.0 + # via + # -r requirements/test.txt + # xblock +webob==1.8.7 + # via + # -r requirements/test.txt + # xblock +xblock==1.9.1 + # via + # -r requirements/test.txt + # edx-completion + # openedx-completion-aggregator +zipp==3.17.0 # via # importlib-metadata # importlib-resources + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/requirements/pip-tools.txt b/requirements/pip-tools.txt index 007ed38..0e88226 100644 --- a/requirements/pip-tools.txt +++ b/requirements/pip-tools.txt @@ -4,11 +4,13 @@ # # make upgrade # -build==0.10.0 +build==1.0.3 # via pip-tools click==8.1.7 # via pip-tools -packaging==23.1 +importlib-metadata==7.0.1 + # via build +packaging==23.2 # via build pip-tools==7.3.0 # via -r requirements/pip-tools.in @@ -19,8 +21,10 @@ tomli==2.0.1 # build # pip-tools # pyproject-hooks -wheel==0.41.2 +wheel==0.42.0 # via pip-tools +zipp==3.17.0 + # 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 13c7e84..a4cf530 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -4,11 +4,11 @@ # # make upgrade # -wheel==0.41.2 +wheel==0.42.0 # via -r requirements/pip.in # The following packages are considered to be unsafe in a requirements file: -pip==23.2.1 +pip==23.3.2 # via -r requirements/pip.in -setuptools==68.1.2 +setuptools==69.0.3 # via -r requirements/pip.in diff --git a/requirements/quality.in b/requirements/quality.in index 74b416f..80413cb 100644 --- a/requirements/quality.in +++ b/requirements/quality.in @@ -4,9 +4,6 @@ -r test.txt # Core and testing dependencies for this package -edx-lint # edX pylint rules and plugins -isort # to standardize order of imports -pycodestyle # PEP 8 compliance validation -pydocstyle # PEP 257 compliance validation black # Code formatter ruff # Linter +yamllint # A linter for YAML files. diff --git a/requirements/quality.txt b/requirements/quality.txt index c09c086..ed95d7b 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -4,134 +4,378 @@ # # make upgrade # +amqp==5.2.0 + # via + # -r requirements/test.txt + # kombu +appdirs==1.4.4 + # via + # -r requirements/test.txt + # fs asgiref==3.7.2 # via # -r requirements/test.txt # django -astroid==2.15.6 +attrs==23.2.0 # via - # pylint - # pylint-celery -black==23.7.0 + # -r requirements/test.txt + # edx-ace +backports-zoneinfo[tzdata]==0.2.1 + # via + # -r requirements/test.txt + # backports-zoneinfo + # celery + # django-celery-beat + # django-timezone-field + # kombu +billiard==4.2.0 + # via + # -r requirements/test.txt + # celery +black==23.12.1 # via -r requirements/quality.in +celery==5.3.6 + # via + # -r requirements/test.txt + # django-celery-beat + # edx-celeryutils + # event-tracking + # openedx-completion-aggregator +certifi==2023.11.17 + # via + # -r requirements/test.txt + # requests +cffi==1.16.0 + # via + # -r requirements/test.txt + # cryptography + # pynacl +chardet==5.2.0 + # via + # -r requirements/test.txt + # reportlab +charset-normalizer==3.3.2 + # via + # -r requirements/test.txt + # requests click==8.1.7 # via # -r requirements/test.txt # black - # click-log + # celery + # click-didyoumean + # click-plugins + # click-repl # code-annotations - # edx-lint -click-log==0.4.0 - # via edx-lint + # edx-django-utils +click-didyoumean==0.3.0 + # via + # -r requirements/test.txt + # celery +click-plugins==1.1.1 + # via + # -r requirements/test.txt + # celery +click-repl==0.3.0 + # via + # -r requirements/test.txt + # celery code-annotations==1.5.0 # via # -r requirements/test.txt - # edx-lint -coverage[toml]==7.3.0 + # edx-toggles +coverage[toml]==7.4.0 # via # -r requirements/test.txt + # coverage + # django-coverage-plugin # pytest-cov -dill==0.3.7 - # via pylint -django==3.2.20 +cron-descriptor==1.4.0 + # via + # -r requirements/test.txt + # django-celery-beat +cryptography==41.0.7 + # via + # -r requirements/test.txt + # pyjwt +dj-inmemorystorage==2.1.0 + # via -r requirements/test.txt +django==3.2.23 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/test.txt + # dj-inmemorystorage + # django-celery-beat + # django-crum # django-model-utils + # django-timezone-field + # django-waffle + # djangorestframework + # drf-jwt + # edx-ace + # edx-celeryutils + # edx-completion + # edx-django-utils + # edx-drf-extensions + # edx-toggles + # event-tracking + # jsonfield + # openedx-completion-aggregator +django-celery-beat==2.5.0 + # via -r requirements/test.txt +django-coverage-plugin==3.1.0 + # via -r requirements/test.txt +django-crum==0.7.9 + # via + # -r requirements/test.txt + # edx-django-utils + # edx-toggles django-model-utils==4.3.1 + # via + # -r requirements/test.txt + # edx-celeryutils + # edx-completion + # openedx-completion-aggregator +django-object-actions==4.2.0 # via -r requirements/test.txt -edx-lint==5.3.4 - # via -r requirements/quality.in -exceptiongroup==1.1.3 +django-reverse-admin==2.9.6 + # via -r requirements/test.txt +django-timezone-field==6.1.0 + # via + # -r requirements/test.txt + # django-celery-beat +django-waffle==4.1.0 + # via + # -r requirements/test.txt + # edx-django-utils + # edx-drf-extensions + # edx-toggles +djangorestframework==3.14.0 + # via + # -r requirements/test.txt + # drf-jwt + # edx-completion + # edx-drf-extensions + # openedx-completion-aggregator +drf-jwt==1.19.2 + # via + # -r requirements/test.txt + # edx-drf-extensions +edx-ace==1.7.0 + # via -r requirements/test.txt +edx-celeryutils==1.2.3 + # via + # -r requirements/test.txt + # openedx-completion-aggregator +edx-completion==4.4.0 + # via + # -r requirements/test.txt + # openedx-completion-aggregator +edx-django-utils==5.9.0 + # via + # -r requirements/test.txt + # edx-drf-extensions + # edx-toggles + # event-tracking +edx-drf-extensions==9.1.2 + # via + # -r requirements/test.txt + # edx-completion +edx-opaque-keys==2.5.1 + # via + # -r requirements/test.txt + # edx-completion + # edx-drf-extensions + # openedx-completion-aggregator +edx-toggles==5.1.0 + # via + # -r requirements/test.txt + # edx-completion + # openedx-completion-aggregator +event-tracking==2.2.0 + # via + # -r requirements/test.txt + # edx-completion +exceptiongroup==1.2.0 # via # -r requirements/test.txt # pytest +factory-boy==3.3.0 + # via -r requirements/test.txt +faker==22.1.0 + # via + # -r requirements/test.txt + # factory-boy +fs==2.4.16 + # via + # -r requirements/test.txt + # xblock +idna==3.6 + # via + # -r requirements/test.txt + # requests iniconfig==2.0.0 # via # -r requirements/test.txt # pytest -isort==5.12.0 - # via - # -r requirements/quality.in - # pylint jinja2==3.1.2 # via # -r requirements/test.txt # code-annotations -lazy-object-proxy==1.9.0 - # via astroid +jsonfield==3.1.0 + # via + # -r requirements/test.txt + # edx-celeryutils +kombu==5.3.4 + # via + # -r requirements/test.txt + # celery +lxml==5.1.0 + # via + # -r requirements/test.txt + # xblock +mako==1.3.0 + # via + # -r requirements/test.txt + # xblock markupsafe==2.1.3 # via # -r requirements/test.txt # jinja2 -mccabe==0.7.0 - # via pylint + # mako + # xblock mypy-extensions==1.0.0 # via black -packaging==23.1 +newrelic==9.4.0 + # via + # -r requirements/test.txt + # edx-django-utils +openedx-completion-aggregator==4.0.3 + # via -r requirements/test.txt +packaging==23.2 # via # -r requirements/test.txt # black # pytest -pathspec==0.11.2 - # via black -pbr==5.11.1 +pathspec==0.12.1 + # via + # black + # yamllint +pbr==6.0.0 # via # -r requirements/test.txt # stevedore -platformdirs==3.10.0 +pillow==10.2.0 + # via + # -r requirements/test.txt + # reportlab +platformdirs==3.11.0 # via + # -c requirements/constraints.txt # black - # pylint -pluggy==1.2.0 +pluggy==1.3.0 # via # -r requirements/test.txt # pytest -pycodestyle==2.11.0 - # via -r requirements/quality.in -pydocstyle==6.3.0 - # via -r requirements/quality.in -pylint==2.17.5 +prompt-toolkit==3.0.43 + # via + # -r requirements/test.txt + # click-repl +psutil==5.9.7 + # via + # -r requirements/test.txt + # edx-django-utils +pycparser==2.21 + # via + # -r requirements/test.txt + # cffi +pyjwt[crypto]==2.8.0 + # via + # -r requirements/test.txt + # drf-jwt + # edx-drf-extensions + # pyjwt +pymongo==3.13.0 # via - # edx-lint - # pylint-celery - # pylint-django - # pylint-plugin-utils -pylint-celery==0.3 - # via edx-lint -pylint-django==2.5.3 - # via edx-lint -pylint-plugin-utils==0.8.2 + # -r requirements/test.txt + # edx-opaque-keys + # event-tracking +pynacl==1.5.0 # via - # pylint-celery - # pylint-django -pytest==7.4.0 + # -r requirements/test.txt + # edx-django-utils +pypdf==3.17.4 + # via -r requirements/test.txt +pytest==7.4.4 # via # -r requirements/test.txt # pytest-cov # pytest-django pytest-cov==4.1.0 # via -r requirements/test.txt -pytest-django==4.5.2 +pytest-django==4.7.0 # via -r requirements/test.txt +python-crontab==3.0.0 + # via + # -r requirements/test.txt + # django-celery-beat +python-dateutil==2.8.2 + # via + # -r requirements/test.txt + # celery + # edx-ace + # faker + # python-crontab + # xblock python-slugify==8.0.1 # via # -r requirements/test.txt # code-annotations -pytz==2023.3 +pytz==2023.3.post1 # via # -r requirements/test.txt # django + # djangorestframework + # edx-completion + # event-tracking + # xblock pyyaml==6.0.1 # via # -r requirements/test.txt # code-annotations -ruff==0.0.285 + # xblock + # yamllint +reportlab==4.0.9 + # via -r requirements/test.txt +requests==2.31.0 + # via + # -r requirements/test.txt + # edx-drf-extensions + # sailthru-client +ruff==0.1.11 # via -r requirements/quality.in +sailthru-client==2.2.3 + # via + # -r requirements/test.txt + # edx-ace +semantic-version==2.10.0 + # via + # -r requirements/test.txt + # edx-drf-extensions +simplejson==3.19.2 + # via + # -r requirements/test.txt + # sailthru-client + # xblock six==1.16.0 - # via edx-lint -snowballstemmer==2.2.0 - # via pydocstyle + # via + # -r requirements/test.txt + # dj-inmemorystorage + # edx-ace + # event-tracking + # fs + # openedx-completion-aggregator + # python-dateutil sqlparse==0.4.4 # via # -r requirements/test.txt @@ -140,6 +384,9 @@ stevedore==5.1.0 # via # -r requirements/test.txt # code-annotations + # edx-ace + # edx-django-utils + # edx-opaque-keys text-unidecode==1.3 # via # -r requirements/test.txt @@ -149,16 +396,51 @@ tomli==2.0.1 # -r requirements/test.txt # black # coverage - # pylint # pytest -tomlkit==0.12.1 - # via pylint -typing-extensions==4.7.1 +typing-extensions==4.9.0 # via # -r requirements/test.txt # asgiref - # astroid # black - # pylint -wrapt==1.15.0 - # via astroid + # edx-opaque-keys + # faker + # kombu + # pypdf +tzdata==2023.4 + # via + # -r requirements/test.txt + # backports-zoneinfo + # celery + # django-celery-beat +urllib3==2.1.0 + # via + # -r requirements/test.txt + # requests +vine==5.1.0 + # via + # -r requirements/test.txt + # amqp + # celery + # kombu +wcwidth==0.2.13 + # via + # -r requirements/test.txt + # prompt-toolkit +web-fragments==2.1.0 + # via + # -r requirements/test.txt + # xblock +webob==1.8.7 + # via + # -r requirements/test.txt + # xblock +xblock==1.9.1 + # via + # -r requirements/test.txt + # edx-completion + # openedx-completion-aggregator +yamllint==1.33.0 + # via -r requirements/quality.in + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/requirements/test.in b/requirements/test.in index 6797160..38adc3f 100644 --- a/requirements/test.in +++ b/requirements/test.in @@ -4,5 +4,8 @@ -r base.txt # Core dependencies for this package pytest-cov # pytest extension for code coverage statistics +django-coverage-plugin # https://github.com/nedbat/django_coverage_plugin pytest-django # pytest extension for better Django support code-annotations # provides commands used by the pii_check make target. +dj-inmemorystorage # provides an in-memory storage backend for Django +factory-boy # provides a fixtures replacement for pytest diff --git a/requirements/test.txt b/requirements/test.txt index 2399e49..f8c2c9e 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -4,65 +4,409 @@ # # make upgrade # +amqp==5.2.0 + # via + # -r requirements/base.txt + # kombu +appdirs==1.4.4 + # via + # -r requirements/base.txt + # fs asgiref==3.7.2 # via # -r requirements/base.txt # django +attrs==23.2.0 + # via + # -r requirements/base.txt + # edx-ace +backports-zoneinfo[tzdata]==0.2.1 + # via + # -r requirements/base.txt + # backports-zoneinfo + # celery + # django-celery-beat + # django-timezone-field + # kombu +billiard==4.2.0 + # via + # -r requirements/base.txt + # celery +celery==5.3.6 + # via + # -r requirements/base.txt + # django-celery-beat + # edx-celeryutils + # event-tracking + # openedx-completion-aggregator +certifi==2023.11.17 + # via + # -r requirements/base.txt + # requests +cffi==1.16.0 + # via + # -r requirements/base.txt + # cryptography + # pynacl +chardet==5.2.0 + # via + # -r requirements/base.txt + # reportlab +charset-normalizer==3.3.2 + # via + # -r requirements/base.txt + # requests click==8.1.7 - # via code-annotations + # via + # -r requirements/base.txt + # celery + # click-didyoumean + # click-plugins + # click-repl + # code-annotations + # edx-django-utils +click-didyoumean==0.3.0 + # via + # -r requirements/base.txt + # celery +click-plugins==1.1.1 + # via + # -r requirements/base.txt + # celery +click-repl==0.3.0 + # via + # -r requirements/base.txt + # celery code-annotations==1.5.0 + # via + # -r requirements/base.txt + # -r requirements/test.in + # edx-toggles +coverage[toml]==7.4.0 + # via + # coverage + # django-coverage-plugin + # pytest-cov +cron-descriptor==1.4.0 + # via + # -r requirements/base.txt + # django-celery-beat +cryptography==41.0.7 + # via + # -r requirements/base.txt + # pyjwt +dj-inmemorystorage==2.1.0 # via -r requirements/test.in -coverage[toml]==7.3.0 - # via pytest-cov # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/base.txt + # dj-inmemorystorage + # django-celery-beat + # django-crum # django-model-utils + # django-timezone-field + # django-waffle + # djangorestframework + # drf-jwt + # edx-ace + # edx-celeryutils + # edx-completion + # edx-django-utils + # edx-drf-extensions + # edx-toggles + # event-tracking + # jsonfield + # openedx-completion-aggregator +django-celery-beat==2.5.0 + # via -r requirements/base.txt +django-coverage-plugin==3.1.0 + # via -r requirements/test.in +django-crum==0.7.9 + # via + # -r requirements/base.txt + # edx-django-utils + # edx-toggles django-model-utils==4.3.1 + # via + # -r requirements/base.txt + # edx-celeryutils + # edx-completion + # openedx-completion-aggregator +django-object-actions==4.2.0 + # via -r requirements/base.txt +django-reverse-admin==2.9.6 + # via -r requirements/base.txt +django-timezone-field==6.1.0 + # via + # -r requirements/base.txt + # django-celery-beat +django-waffle==4.1.0 + # via + # -r requirements/base.txt + # edx-django-utils + # edx-drf-extensions + # edx-toggles +djangorestframework==3.14.0 + # via + # -r requirements/base.txt + # drf-jwt + # edx-completion + # edx-drf-extensions + # openedx-completion-aggregator +drf-jwt==1.19.2 + # via + # -r requirements/base.txt + # edx-drf-extensions +edx-ace==1.7.0 # via -r requirements/base.txt -exceptiongroup==1.1.3 +edx-celeryutils==1.2.3 + # via + # -r requirements/base.txt + # openedx-completion-aggregator +edx-completion==4.4.0 + # via + # -r requirements/base.txt + # openedx-completion-aggregator +edx-django-utils==5.9.0 + # via + # -r requirements/base.txt + # edx-drf-extensions + # edx-toggles + # event-tracking +edx-drf-extensions==9.1.2 + # via + # -r requirements/base.txt + # edx-completion +edx-opaque-keys==2.5.1 + # via + # -r requirements/base.txt + # edx-completion + # edx-drf-extensions + # openedx-completion-aggregator +edx-toggles==5.1.0 + # via + # -r requirements/base.txt + # edx-completion + # openedx-completion-aggregator +event-tracking==2.2.0 + # via + # -r requirements/base.txt + # edx-completion +exceptiongroup==1.2.0 # via pytest +factory-boy==3.3.0 + # via -r requirements/test.in +faker==22.1.0 + # via factory-boy +fs==2.4.16 + # via + # -r requirements/base.txt + # xblock +idna==3.6 + # via + # -r requirements/base.txt + # requests iniconfig==2.0.0 # via pytest jinja2==3.1.2 - # via code-annotations + # via + # -r requirements/base.txt + # code-annotations +jsonfield==3.1.0 + # via + # -r requirements/base.txt + # edx-celeryutils +kombu==5.3.4 + # via + # -r requirements/base.txt + # celery +lxml==5.1.0 + # via + # -r requirements/base.txt + # xblock +mako==1.3.0 + # via + # -r requirements/base.txt + # xblock markupsafe==2.1.3 - # via jinja2 -packaging==23.1 + # via + # -r requirements/base.txt + # jinja2 + # mako + # xblock +newrelic==9.4.0 + # via + # -r requirements/base.txt + # edx-django-utils +openedx-completion-aggregator==4.0.3 + # via -r requirements/base.txt +packaging==23.2 # via pytest -pbr==5.11.1 - # via stevedore -pluggy==1.2.0 +pbr==6.0.0 + # via + # -r requirements/base.txt + # stevedore +pillow==10.2.0 + # via + # -r requirements/base.txt + # reportlab +pluggy==1.3.0 # via pytest -pytest==7.4.0 +prompt-toolkit==3.0.43 + # via + # -r requirements/base.txt + # click-repl +psutil==5.9.7 + # via + # -r requirements/base.txt + # edx-django-utils +pycparser==2.21 + # via + # -r requirements/base.txt + # cffi +pyjwt[crypto]==2.8.0 + # via + # -r requirements/base.txt + # drf-jwt + # edx-drf-extensions + # pyjwt +pymongo==3.13.0 + # via + # -r requirements/base.txt + # edx-opaque-keys + # event-tracking +pynacl==1.5.0 + # via + # -r requirements/base.txt + # edx-django-utils +pypdf==3.17.4 + # via -r requirements/base.txt +pytest==7.4.4 # via # pytest-cov # pytest-django pytest-cov==4.1.0 # via -r requirements/test.in -pytest-django==4.5.2 +pytest-django==4.7.0 # via -r requirements/test.in +python-crontab==3.0.0 + # via + # -r requirements/base.txt + # django-celery-beat +python-dateutil==2.8.2 + # via + # -r requirements/base.txt + # celery + # edx-ace + # faker + # python-crontab + # xblock python-slugify==8.0.1 - # via code-annotations -pytz==2023.3 + # via + # -r requirements/base.txt + # code-annotations +pytz==2023.3.post1 # via # -r requirements/base.txt # django + # djangorestframework + # edx-completion + # event-tracking + # xblock pyyaml==6.0.1 - # via code-annotations + # via + # -r requirements/base.txt + # code-annotations + # xblock +reportlab==4.0.9 + # via -r requirements/base.txt +requests==2.31.0 + # via + # -r requirements/base.txt + # edx-drf-extensions + # sailthru-client +sailthru-client==2.2.3 + # via + # -r requirements/base.txt + # edx-ace +semantic-version==2.10.0 + # via + # -r requirements/base.txt + # edx-drf-extensions +simplejson==3.19.2 + # via + # -r requirements/base.txt + # sailthru-client + # xblock +six==1.16.0 + # via + # -r requirements/base.txt + # dj-inmemorystorage + # edx-ace + # event-tracking + # fs + # openedx-completion-aggregator + # python-dateutil sqlparse==0.4.4 # via # -r requirements/base.txt # django stevedore==5.1.0 - # via code-annotations + # via + # -r requirements/base.txt + # code-annotations + # edx-ace + # edx-django-utils + # edx-opaque-keys text-unidecode==1.3 - # via python-slugify + # via + # -r requirements/base.txt + # python-slugify tomli==2.0.1 # via # coverage # pytest -typing-extensions==4.7.1 +typing-extensions==4.9.0 # via # -r requirements/base.txt # asgiref + # edx-opaque-keys + # faker + # kombu + # pypdf +tzdata==2023.4 + # via + # -r requirements/base.txt + # backports-zoneinfo + # celery + # django-celery-beat +urllib3==2.1.0 + # via + # -r requirements/base.txt + # requests +vine==5.1.0 + # via + # -r requirements/base.txt + # amqp + # celery + # kombu +wcwidth==0.2.13 + # via + # -r requirements/base.txt + # prompt-toolkit +web-fragments==2.1.0 + # via + # -r requirements/base.txt + # xblock +webob==1.8.7 + # via + # -r requirements/base.txt + # xblock +xblock==1.9.1 + # via + # -r requirements/base.txt + # edx-completion + # openedx-completion-aggregator + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index d782599..0000000 --- a/setup.cfg +++ /dev/null @@ -1,10 +0,0 @@ -[isort] -include_trailing_comma = True -indent = ' ' -line_length = 120 -multi_line_output = 3 -skip= - migrations - -[wheel] -universal = 1 diff --git a/setup.py b/setup.py index 277b266..379c315 100755 --- a/setup.py +++ b/setup.py @@ -1,15 +1,16 @@ #!/usr/bin/env python -""" -Package metadata for openedx_certificates. -""" +"""Package metadata for openedx_certificates.""" +from __future__ import annotations + import os import re import sys +from pathlib import Path from setuptools import find_packages, setup -def get_version(*file_paths): +def get_version(file_path: Path) -> str: """ Extract the version string from the file. @@ -17,15 +18,16 @@ def get_version(*file_paths): - file_paths: relative path fragments to file with version string """ - filename = os.path.join(os.path.dirname(__file__), *file_paths) - version_file = open(filename, encoding="utf8").read() - version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M) + filename = Path(__file__).parent / file_path + with Path(filename).open(encoding="utf8") as f: + version_file = f.read() + version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M) if version_match: return version_match.group(1) - raise RuntimeError('Unable to find version string.') + raise RuntimeError('Unable to find version string.') # noqa: EM101 -def load_requirements(*requirements_paths): +def load_requirements(*requirements_paths: Path) -> list[str]: # noqa: C901 """ Load all requirements from the specified requirements files. @@ -36,7 +38,7 @@ def load_requirements(*requirements_paths): # e.g. {"django": "Django", "confluent-kafka": "confluent_kafka[avro]"} by_canonical_name = {} - def check_name_consistent(package): + def check_name_consistent(package: str) -> None: """ Raise exception if package is named different ways. @@ -50,9 +52,9 @@ def check_name_consistent(package): 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.' + raise Exception( # noqa: TRY002 + f'Encountered both "{seen_spelling}" and "{package}" in requirements ' # noqa: EM102 + 'and constraints files; please use just one or the other.', ) requirements = {} @@ -62,10 +64,14 @@ def check_name_consistent(package): 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) + fr"([{re_package_name_base_chars}]+(?:\[[{re_package_name_base_chars},\s]+])?)([<>=][^#\s]+)?", ) - def add_version_constraint_or_raise(current_line, current_requirements, add_if_not_present): + def add_version_constraint_or_raise( + current_line: str, + current_requirements: dict[str, str], + add_if_not_present: bool, # noqa: FBT001 + ): regex_match = requirement_line_regex.match(current_line) if regex_match: package = regex_match.group(1) @@ -75,11 +81,12 @@ def add_version_constraint_or_raise(current_line, current_requirements, add_if_n # 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: + # noinspection PyExceptionInherit raise BaseException( - f'Multiple constraint definitions found for {package}:' + f'Multiple constraint definitions found for {package}:' # noqa: EM102 f' "{existing_version_constraints}" and "{version_constraints}".' f'Combine constraints into one location with {package}' - f'{existing_version_constraints},{version_constraints}.' + f'{existing_version_constraints},{version_constraints}.', ) if add_if_not_present or package in current_requirements: current_requirements[package] = version_constraints @@ -87,46 +94,44 @@ def add_version_constraint_or_raise(current_line, current_requirements, add_if_n # 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: + with path.open() as reqs: for line in reqs: if is_requirement(line): - add_version_constraint_or_raise(line, requirements, True) + add_version_constraint_or_raise(line, requirements, add_if_not_present=True) if line and line.startswith('-c') and not line.startswith('-c http'): - constraint_files.add(os.path.dirname(path) + '/' + line.split('#')[0].replace('-c', '').strip()) + constraint_files.add(path.parent / line.split('#')[0].replace('-c', '').strip()) # process constraint files: add constraints to existing requirements for constraint_file in constraint_files: - with open(constraint_file) as reader: + with constraint_file.open() as reader: for line in reader: if is_requirement(line): - add_version_constraint_or_raise(line, requirements, False) + add_version_constraint_or_raise(line, requirements, add_if_not_present=False) # process back into list of pkg><=constraints strings - constrained_requirements = [f'{pkg}{version or ""}' for (pkg, version) in sorted(requirements.items())] - return constrained_requirements + return [f'{pkg}{version or ""}' for (pkg, version) in sorted(requirements.items())] -def is_requirement(line): +def is_requirement(line: str) -> bool: """ Return True if the requirement line is a package requirement. Returns: - bool: True if the line is not blank, a comment, - a URL, or an included file + bool: True if the line is not blank, a comment, a URL, or an included file. """ - return line and line.strip() and not line.startswith(("-r", "#", "-e", "git+", "-c")) + return bool(line and line.strip() and not line.startswith(("-r", "#", "-e", "git+", "-c"))) -VERSION = get_version('openedx_certificates', '__init__.py') +VERSION = get_version(Path('openedx_certificates/__init__.py')) if sys.argv[-1] == 'tag': - print("Tagging the version on github:") - os.system("git tag -a %s -m 'version %s'" % (VERSION, VERSION)) - os.system("git push --tags") + print("Tagging the version on github:") # noqa: T201 + os.system(f"git tag -a {VERSION} -m 'version {VERSION}'") # noqa: S605x + os.system("git push --tags") # noqa: S605, S607 sys.exit() -README = open(os.path.join(os.path.dirname(__file__), 'README.rst'), encoding="utf8").read() -CHANGELOG = open(os.path.join(os.path.dirname(__file__), 'CHANGELOG.rst'), encoding="utf8").read() +README = (Path(__file__).parent / 'README.rst').open(encoding="utf8").read() +CHANGELOG = (Path(__file__).parent / 'CHANGELOG.rst').open(encoding="utf8").read() setup( name='openedx-certificates', @@ -141,8 +146,14 @@ def is_requirement(line): include=['openedx_certificates', 'openedx_certificates.*'], exclude=["*tests"], ), + entry_points={ + "lms.djangoapp": [ + "openedx_certificates = openedx_certificates.apps:OpenedxCertificatesConfig", + ], + }, include_package_data=True, - install_requires=load_requirements('requirements/base.in'), + install_requires=load_requirements(Path('requirements/base.in')), + options={'bdist_wheel': {'universal': True}}, python_requires=">=3.8", license="AGPL 3.0", zip_safe=False, diff --git a/test_settings.py b/test_settings.py index ea4d027..4cdd747 100644 --- a/test_settings.py +++ b/test_settings.py @@ -5,14 +5,12 @@ Django applications, so these settings will not be used. """ -from os.path import abspath, dirname, join +from pathlib import Path -def root(*args): - """ - Get the absolute path of the given path relative to the project root. - """ - return join(abspath(dirname(__file__)), *args) +def root(path: Path) -> Path: + """Get the absolute path of the given path relative to the project root.""" + return Path(__file__).parent.resolve() / path DATABASES = { @@ -23,7 +21,7 @@ def root(*args): 'PASSWORD': '', 'HOST': '', 'PORT': '', - } + }, } INSTALLED_APPS = ( @@ -32,16 +30,25 @@ def root(*args): 'django.contrib.contenttypes', 'django.contrib.messages', 'django.contrib.sessions', + 'completion', + 'completion_aggregator', + 'django_celery_beat', 'openedx_certificates', + 'django_object_actions', ) +MIGRATION_MODULES = { + # the module 'third_party_app' is the one you want to skip + 'completion_aggregator': None, +} + LOCALE_PATHS = [ - root('openedx_certificates', 'conf', 'locale'), + root(Path('openedx_certificates/conf/locale')), ] ROOT_URLCONF = 'openedx_certificates.urls' -SECRET_KEY = 'insecure-secret-key' +SECRET_KEY = 'insecure-secret-key' # noqa: S105 MIDDLEWARE = ( 'django.contrib.auth.middleware.AuthenticationMiddleware', @@ -57,7 +64,12 @@ def root(*args): 'context_processors': [ 'django.contrib.auth.context_processors.auth', # this is required for admin 'django.contrib.messages.context_processors.messages', # this is required for admin + 'django.template.context_processors.request', # this is required for admin ], }, - } + }, ] + +TESTING = True +USE_TZ = True +DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' diff --git a/test_utils/factories.py b/test_utils/factories.py new file mode 100644 index 0000000..db9a407 --- /dev/null +++ b/test_utils/factories.py @@ -0,0 +1,29 @@ +"""Factories for creating test data.""" + +from datetime import datetime + +import factory +from django.contrib.auth.models import User +from factory.django import DjangoModelFactory +from pytz import UTC + + +class UserFactory(DjangoModelFactory): + """A Factory for User objects.""" + + class Meta: # noqa: D106 + model = User + django_get_or_create = ('email', 'username') + + _DEFAULT_PASSWORD = 'test' # noqa: S105 + + username = factory.Sequence('robot{}'.format) + email = factory.Sequence('robot+test+{}@edx.org'.format) + password = factory.django.Password(_DEFAULT_PASSWORD) + first_name = factory.Sequence('Robot{}'.format) + last_name = 'Test' + is_staff = False + is_active = True + is_superuser = False + last_login = datetime(2012, 1, 1, tzinfo=UTC) + date_joined = datetime(2011, 1, 1, tzinfo=UTC) diff --git a/tests/test_generators.py b/tests/test_generators.py new file mode 100644 index 0000000..c5edabf --- /dev/null +++ b/tests/test_generators.py @@ -0,0 +1,297 @@ +"""This module contains unit tests for the generate_pdf_certificate function.""" + +from __future__ import annotations + +import io +from unittest.mock import Mock, call, patch +from uuid import uuid4 + +import pytest +from django.conf import settings +from django.core.files.base import ContentFile +from django.core.files.storage import DefaultStorage, FileSystemStorage +from django.test import override_settings +from inmemorystorage import InMemoryStorage +from opaque_keys.edx.keys import CourseKey +from pypdf import PdfWriter +from pypdf.constants import UserAccessPermissions + +from openedx_certificates.generators import ( + _get_user_name, + _register_font, + _save_certificate, + _write_text_on_template, + generate_pdf_certificate, +) + + +def test_get_user_name(): + """Test the _get_user_name function.""" + user = Mock(first_name="First", last_name="Last") + user.profile.name = "Profile Name" + + # Test when profile name is available + assert _get_user_name(user) == "Profile Name" + + # Test when profile name is not available + user.profile.name = None + assert _get_user_name(user) == "First Last" + + +@patch("openedx_certificates.generators.ExternalCertificateAsset.get_asset_by_slug") +def test_register_font_without_custom_font(mock_get_asset_by_slug: Mock): + """Test the _register_font falls back to the default font when no custom font is specified.""" + options = {} + assert _register_font(options) == "Helvetica" + mock_get_asset_by_slug.assert_not_called() + + +@patch("openedx_certificates.generators.ExternalCertificateAsset.get_asset_by_slug") +@patch('openedx_certificates.generators.TTFont') +@patch("openedx_certificates.generators.pdfmetrics.registerFont") +def test_register_font_with_custom_font(mock_register_font: Mock, mock_font_class: Mock, mock_get_asset_by_slug: Mock): + """Test the _register_font registers the custom font when specified.""" + custom_font = "MyFont" + options = {"font": custom_font} + + mock_get_asset_by_slug.return_value = "font_path" + + assert _register_font(options) == custom_font + mock_get_asset_by_slug.assert_called_once_with(custom_font) + mock_font_class.assert_called_once_with(custom_font, mock_get_asset_by_slug.return_value) + mock_register_font.assert_called_once_with(mock_font_class.return_value) + + +@pytest.mark.parametrize( + ("course_name", "options", "expected"), + [ + ('Programming 101', {}, {}), # No options - use default coordinates and colors. + ( + 'Programming 101', + { + 'name_y': 250, + 'course_name_y': 200, + 'issue_date_y': 150, + 'name_color': '123', + 'course_name_color': '#9B192A', + 'issue_date_color': '#f59a8e', + }, + { + 'name_color': (17 / 255, 34 / 255, 51 / 255), + 'course_name_color': (155 / 255, 25 / 255, 42 / 255), + 'issue_date_color': (245 / 255, 154 / 255, 142 / 255), + }, + ), # Custom coordinates and colors. + ('Programming\n101\nAdvanced Programming', {}, {}), # Multiline course name. + ], +) +@patch('openedx_certificates.generators.canvas.Canvas', return_value=Mock(stringWidth=Mock(return_value=10))) +def test_write_text_on_template(mock_canvas_class: Mock, course_name: str, options: dict[str, int], expected: dict): + """Test the _write_text_on_template function.""" + username = 'John Doe' + course_name = 'Programming 101' + template_height = 300 + template_width = 200 + font = 'Helvetica' + string_width = mock_canvas_class.return_value.stringWidth.return_value + test_date = 'April 1, 2021' + + # Reset the mock to discard calls list from previous tests + mock_canvas_class.reset_mock() + + template_mock = Mock() + template_mock.mediabox = [0, 0, template_width, template_height] + + # Call the function with test parameters and mocks + with patch('openedx_certificates.generators.get_localized_certificate_date', return_value=test_date): + _write_text_on_template(template_mock, font, username, course_name, options) + + # Verifying that Canvas was the correct pagesize. + # Use `call_args_list` to ignore the first argument, which is an instance of io.BytesIO. + assert mock_canvas_class.call_args_list[0][1]['pagesize'] == (template_width, template_height) + + # Mock Canvas object retrieved from Canvas constructor call + canvas_object = mock_canvas_class.return_value + + # Expected coordinates for drawString method, based on fixed stringWidth + expected_name_x = (template_width - string_width) / 2 + expected_name_y = options.get('name_y', 290) + expected_course_name_x = (template_width - string_width) / 2 + expected_course_name_y = options.get('course_name_y', 220) + expected_issue_date_x = (template_width - string_width) / 2 + expected_issue_date_y = options.get('issue_date_y', 120) + + # Expected colors for setFillColorRGB method + expected_name_color = expected.get('name_color', (0, 0, 0)) + expected_course_name_color = expected.get('course_name_color', (0, 0, 0)) + expected_issue_date_color = expected.get('issue_date_color', (0, 0, 0)) + + # The number of calls to drawString should be 2 (name and issue date) + number of lines in course name. + assert canvas_object.drawString.call_count == 3 + course_name.count('\n') + + # Check the calls to setFont, setFillColorRGB and drawString methods on Canvas object + assert canvas_object.setFont.call_args_list[0] == call(font, 32) + assert canvas_object.setFillColorRGB.call_args_list[0] == call(*expected_name_color) + assert canvas_object.drawString.call_args_list[0] == call(expected_name_x, expected_name_y, username) + assert mock_canvas_class.return_value.stringWidth.mock_calls[0][1] == (username,) + + assert canvas_object.setFont.call_args_list[1] == call(font, 28) + assert canvas_object.setFillColorRGB.call_args_list[1] == call(*expected_course_name_color) + + assert canvas_object.setFont.call_args_list[2] == call(font, 12) + assert canvas_object.setFillColorRGB.call_args_list[2] == call(*expected_issue_date_color) + + for line_number, line in enumerate(course_name.split('\n')): + assert mock_canvas_class.return_value.stringWidth.mock_calls[line_number + 1][1] == (line,) + assert canvas_object.drawString.mock_calls[1 + line_number][1] == ( + expected_course_name_x, + expected_course_name_y - (line_number * 28 * 1.1), + line, + ) + + assert mock_canvas_class.return_value.stringWidth.mock_calls[-1][1] == (test_date,) + assert canvas_object.drawString.mock_calls[-1][1] == (expected_issue_date_x, expected_issue_date_y, test_date) + + +@override_settings(LMS_ROOT_URL="https://example.com", MEDIA_URL="media/") +@pytest.mark.parametrize( + "storage", + [ + (InMemoryStorage()), # Test a real storage, without mocking. + (Mock(spec=FileSystemStorage, exists=Mock(return_value=False))), # Test calls in a mocked storage. + # Test calls in a mocked storage when the file already exists. + (Mock(spec=FileSystemStorage, exists=Mock(return_value=True))), + ], +) +@patch('openedx_certificates.generators.secrets.token_hex', return_value='test_token') +@patch('openedx_certificates.generators.ContentFile', autospec=True) +def test_save_certificate(mock_contentfile: Mock, mock_token_hex: Mock, storage: DefaultStorage | Mock): + """Test the _save_certificate function.""" + # Mock the certificate. + certificate = Mock(spec=PdfWriter) + certificate_uuid = uuid4() + output_path = f'external_certificates/{certificate_uuid}.pdf' + pdf_bytes = io.BytesIO() + certificate.write.return_value = pdf_bytes + content_file = ContentFile(pdf_bytes.getvalue()) + mock_contentfile.return_value = content_file + + # Expected values for the encrypt method + expected_pdf_permissions = ( + UserAccessPermissions.PRINT + | UserAccessPermissions.PRINT_TO_REPRESENTATION + | UserAccessPermissions.EXTRACT_TEXT_AND_GRAPHICS + ) + + # Run the function. + with patch('openedx_certificates.generators.default_storage', storage): + url = _save_certificate(certificate, certificate_uuid) + + # Check the calls in a mocked storage. + if isinstance(storage, Mock): + storage.exists.assert_called_once_with(output_path) + storage.save.assert_called_once_with(output_path, content_file) + storage.url.assert_not_called() + if storage.exists.return_value: + storage.delete.assert_called_once_with(output_path) + else: + storage.delete.assert_not_called() + + if isinstance(storage, Mock): + assert url == f'{settings.LMS_ROOT_URL}/media/{output_path}' + else: + assert url == f'/{output_path}' + + # Check the calls to certificate.encrypt + certificate.encrypt.assert_called_once_with( + '', + mock_token_hex(), + permissions_flag=expected_pdf_permissions, + algorithm='AES-256', + ) + + # Allow specifying a custom domain for certificates. + with override_settings(CERTIFICATES_CUSTOM_DOMAIN='https://example2.com'): + url = _save_certificate(certificate, certificate_uuid) + assert url == f'https://example2.com/{certificate_uuid}.pdf' + + +@pytest.mark.parametrize( + ("course_name", "options", "expected_template_slug", "expected_course_name"), + [ + # Default. + ('Test Course', {'template': 'template_slug'}, 'template_slug', 'Test Course'), + # Specify a different template for two-line course names and replace semicolon with newline in course name. + ( + 'Test Course; Test Course', + {'template': 'template_slug', 'template_two_lines': 'template_two_lines_slug'}, + 'template_two_lines_slug', + 'Test Course\n Test Course', + ), + # Do not replace semicolon with newline when the `template_two_lines` option is not specified. + ('Test Course; Test Course', {'template': 'template_slug'}, 'template_slug', 'Test Course; Test Course'), + # Override course name. + ('Test Course', {'template': 'template_slug', 'course_name': 'Override'}, 'template_slug', 'Override'), + # Ignore empty course name override. + ('Test Course', {'template': 'template_slug', 'course_name': ''}, 'template_slug', 'Test Course'), + ], +) +@patch( + 'openedx_certificates.generators.ExternalCertificateAsset.get_asset_by_slug', + return_value=Mock( + open=Mock( + return_value=Mock( + __enter__=Mock(return_value=Mock(read=Mock(return_value=b'pdf_data'))), + __exit__=Mock(return_value=None), + ), + ), + ), +) +@patch('openedx_certificates.generators._get_user_name') +@patch('openedx_certificates.generators.get_course_name') +@patch('openedx_certificates.generators._register_font') +@patch('openedx_certificates.generators.PdfReader') +@patch('openedx_certificates.generators.PdfWriter') +@patch( + 'openedx_certificates.generators._write_text_on_template', + return_value=Mock(getpdfdata=Mock(return_value=b'pdf_data')), +) +@patch('openedx_certificates.generators._save_certificate', return_value='certificate_url') +def test_generate_pdf_certificate( # noqa: PLR0913 + mock_save_certificate: Mock, + mock_write_text_on_template: Mock, + mock_pdf_writer: Mock, + mock_pdf_reader: Mock, + mock_register_font: Mock, + mock_get_course_name: Mock, + mock_get_user_name: Mock, + mock_get_asset_by_slug: Mock, + course_name: str, + options: dict[str, str], + expected_template_slug: str, + expected_course_name: str, +): + """Test the generate_pdf_certificate function.""" + course_id = CourseKey.from_string('course-v1:edX+DemoX+Demo_Course') + user = Mock() + mock_get_course_name.return_value = course_name + + result = generate_pdf_certificate(course_id, user, Mock(), options) + + assert result == 'certificate_url' + mock_get_asset_by_slug.assert_called_with(expected_template_slug) + mock_get_user_name.assert_called_once_with(user) + if options.get('course_name'): + mock_get_course_name.assert_not_called() + else: + mock_get_course_name.assert_called_once_with(course_id) + mock_register_font.assert_called_once_with(options) + assert mock_pdf_reader.call_count == 2 + mock_pdf_writer.assert_called_once_with() + + mock_write_text_on_template.assert_called_once() + _, args, _kwargs = mock_write_text_on_template.mock_calls[0] + assert args[-2] == expected_course_name + assert args[-1] == options + + mock_save_certificate.assert_called_once() diff --git a/tests/test_models.py b/tests/test_models.py index c366a62..f07e58d 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,30 +1,331 @@ -#!/usr/bin/env python -""" -Tests for the `openedx-certificates` models module. -""" +"""Tests for the `openedx-certificates` models.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from unittest.mock import Mock, patch +from uuid import UUID, uuid4 import pytest +from django.core.exceptions import ValidationError +from django.db import IntegrityError +from django_celery_beat.models import PeriodicTask + +from openedx_certificates.exceptions import CertificateGenerationError +from openedx_certificates.models import ( + ExternalCertificate, + ExternalCertificateCourseConfiguration, + ExternalCertificateType, +) +from test_utils.factories import UserFactory + +if TYPE_CHECKING: + from django.db.models import Model + from django.contrib.auth.models import User + from opaque_keys.edx.keys import CourseKey -class TestExternalCertificateConfiguration: - """ - Tests of the ExternalCertificateConfiguration model. - """ +def _mock_retrieval_func(_course_id: CourseKey, _options: dict[str, Any]) -> list[int]: + return [1, 2, 3] - @pytest.mark.skip(reason="Placeholder to allow pytest to succeed before real tests are in place.") - def test_placeholder(self): - """ - TODO: Delete this test once there are real tests. - """ + +def _mock_generation_func(_course_id: CourseKey, _user: User, _certificate_uuid: UUID, _options: dict[str, Any]) -> str: + return "test_url" class TestExternalCertificateType: - """ - Tests of the ExternalCertificateType model. - """ - - @pytest.mark.skip(reason="Placeholder to allow pytest to succeed before real tests are in place.") - def test_placeholder(self): - """ - TODO: Delete this test once there are real tests. - """ + """Tests for the ExternalCertificateType model.""" + + def test_str(self): + """Test the string representation of the model.""" + certificate_type = ExternalCertificateType(name="Test Type") + assert str(certificate_type) == "Test Type" + + def test_clean_with_valid_functions(self): + """Test the clean method with valid function paths.""" + certificate_type = ExternalCertificateType( + name="Test Type", + retrieval_func="test_models._mock_retrieval_func", + generation_func="test_models._mock_generation_func", + ) + certificate_type.clean() + + @pytest.mark.parametrize("function_path", ["", "invalid_format_func"]) + def test_clean_with_invalid_function_format(self, function_path: str): + """Test the clean method with invalid function format.""" + certificate_type = ExternalCertificateType( + name="Test Type", + retrieval_func="test_models._mock_retrieval_func", + generation_func=function_path, + ) + with pytest.raises(ValidationError) as exc: + certificate_type.clean() + assert "Function path must be in format 'module.function_name'" in str(exc.value) + + def test_clean_with_invalid_function(self): + """Test the clean method with invalid function paths.""" + certificate_type = ExternalCertificateType( + name="Test Type", + retrieval_func="test_models._mock_retrieval_func", + generation_func="invalid.module.path", + ) + with pytest.raises(ValidationError) as exc: + certificate_type.clean() + assert ( + f"The function {certificate_type.generation_func} could not be found. Please provide a valid path" + in str(exc.value) + ) + + +class TestExternalCertificateCourseConfiguration: + """Tests for the ExternalCertificateCourseConfiguration model.""" + + def setup_method(self): + """Prepare the test data.""" + self.certificate_type = ExternalCertificateType( + name="Test Type", + retrieval_func="test_models._mock_retrieval_func", + generation_func="test_models._mock_generation_func", + ) + self.course_config = ExternalCertificateCourseConfiguration( + course_id="course-v1:TestX+T101+2023", + certificate_type=self.certificate_type, + ) + + @pytest.mark.django_db() + def test_periodic_task_is_auto_created(self): + """Test that a periodic task is automatically created for the new configuration.""" + self.certificate_type.save() + self.course_config.save() + self.course_config.refresh_from_db() + + assert (periodic_task := self.course_config.periodic_task) is not None + assert periodic_task.enabled is False + assert periodic_task.name == str(self.course_config) + assert periodic_task.args == f'[{self.course_config.id}]' + assert periodic_task.task == 'openedx_certificates.tasks.generate_certificates_for_course_task' + + @pytest.mark.django_db() + def test_periodic_task_is_deleted_on_deletion(self): + """Test that the periodic task is deleted when the configuration is deleted.""" + self.certificate_type.save() + self.course_config.save() + assert PeriodicTask.objects.count() == 1 + + self.course_config.delete() + assert not PeriodicTask.objects.exists() + + @pytest.mark.django_db() + def test_periodic_task_deletion_removes_the_configuration(self): + """Test that the configuration is deleted when the periodic task is deleted.""" + self.certificate_type.save() + self.course_config.save() + assert PeriodicTask.objects.count() == 1 + + self.course_config.periodic_task.delete() + assert not ExternalCertificateCourseConfiguration.objects.exists() + + @pytest.mark.django_db() + @pytest.mark.parametrize( + ("deleted_model", "verified_model"), + [ + (ExternalCertificateCourseConfiguration, PeriodicTask), # `post_delete` signal. + (PeriodicTask, ExternalCertificateCourseConfiguration), # Cascade deletion of the `OneToOneField`. + ], + ) + def test_bulk_delete(self, deleted_model: type[Model], verified_model: type[Model]): + """Test that the bulk deletion of configurations removes the periodic tasks (and vice versa).""" + self.certificate_type.save() + self.course_config.save() + + ExternalCertificateCourseConfiguration( + course_id="course-v1:TestX+T101+2024", + certificate_type=self.certificate_type, + ).save() + assert PeriodicTask.objects.count() == 2 + + deleted_model.objects.all().delete() + assert not verified_model.objects.exists() + + def test_str_representation(self): + """Test the string representation of the model.""" + assert str(self.course_config) == f'{self.certificate_type.name} in course-v1:TestX+T101+2023' + + def test_get_eligible_user_ids(self): + """Test the get_eligible_user_ids method.""" + eligible_user_ids = self.course_config.get_eligible_user_ids() + assert eligible_user_ids == [1, 2, 3] + + @pytest.mark.django_db() + def test_filter_out_user_ids_with_certificates(self): + """Test the filter_out_user_ids_with_certificates method.""" + self.certificate_type.save() + self.course_config.save() + + cert_data = { + "course_id": self.course_config.course_id, + "certificate_type": self.certificate_type.name, + } + + ExternalCertificate.objects.create( + uuid=uuid4(), + user_id=1, + status=ExternalCertificate.Status.GENERATING, + **cert_data, + ) + ExternalCertificate.objects.create( + uuid=uuid4(), + user_id=2, + status=ExternalCertificate.Status.AVAILABLE, + **cert_data, + ) + ExternalCertificate.objects.create( + uuid=uuid4(), + user_id=3, + status=ExternalCertificate.Status.ERROR, + **cert_data, + ) + ExternalCertificate.objects.create( + uuid=uuid4(), + user_id=4, + status=ExternalCertificate.Status.INVALIDATED, + **cert_data, + ) + ExternalCertificate.objects.create( + uuid=uuid4(), + user_id=5, + status=ExternalCertificate.Status.ERROR, + **cert_data, + ) + + filtered_users = self.course_config.filter_out_user_ids_with_certificates([1, 2, 3, 4, 6]) + assert filtered_users == [3, 6] + + @pytest.mark.django_db() + @patch.object(ExternalCertificate, 'send_email') + def test_generate_certificate_for_user(self, mock_send_email: Mock): + """Test the generate_certificate_for_user method.""" + user = UserFactory.create() + task_id = 123 + + self.course_config.generate_certificate_for_user(user.id, task_id) + assert ExternalCertificate.objects.filter( + user_id=user.id, + course_id=self.course_config.course_id, + certificate_type=self.certificate_type, + user_full_name=f"{user.first_name} {user.last_name}", + status=ExternalCertificate.Status.AVAILABLE, + generation_task_id=task_id, + download_url="test_url", + ).exists() + mock_send_email.assert_called_once() + + # For now, we only prevent the generation task from sending emails to inactive users. + # In the future, we may want to prevent the generation task from generating certificates for inactive users. + + user = UserFactory.create(is_active=False) + + self.course_config.generate_certificate_for_user(user.id, task_id) + assert ExternalCertificate.objects.filter(course_id=self.course_config.course_id).count() == 2 + mock_send_email.assert_called_once() + + user = UserFactory.create() + user.set_unusable_password() + user.save() + + self.course_config.generate_certificate_for_user(user.id, task_id) + assert ExternalCertificate.objects.filter(course_id=self.course_config.course_id).count() == 3 + mock_send_email.assert_called_once() + + @pytest.mark.django_db() + @patch.object(ExternalCertificate, 'send_email') + def test_generate_certificate_for_user_update_existing(self, mock_send_email: Mock): + """Test the generate_certificate_for_user method updates an existing certificate.""" + user = UserFactory.create() + + ExternalCertificate.objects.create( + user_id=user.id, + course_id=self.course_config.course_id, + certificate_type=self.certificate_type, + user_full_name="Random Name", + status=ExternalCertificate.Status.ERROR, + generation_task_id=123, + download_url="random_url", + ) + + self.course_config.generate_certificate_for_user(user.id) + assert ExternalCertificate.objects.filter( + user_id=user.id, + course_id=self.course_config.course_id, + certificate_type=self.certificate_type, + user_full_name=f"{user.first_name} {user.last_name}", + status=ExternalCertificate.Status.AVAILABLE, + generation_task_id=0, + download_url="test_url", + ).exists() + mock_send_email.assert_called_once() + + @pytest.mark.django_db() + @patch('openedx_certificates.models.import_module') + def test_generate_certificate_for_user_with_exception(self, mock_module: Mock): + """Test the generate_certificate_for_user handles the case when the generation function raises an exception.""" + user = UserFactory.create() + task_id = 123 + + def mock_func_raise_exception(*_args, **_kwargs): + msg = "Test Exception" + raise RuntimeError(msg) + + mock_module.return_value = mock_func_raise_exception + + # Call the method under test and check that it raises the correct exception. + with pytest.raises(CertificateGenerationError) as exc: + self.course_config.generate_certificate_for_user(user.id, task_id) + + assert 'Failed to generate the' in str(exc.value) + assert ExternalCertificate.objects.filter( + user_id=user.id, + course_id=self.course_config.course_id, + certificate_type=self.certificate_type, + user_full_name=f"{user.first_name} {user.last_name}", + status=ExternalCertificate.Status.ERROR, + generation_task_id=task_id, + download_url='', + ).exists() + + +class TestExternalCertificate: + """Tests for the ExternalCertificate model.""" + + def setup_method(self): + """Prepare the test data.""" + self.certificate = ExternalCertificate( + uuid=uuid4(), + user_id=1, + user_full_name='Test User', + course_id='course-v1:TestX+T101+2023', + certificate_type='Test Type', + status=ExternalCertificate.Status.GENERATING, + download_url='http://www.test.com', + generation_task_id='12345', + ) + + def test_str_representation(self): + """Test the string representation of a certificate.""" + assert str(self.certificate) == 'Test Type for Test User in course-v1:TestX+T101+2023' + + @pytest.mark.django_db() + def test_unique_together_constraint(self): + """Test that the unique_together constraint is enforced.""" + self.certificate.save() + certificate_2_info = { + "uuid": uuid4(), + "user_id": 1, + "user_full_name": 'Test User 2', + "course_id": 'course-v1:TestX+T101+2023', + "certificate_type": 'Test Type', + "status": ExternalCertificate.Status.GENERATING, + "download_url": 'http://www.test2.com', + "generation_task_id": '122345', + } + with pytest.raises(IntegrityError): + ExternalCertificate.objects.create(**certificate_2_info) diff --git a/tests/test_processors.py b/tests/test_processors.py new file mode 100644 index 0000000..4ab9068 --- /dev/null +++ b/tests/test_processors.py @@ -0,0 +1,280 @@ +"""Tests for the certificate processors.""" +from __future__ import annotations + +from unittest.mock import Mock, call, patch + +import pytest +from django.http import QueryDict +from opaque_keys.edx.keys import CourseKey + +# noinspection PyProtectedMember +from openedx_certificates.processors import ( + _are_grades_passing_criteria, + _get_category_weights, + _get_grades_by_format, + _prepare_request_to_completion_aggregator, + retrieve_course_completions, + retrieve_subsection_grades, +) + + +@patch( + 'openedx_certificates.processors.get_course_grading_policy', + return_value=[{'type': 'Homework', 'weight': 0.15}, {'type': 'Exam', 'weight': 0.85}], +) +def test_get_category_weights(mock_get_course_grading_policy: Mock): + """Check that the course grading policy is retrieved and the category weights are calculated correctly.""" + course_id = Mock(spec=CourseKey) + assert _get_category_weights(course_id) == {'homework': 0.15, 'exam': 0.85} + mock_get_course_grading_policy.assert_called_once_with(course_id) + + +@patch('openedx_certificates.processors.prefetch_course_grades') +@patch('openedx_certificates.processors.get_course_grade_factory') +def test_get_grades_by_format(mock_get_course_grade_factory: Mock, mock_prefetch_course_grades: Mock): + """Test that grades are retrieved for each user and categorized by assignment types.""" + course_id = Mock(spec=CourseKey) + users = [Mock(name="User1", id=101), Mock(name="User2", id=102)] + + mock_read_grades = Mock() + mock_read_grades.return_value.graded_subsections_by_format.return_value = { + 'Homework': {'subsection1': Mock(graded_total=Mock(earned=50.0, possible=100.0))}, + 'Exam': {'subsection2': Mock(graded_total=Mock(earned=90.0, possible=100.0))}, + } + mock_get_course_grade_factory.return_value.read = mock_read_grades + + result = _get_grades_by_format(course_id, users) + + assert result == {101: {'homework': 50.0, 'exam': 90.0}, 102: {'homework': 50.0, 'exam': 90.0}} + mock_prefetch_course_grades.assert_called_once_with(course_id, users) + mock_get_course_grade_factory.assert_called_once() + + mock_read_grades.assert_has_calls( + [ + call(users[0], course_key=course_id), + call().graded_subsections_by_format(), + call(users[1], course_key=course_id), + call().graded_subsections_by_format(), + ], + ) + + +_are_grades_passing_criteria_test_data = [ + ( + "All grades are passing", + {"homework": 90, "lab": 90, "exam": 90}, + {"homework": 85, "lab": 80, "exam": 60, "total": 50}, + {"homework": 0.3, "lab": 0.3, "exam": 0.4}, + True, + ), + ( + "The homework grade is failing", + {"homework": 80, "lab": 90, "exam": 70}, + {"homework": 85, "lab": 80, "exam": 60, "total": 50}, + {"homework": 0.3, "lab": 0.3, "exam": 0.4}, + False, + ), + ( + "The total grade is failing", + {"homework": 90, "lab": 90, "exam": 70}, + {"homework": 85, "lab": 80, "exam": 60, "total": 300}, + {"homework": 0.3, "lab": 0.3, "exam": 0.4}, + False, + ), + ( + "Only the total grade is required", + {"homework": 90, "lab": 90, "exam": 70}, + {"total": 50}, + {"homework": 0.3, "lab": 0.3, "exam": 0.4}, + True, + ), + ( + "Total grade is not required", + {"homework": 90, "lab": 90, "exam": 70}, + {"homework": 85, "lab": 80}, + {"homework": 0.3, "lab": 0.3, "exam": 0.4}, + True, + ), + ( + "Required grades are not defined", + {"homework": 80, "lab": 90, "exam": 70}, + {}, + {"homework": 0.3, "lab": 0.3, "exam": 0.4}, + True, + ), + ( + "User has no grades", + {}, + {"homework": 85, "lab": 80, "exam": 60, "total": 240}, + {"homework": 0.3, "lab": 0.3, "exam": 0.4}, + False, + ), + ("User has no grades and the required grades are not defined", {}, {}, {}, True), + ( + "User has no grades in a required category", + {"homework": 90, "lab": 85}, + {"homework": 85, "lab": 80, "exam": 60}, + {"homework": 0.3, "lab": 0.3, "exam": 0.4}, + False, + ), +] + + +@pytest.mark.parametrize( + ('desc', 'user_grades', 'required_grades', 'category_weights', 'expected'), + _are_grades_passing_criteria_test_data, + ids=[i[0] for i in _are_grades_passing_criteria_test_data], +) +def test_are_grades_passing_criteria( + desc: str, # noqa: ARG001 + user_grades: dict[str, float], + required_grades: dict[str, float], + category_weights: dict[str, float], + expected: bool, # noqa: FBT001 +): + """Test that the user grades are compared to the required grades correctly.""" + assert _are_grades_passing_criteria(user_grades, required_grades, category_weights) == expected + + +def test_are_grades_passing_criteria_invalid_grade_category(): + """Test that an exception is raised if user grades contain a category that is not defined in the grading policy.""" + with pytest.raises(ValueError, match='unknown_category'): + _are_grades_passing_criteria( + {"homework": 90, "unknown_category": 90}, + {"total": 175}, + {"homework": 0.5, "lab": 0.5}, + ) + + +@patch('openedx_certificates.processors.get_course_enrollments') +@patch('openedx_certificates.processors._get_grades_by_format') +@patch('openedx_certificates.processors._get_category_weights') +@patch('openedx_certificates.processors._are_grades_passing_criteria') +def test_retrieve_subsection_grades( + mock_are_grades_passing_criteria: Mock, + mock_get_category_weights: Mock, + mock_get_grades_by_format: Mock, + mock_get_course_enrollments: Mock, +): + """Test that the function returns the eligible users.""" + course_id = Mock(spec=CourseKey) + options = { + 'required_grades': { + 'homework': 0.4, + 'exam': 0.9, + 'total': 0.8, + }, + } + users = [Mock(name="User1", id=101), Mock(name="User2", id=102)] + grades = { + 101: {'homework': 0.5, 'exam': 0.9}, + 102: {'homework': 0.3, 'exam': 0.95}, + } + required_grades = {'homework': 40.0, 'exam': 90.0, 'total': 80.0} + weights = {'homework': 0.2, 'exam': 0.7, 'lab': 0.1} + + mock_get_course_enrollments.return_value = users + mock_get_grades_by_format.return_value = grades + mock_get_category_weights.return_value = weights + mock_are_grades_passing_criteria.side_effect = [True, False] + + result = retrieve_subsection_grades(course_id, options) + + assert result == [101] + mock_get_course_enrollments.assert_called_once_with(course_id) + mock_get_grades_by_format.assert_called_once_with(course_id, users) + mock_get_category_weights.assert_called_once_with(course_id) + mock_are_grades_passing_criteria.assert_has_calls( + [ + call(grades[101], required_grades, weights), + call(grades[102], required_grades, weights), + ], + ) + + +def test_prepare_request_to_completion_aggregator(): + """Test that the request to the completion aggregator API is prepared correctly.""" + course_id = Mock(spec=CourseKey) + query_params = {'param1': 'value1', 'param2': 'value2'} + url = '/test_url/' + + with patch('openedx_certificates.processors.get_user_model') as mock_get_user_model, patch( + 'openedx_certificates.processors.CompletionDetailView', + ) as mock_view_class: + staff_user = Mock(is_staff=True) + mock_get_user_model().objects.filter().first.return_value = staff_user + + view = _prepare_request_to_completion_aggregator(course_id, query_params, url) + + mock_view_class.assert_called_once() + assert view.request.course_id == course_id + # noinspection PyUnresolvedReferences + assert view._effective_user is staff_user + assert isinstance(view, mock_view_class.return_value.__class__) + + # Create a QueryDict from the query_params dictionary. + query_params_qdict = QueryDict('', mutable=True) + query_params_qdict.update(query_params) + assert view.request.query_params.urlencode() == query_params_qdict.urlencode() + + +@patch('openedx_certificates.processors._prepare_request_to_completion_aggregator') +@patch('openedx_certificates.processors.get_user_model') +def test_retrieve_course_completions(mock_get_user_model: Mock, mock_prepare_request_to_completion_aggregator: Mock): + """Test that we retrieve the course completions for all users and return IDs of users who meet the criteria.""" + course_id = Mock(spec=CourseKey) + options = {'required_completion': 0.8} + completions_page1 = { + 'pagination': {'next': '/completion-aggregator/v1/course/{course_id}/?page=2&page_size=1000'}, + 'results': [ + {'username': 'user1', 'completion': {'percent': 0.9}}, + ], + } + completions_page2 = { + 'pagination': {'next': None}, + 'results': [ + {'username': 'user2', 'completion': {'percent': 0.7}}, + {'username': 'user3', 'completion': {'percent': 0.8}}, + ], + } + + mock_view_page1 = Mock() + mock_view_page1.get.return_value.data = completions_page1 + mock_view_page2 = Mock() + mock_view_page2.get.return_value.data = completions_page2 + mock_prepare_request_to_completion_aggregator.side_effect = [mock_view_page1, mock_view_page2] + + def filter_side_effect(*_args, **kwargs) -> list[int]: + """ + A mock side effect function for User.objects.filter(). + + It allows testing this code without a database access. + + :returns: The user IDs corresponding to the provided usernames. + """ + usernames = kwargs['username__in'] + + values_list_mock = Mock() + values_list_mock.return_value = [username_id_map[username] for username in usernames] + queryset_mock = Mock() + queryset_mock.values_list = values_list_mock + + return queryset_mock + + username_id_map = {"user1": 1, "user2": 2, "user3": 3} + mock_user_model = Mock() + mock_user_model.objects.filter.side_effect = filter_side_effect + mock_get_user_model.return_value = mock_user_model + + result = retrieve_course_completions(course_id, options) + + assert result == [1, 3] + mock_prepare_request_to_completion_aggregator.assert_has_calls( + [ + call(course_id, {'page_size': 1000, 'page': 1}, f'/completion-aggregator/v1/course/{course_id}/'), + call(course_id, {'page_size': 1000, 'page': 2}, f'/completion-aggregator/v1/course/{course_id}/'), + ], + ) + mock_view_page1.get.assert_called_once_with(mock_view_page1.request, str(course_id)) + mock_view_page2.get.assert_called_once_with(mock_view_page2.request, str(course_id)) + mock_user_model.objects.filter.assert_called_once_with(username__in=['user1', 'user3']) diff --git a/tests/test_tasks.py b/tests/test_tasks.py new file mode 100644 index 0000000..439f4ac --- /dev/null +++ b/tests/test_tasks.py @@ -0,0 +1,79 @@ +"""Tests for the openedx-certificates Celery tasks.""" + +from unittest.mock import MagicMock, Mock, PropertyMock, patch + +import pytest + +from openedx_certificates.tasks import ( + generate_all_certificates_task, + generate_certificate_for_user_task, + generate_certificates_for_course_task, +) + + +@pytest.mark.django_db() +def test_generate_certificate_for_user(): + """Test if the `generate_certificate_for_user` method is called with correct parameters.""" + course_config_id = 123 + user_id = 456 + task_id = 789 + + with patch('openedx_certificates.models.ExternalCertificateCourseConfiguration.objects.get') as mock_get, patch( + 'openedx_certificates.tasks.generate_certificate_for_user_task', + ) as mock_task: + mock_config = Mock() + mock_get.return_value = mock_config + + mock_request = Mock() + type(mock_request).id = PropertyMock(return_value=task_id) + type(mock_task).request = PropertyMock(return_value=mock_request) + + # Call the actual task + generate_certificate_for_user_task(course_config_id, user_id) + + mock_config.generate_certificate_for_user.assert_called_once_with(user_id, task_id) + + +@pytest.mark.django_db() +def test_generate_certificates_for_course_with_filtering(): + """Test if `generate_certificate_for_user_task.delay` is called for each filtered eligible user.""" + course_config_id = 123 + all_eligible_user_ids = [1, 2, 3, 4] # Initial set of eligible user IDs + filtered_user_ids = [1, 3] # User IDs after filtering (e.g., users 2 and 4 already have certificates) + + with patch('openedx_certificates.models.ExternalCertificateCourseConfiguration.objects.get') as mock_get, patch( + 'openedx_certificates.tasks.generate_certificate_for_user_task.delay', + ) as mock_delay: + mock_config = Mock() + mock_get.return_value = mock_config + + # Mocking the methods to return predefined lists + mock_config.get_eligible_user_ids.return_value = all_eligible_user_ids + mock_config.filter_out_user_ids_with_certificates.return_value = filtered_user_ids + + generate_certificates_for_course_task(course_config_id) + + # Ensure that the delay method is called only for filtered user IDs + assert mock_delay.call_count == len(filtered_user_ids) + for user_id in filtered_user_ids: + mock_delay.assert_any_call(course_config_id, user_id) + + +@pytest.mark.django_db() +def test_generate_all_certificates(): + """Test if `generate_certificates_for_course_task.delay` is called for each enabled configuration.""" + config_ids = [101, 102, 103] + + # Create a mock QuerySet + mock_queryset = MagicMock() + mock_queryset.values_list.return_value = config_ids + + with patch( + 'openedx_certificates.models.ExternalCertificateCourseConfiguration.get_enabled_configurations', + return_value=mock_queryset, + ), patch('openedx_certificates.tasks.generate_certificates_for_course_task.delay') as mock_delay: + generate_all_certificates_task() + + assert mock_delay.call_count == len(config_ids) + for config_id in config_ids: + mock_delay.assert_any_call(config_id) diff --git a/tox.ini b/tox.ini index fb40586..de01ad7 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py38-django{32,40} +envlist = py38-django{32,40},docs,quality,pii_check,package [doc8] ; D001 = Line too long @@ -29,11 +29,6 @@ max-line-length = 120 ignore = D101,D200,D203,D212,D215,D404,D405,D406,D407,D408,D409,D410,D411,D412,D413,D414 match-dir = (?!migrations) -[pytest] -DJANGO_SETTINGS_MODULE = test_settings -addopts = --cov openedx_certificates --cov tests --cov-report term-missing --cov-report xml -norecursedirs = .* docs requirements site-packages - [testenv] deps = django32: Django>=3.2,<4.0 @@ -41,15 +36,16 @@ deps = -r{toxinidir}/requirements/test.txt commands = python manage.py check + python manage.py makemigrations openedx_certificates --check --dry-run --verbosity 3 pytest {posargs} [testenv:docs] setenv = DJANGO_SETTINGS_MODULE = test_settings PYTHONPATH = {toxinidir} - # Adding the option here instead of as a default in the docs Makefile because that Makefile is generated by shpinx. + # Adding the option here instead of as a default in the docs Makefile because that Makefile is generated by sphinx. SPHINXOPTS = -W -whitelist_externals = +allowlist_externals = make rm deps = @@ -64,19 +60,13 @@ commands = twine check dist/* [testenv:quality] -whitelist_externals = +allowlist_externals = make - rm - touch deps = -r{toxinidir}/requirements/quality.txt commands = - touch tests/__init__.py - pylint openedx_certificates tests test_utils manage.py setup.py - rm tests/__init__.py - pycodestyle openedx_certificates tests manage.py setup.py - pydocstyle openedx_certificates tests manage.py setup.py - isort --check-only --diff tests test_utils openedx_certificates manage.py setup.py test_settings.py + ruff . + yamllint --strict --format parsable . make selfcheck [testenv:pii_check]