├── .annotation_safe_list.yml ├── .coveragerc ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── add-depr-ticket-to-depr-board.yml │ ├── add-remove-label-on-comment.yml │ ├── ci.yml │ ├── codeql-analysis.yml │ ├── commitlint.yml │ ├── migrations-mysql8-check.yml │ ├── self-assign-issue.yml │ ├── trivy-code-scanning.yml │ └── upgrade-python-requirements.yml ├── .gitignore ├── .pep8 ├── .pii_annotations.yml ├── .pycodestyle ├── AUTHORS ├── LICENSE.TXT ├── Makefile ├── README.rst ├── catalog-info.yaml ├── conftest.py ├── db_keyword_overrides.yml ├── manage.py ├── notesapi ├── __init__.py ├── urls.py └── v1 │ ├── __init__.py │ ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── bulk_create_notes.py │ │ └── data │ │ └── basic_words.txt │ ├── migrations │ ├── 0001_initial.py │ ├── 0002_note_tags.py │ ├── 0003_auto_20200703_1515.py │ └── __init__.py │ ├── models.py │ ├── paginators.py │ ├── permissions.py │ ├── search_indexes │ ├── __init__.py │ ├── backends │ │ ├── __init__.py │ │ └── note.py │ ├── documents │ │ ├── __init__.py │ │ ├── analyzers.py │ │ └── note.py │ ├── paginators.py │ └── serializers │ │ ├── __init__.py │ │ └── note.py │ ├── serializers.py │ ├── tests │ ├── __init__.py │ ├── helpers.py │ ├── test_meilisearch.py │ ├── test_models.py │ ├── test_update_index.py │ └── test_views.py │ ├── urls.py │ ├── utils.py │ └── views │ ├── __init__.py │ ├── common.py │ ├── elasticsearch.py │ ├── exceptions.py │ ├── meilisearch.py │ └── mysql.py ├── notesserver ├── __init__.py ├── docker-compose.test.yml ├── docker_gunicorn_configuration.py ├── settings │ ├── __init__.py │ ├── common.py │ ├── dev.py │ ├── devstack.py │ ├── logger.py │ ├── test.py │ ├── test_es_disabled.py │ └── yaml_config.py ├── test_views.py ├── urls.py ├── views.py └── wsgi.py ├── pylintrc ├── pylintrc_tweaks ├── pytest.ini ├── requirements ├── base.in ├── base.txt ├── ci.in ├── ci.txt ├── constraints.txt ├── django.txt ├── pip-tools.in ├── pip-tools.txt ├── pip.in ├── pip.txt ├── quality.in ├── quality.txt ├── test.in └── test.txt └── tox.ini /.annotation_safe_list.yml: -------------------------------------------------------------------------------- 1 | auth.Group: 2 | ".. no_pii::" : "No PII" 3 | auth.Permission: 4 | ".. no_pii::" : "No PII" 5 | auth.User: 6 | ".. pii::": "username and email address" 7 | ".. pii_types::" : name, other 8 | ".. pii_retirement::" : local_api 9 | contenttypes.ContentType: 10 | ".. no_pii::": "No PII" 11 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | omit = notesserver/settings* 4 | *wsgi.py 5 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Code owners for edx-notes-api 2 | 3 | # These owners will be the default owners for everything in 4 | # the repo. Unless a later match takes precedence, they will 5 | # be requested for review when someone opens a pull request. 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Adding new check for github-actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | -------------------------------------------------------------------------------- /.github/workflows/add-depr-ticket-to-depr-board.yml: -------------------------------------------------------------------------------- 1 | # Run the workflow that adds new tickets that are either: 2 | # - labelled "DEPR" 3 | # - title starts with "[DEPR]" 4 | # - body starts with "Proposal Date" (this is the first template field) 5 | # to the org-wide DEPR project board 6 | 7 | name: Add newly created DEPR issues to the DEPR project board 8 | 9 | on: 10 | issues: 11 | types: [opened] 12 | 13 | jobs: 14 | routeissue: 15 | uses: openedx/.github/.github/workflows/add-depr-ticket-to-depr-board.yml@master 16 | secrets: 17 | GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }} 18 | GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }} 19 | SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }} 20 | -------------------------------------------------------------------------------- /.github/workflows/add-remove-label-on-comment.yml: -------------------------------------------------------------------------------- 1 | # This workflow runs when a comment is made on the ticket 2 | # If the comment starts with "label: " it tries to apply 3 | # the label indicated in rest of comment. 4 | # If the comment starts with "remove label: ", it tries 5 | # to remove the indicated label. 6 | # Note: Labels are allowed to have spaces and this script does 7 | # not parse spaces (as often a space is legitimate), so the command 8 | # "label: really long lots of words label" will apply the 9 | # label "really long lots of words label" 10 | 11 | name: Allows for the adding and removing of labels via comment 12 | 13 | on: 14 | issue_comment: 15 | types: [created] 16 | 17 | jobs: 18 | add_remove_labels: 19 | uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master 20 | 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Django CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: 8 | - "**" 9 | 10 | jobs: 11 | run_tests: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | python-version: ["3.11", "3.12"] 17 | toxenv: ["django42", "quality", "pii_check", "check_keywords"] 18 | 19 | services: 20 | mysql: 21 | image: mysql:8.0 22 | options: '--health-cmd="mysqladmin ping -h localhost" --health-interval=10s --health-timeout=5s --health-retries=3' 23 | env: 24 | MYSQL_ROOT_PASSWORD: 25 | MYSQL_ALLOW_EMPTY_PASSWORD: "yes" 26 | MYSQL_DATABASE: "edx_notes_api" 27 | ports: 28 | - 3306:3306 29 | 30 | elasticsearch: 31 | image: elasticsearch:7.13.4 32 | options: '--health-cmd="curl -f http://localhost:9200 || exit 1" --health-interval=10s --health-timeout=5s --health-retries=3' 33 | env: 34 | discovery.type: single-node 35 | bootstrap.memory_lock: "true" 36 | ES_JAVA_OPTS: "-Xms512m -Xmx512m" 37 | ports: 38 | - 9200:9200 39 | 40 | steps: 41 | - name: Checkout code 42 | uses: actions/checkout@v4 43 | 44 | - name: Set up Python ${{ matrix.python-version }} 45 | uses: actions/setup-python@v5 46 | with: 47 | python-version: ${{ matrix.python-version }} 48 | 49 | - name: Install system packages 50 | run: sudo apt-get update && sudo apt-get install -y libxmlsec1-dev 51 | 52 | - name: Install pip and Tox 53 | run: pip install --upgrade pip tox 54 | 55 | - name: Run Tox tests 56 | env: 57 | CONN_MAX_AGE: 60 58 | DB_ENGINE: django.db.backends.mysql 59 | DB_HOST: 127.0.0.1 60 | DB_NAME: edx_notes_api 61 | DB_PASSWORD: 62 | DB_PORT: 3306 63 | DB_USER: root 64 | ENABLE_DJANGO_TOOLBAR: 1 65 | ELASTICSEARCH_URL: http://127.0.0.1:9200 66 | run: tox -e ${{ matrix.toxenv }} 67 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL Scanning" 2 | # https://securitylab.github.com/tools/codeql 3 | 4 | on: 5 | push: 6 | branches: [ master ] 7 | pull_request: 8 | branches: [ master ] 9 | schedule: 10 | - cron: '27 1 * * 4' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | language: [ 'python' ] 21 | 22 | steps: 23 | - name: Checkout repository 24 | uses: actions/checkout@v4 25 | 26 | # Initializes the CodeQL tools for scanning. 27 | - name: Initialize CodeQL 28 | uses: github/codeql-action/init@v3 29 | with: 30 | languages: ${{ matrix.language }} 31 | 32 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 33 | - name: Autobuild 34 | uses: github/codeql-action/autobuild@v3 35 | 36 | - name: Perform CodeQL Analysis 37 | uses: github/codeql-action/analyze@v3 38 | -------------------------------------------------------------------------------- /.github/workflows/commitlint.yml: -------------------------------------------------------------------------------- 1 | # Run commitlint on the commit messages in a pull request. 2 | 3 | name: Lint Commit Messages 4 | 5 | on: 6 | - pull_request 7 | 8 | jobs: 9 | commitlint: 10 | uses: openedx/.github/.github/workflows/commitlint.yml@master 11 | -------------------------------------------------------------------------------- /.github/workflows/migrations-mysql8-check.yml: -------------------------------------------------------------------------------- 1 | name: Migrations check on MySQL 8 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | push: 7 | branches: 8 | - master 9 | 10 | jobs: 11 | check_migrations: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | python-version: ["3.11", "3.12"] 17 | 18 | steps: 19 | - name: Checkout code 20 | uses: actions/checkout@v4 21 | 22 | - name: Set up Python ${{ matrix.python-version }} with cache 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | cache: "pip" 27 | cache-dependency-path: "**/pip-tools.txt" 28 | 29 | - name: Install system packages 30 | run: sudo apt-get update && sudo apt-get install -y libxmlsec1-dev 31 | 32 | # pinning xmlsec to version 1.3.13 to avoid the CI error, migration checks are failing due to an issue in the latest release of python-xmlsec 33 | # https://github.com/xmlsec/python-xmlsec/issues/314 34 | - name: Install Python dependencies 35 | run: | 36 | pip install -r requirements/pip-tools.txt 37 | pip install -r requirements/test.txt 38 | pip install -r requirements/base.txt 39 | pip uninstall -y mysqlclient 40 | pip install --no-binary mysqlclient mysqlclient 41 | pip uninstall -y xmlsec 42 | pip install --no-binary xmlsec xmlsec==1.3.13 43 | 44 | - name: Start MySQL service 45 | run: sudo service mysql start 46 | 47 | - name: Reset MySQL root password 48 | run: | 49 | mysql -h 127.0.0.1 -u root -proot -e "UPDATE mysql.user SET authentication_string = null WHERE user = 'root'; FLUSH PRIVILEGES;" 50 | 51 | - name: Run migrations 52 | env: 53 | DB_ENGINE: django.db.backends.mysql 54 | DB_NAME: edx_notes_api 55 | DB_USER: root 56 | DB_PASSWORD: 57 | DB_HOST: localhost 58 | DB_PORT: 3306 59 | run: | 60 | echo "CREATE DATABASE IF NOT EXISTS edx_notes_api;" | sudo mysql -u root 61 | python manage.py migrate --settings=notesserver.settings.test 62 | -------------------------------------------------------------------------------- /.github/workflows/self-assign-issue.yml: -------------------------------------------------------------------------------- 1 | # This workflow runs when a comment is made on the ticket 2 | # If the comment starts with "assign me" it assigns the author to the 3 | # ticket (case insensitive) 4 | 5 | name: Assign comment author to ticket if they say "assign me" 6 | on: 7 | issue_comment: 8 | types: [created] 9 | 10 | jobs: 11 | self_assign_by_comment: 12 | uses: openedx/.github/.github/workflows/self-assign-issue.yml@master 13 | -------------------------------------------------------------------------------- /.github/workflows/trivy-code-scanning.yml: -------------------------------------------------------------------------------- 1 | name: "Trivy Code Scanning" 2 | # https://github.com/aquasecurity/trivy#readme 3 | 4 | on: 5 | push: 6 | branches: [master] 7 | pull_request: 8 | branches: [master] 9 | schedule: 10 | - cron: "27 1 * * 4" 11 | 12 | jobs: 13 | Trivy-Scan: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v4 19 | 20 | - name: Run Trivy vulnerability scanner 21 | uses: aquasecurity/trivy-action@master 22 | env: 23 | # https://github.com/aquasecurity/trivy/discussions/7668#discussioncomment-11141034 24 | TRIVY_DB_REPOSITORY: public.ecr.aws/aquasecurity/trivy-db,aquasec/trivy-db,ghcr.io/aquasecurity/trivy-db 25 | TRIVY_JAVA_DB_REPOSITORY: public.ecr.aws/aquasecurity/trivy-java-db,aquasec/trivy-java-db,ghcr.io/aquasecurity/trivy-java-db 26 | with: 27 | scan-type: "fs" 28 | format: "sarif" 29 | output: "trivy-results.sarif" 30 | 31 | - name: Upload Trivy scan results to GitHub Security tab 32 | uses: github/codeql-action/upload-sarif@v3 33 | with: 34 | sarif_file: "trivy-results.sarif" 35 | -------------------------------------------------------------------------------- /.github/workflows/upgrade-python-requirements.yml: -------------------------------------------------------------------------------- 1 | name: Upgrade Python Requirements 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * 1" 6 | workflow_dispatch: 7 | inputs: 8 | branch: 9 | description: "Target branch against which to create requirements PR" 10 | required: true 11 | default: 'master' 12 | 13 | jobs: 14 | call-upgrade-python-requirements-workflow: 15 | uses: openedx/.github/.github/workflows/upgrade-python-requirements.yml@master 16 | with: 17 | branch: ${{ github.event.inputs.branch || 'master' }} 18 | # optional parameters below; fill in if you'd like github or email notifications 19 | # user_reviewers: "" 20 | team_reviewers: 'axim-aximprovements' 21 | email_address: 'aximimprovements@axim.org' 22 | send_success_notification: false 23 | secrets: 24 | requirements_bot_github_token: ${{ secrets.REQUIREMENTS_BOT_GITHUB_TOKEN }} 25 | requirements_bot_github_email: ${{ secrets.REQUIREMENTS_BOT_GITHUB_EMAIL }} 26 | edx_smtp_username: ${{ secrets.EDX_SMTP_USERNAME }} 27 | edx_smtp_password: ${{ secrets.EDX_SMTP_PASSWORD }} 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python artifacts 2 | *.pyc 3 | 4 | # Tests / Coverage reports 5 | .coverage 6 | .tox 7 | coverage/ 8 | pii_report/ 9 | 10 | # Sqlite Database 11 | *.db 12 | 13 | #vim 14 | *.swp 15 | 16 | venv/ 17 | 18 | .idea/ 19 | 20 | # Helm dependencies 21 | /helmcharts/**/*.tgz 22 | 23 | reports/ 24 | -------------------------------------------------------------------------------- /.pep8: -------------------------------------------------------------------------------- 1 | [pep8] 2 | ignore=E501 3 | max_line_length=119 4 | -------------------------------------------------------------------------------- /.pii_annotations.yml: -------------------------------------------------------------------------------- 1 | source_path: ./ 2 | report_path: pii_report 3 | safelist_path: .annotation_safe_list.yml 4 | coverage_target: 100.0 5 | annotations: 6 | ".. no_pii::": 7 | "pii_group": 8 | - ".. pii::": 9 | - ".. pii_types::": 10 | choices: [id, name, other] 11 | - ".. pii_retirement::": 12 | choices: [retained, local_api, consumer_api, third_party] 13 | extensions: 14 | python: 15 | - py 16 | -------------------------------------------------------------------------------- /.pycodestyle: -------------------------------------------------------------------------------- 1 | [pycodestyle] 2 | exclude = .git, .tox, migrations 3 | ignore = E731 4 | max-line-length = 120 5 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Oleg Marshev 2 | Tim Babych 3 | Christina Roberts 4 | Ben McMorran 5 | Mushtaq Ali 6 | -------------------------------------------------------------------------------- /LICENSE.TXT: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published by 637 | the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | 663 | EdX Inc. wishes to state, in clarification of the above license terms, that 664 | any public, independently available web service offered over the network and 665 | communicating with edX's copyrighted works by any form of inter-service 666 | communication, including but not limited to Remote Procedure Call (RPC) 667 | interfaces, is not a work based on our copyrighted work within the meaning 668 | of the license. "Corresponding Source" of this work, or works based on this 669 | work, as defined by the terms of this license do not include source code 670 | files for programs used solely to provide those public, independently 671 | available web services. 672 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PACKAGES = notesserver notesapi 2 | .PHONY: requirements check_keywords 3 | 4 | validate: test.requirements test 5 | 6 | pytest: test-start-services test test-stop-services 7 | 8 | test: clean 9 | python -Wd -m pytest 10 | 11 | test-start-services: 12 | docker compose -f notesserver/docker-compose.test.yml --project-name=edxnotesapi_test up -d --remove-orphans 13 | 14 | test-stop-services: 15 | docker compose -f notesserver/docker-compose.test.yml --project-name=edxnotesapi_test stop 16 | 17 | pii_check: test.requirements pii_clean 18 | DJANGO_SETTINGS_MODULE=notesserver.settings.test code_annotations django_find_annotations --config_file .pii_annotations.yml \ 19 | --lint --report --coverage 20 | 21 | check_keywords: ## Scan the Django models in all installed apps in this project for restricted field names 22 | DJANGO_SETTINGS_MODULE=notesserver.settings.test python manage.py check_reserved_keywords --override_file db_keyword_overrides.yml 23 | 24 | run: 25 | ./manage.py runserver 0.0.0.0:8120 26 | 27 | shell: 28 | ./manage.py shell 29 | 30 | clean: 31 | coverage erase 32 | 33 | pii_clean: 34 | rm -rf pii_report 35 | mkdir -p pii_report 36 | 37 | quality: pycodestyle pylint 38 | 39 | pycodestyle: 40 | pycodestyle --config=.pycodestyle $(PACKAGES) 41 | 42 | pylint: 43 | DJANGO_SETTINGS_MODULE=notesserver.settings.test pylint $(PACKAGES) 44 | 45 | diff-coverage: 46 | diff-cover build/coverage/coverage.xml --html-report build/coverage/diff_cover.html 47 | 48 | diff-quality: 49 | diff-quality --violations=pep8 --html-report build/coverage/diff_quality_pep8.html 50 | diff-quality --violations=pylint --html-report build/coverage/diff_quality_pylint.html 51 | 52 | coverage: diff-coverage diff-quality 53 | 54 | create-index: 55 | python manage.py search_index --rebuild -f 56 | 57 | migrate: 58 | python manage.py migrate --noinput 59 | 60 | static: # provide the static target for devstack's tooling. 61 | @echo "The notes service does not need staticfiles to be compiled. Skipping." 62 | 63 | requirements: 64 | pip install -q -r requirements/base.txt --exists-action=w 65 | 66 | test.requirements: 67 | pip install -q -r requirements/test.txt --exists-action=w 68 | 69 | develop: requirements test.requirements 70 | 71 | piptools: ## install pinned version of pip-compile and pip-sync 72 | pip install -r requirements/pip-tools.txt 73 | 74 | compile-requirements: export CUSTOM_COMPILE_COMMAND=make upgrade 75 | compile-requirements: piptools ## Re-compile *.in requirements to *.txt (without upgrading) 76 | # Make sure to compile files after any other files they include! 77 | pip-compile ${COMPILE_OPTS} --rebuild --allow-unsafe -o requirements/pip.txt requirements/pip.in 78 | pip-compile ${COMPILE_OPTS} -o requirements/pip-tools.txt requirements/pip-tools.in 79 | pip install -qr requirements/pip.txt 80 | pip install -qr requirements/pip-tools.txt 81 | pip-compile ${COMPILE_OPTS} --allow-unsafe -o requirements/base.txt requirements/base.in 82 | pip-compile ${COMPILE_OPTS} --allow-unsafe -o requirements/test.txt requirements/test.in 83 | pip-compile ${COMPILE_OPTS} --allow-unsafe -o requirements/ci.txt requirements/ci.in 84 | pip-compile ${COMPILE_OPTS} --allow-unsafe -o requirements/quality.txt requirements/quality.in 85 | # Let tox control the Django version for tests 86 | grep -e "^django==" requirements/base.txt > requirements/django.txt 87 | sed '/^[dD]jango==/d' requirements/test.txt > requirements/test.tmp 88 | mv requirements/test.tmp requirements/test.txt 89 | 90 | upgrade: ## update the requirements/*.txt files with the latest packages satisfying requirements/*.in 91 | $(MAKE) compile-requirements COMPILE_OPTS="--upgrade" 92 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | edX Student Notes API |build-status| 2 | #################################### 3 | 4 | This is a backend store for edX Student Notes. 5 | 6 | Overview 7 | ******** 8 | 9 | The edX Notes API is designed to be compatible with the `Annotator `__. 10 | 11 | Getting Started 12 | *************** 13 | 14 | 1. Install `ElasticSearch 7.13.4 `__. 15 | 16 | 2. Install the requirements: 17 | 18 | .. code-block:: bash 19 | 20 | make develop 21 | 22 | 3. Create index and put mapping: 23 | 24 | .. code-block:: bash 25 | 26 | make create-index 27 | 28 | 4. Run the server: 29 | 30 | .. code-block:: bash 31 | 32 | make run 33 | 34 | Configuration 35 | ************* 36 | 37 | ``CLIENT_ID`` - OAuth2 Client ID, which is to be found in ``aud`` field of IDTokens which authorize users 38 | 39 | ``CLIENT_SECRET`` - secret with which IDTokens should be encoded 40 | 41 | ``ES_DISABLED`` - set to True when you need to run the service without ElasticSearch support. 42 | e.g if it became corrupted and you're rebuilding the index, while still serving users 43 | through MySQL 44 | 45 | ``ELASTICSEARCH_DSL['default']['hosts']`` - Your ElasticSearch host 46 | 47 | Running Tests 48 | ************* 49 | 50 | Install requirements:: 51 | 52 | make test.requirements 53 | 54 | Start mysql/elasticsearch services:: 55 | 56 | make test-start-services 57 | 58 | Run unit tests:: 59 | 60 | make test 61 | 62 | Run quality checks:: 63 | 64 | make quality 65 | 66 | How To Resync The Index 67 | *********************** 68 | 69 | edX Notes Store uses `Django elasticsearch DSL `_ which comes with several management commands. 70 | 71 | Please read more about ``search_index`` management commands 72 | `here `_. 73 | 74 | License 75 | ******* 76 | 77 | The code in this repository is licensed under version 3 of the AGPL unless 78 | otherwise noted. 79 | 80 | Please see ``LICENSE.txt`` for details. 81 | 82 | How To Contribute 83 | ***************** 84 | 85 | Contributions are very welcome. 86 | 87 | Please read `How To Contribute `_ for details. 88 | 89 | Reporting Security Issues 90 | ************************* 91 | 92 | Please do not report security issues in public. Please email security@openedx.org 93 | 94 | Mailing List and IRC Channel 95 | **************************** 96 | 97 | You can discuss this code on the `edx-code Google Group`__ or in the 98 | ``edx-code`` IRC channel on Freenode. 99 | 100 | __ https://groups.google.com/g/edx-code 101 | 102 | .. |build-status| image:: https://github.com/openedx/edx-notes-api/actions/workflows/ci.yml/badge.svg 103 | :target: https://github.com/openedx/edx-notes-api/actions/workflows/ci.yml 104 | 105 | -------------------------------------------------------------------------------- /catalog-info.yaml: -------------------------------------------------------------------------------- 1 | # This file records information about this repo. Its use is described in OEP-55: 2 | # https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html 3 | 4 | apiVersion: backstage.io/v1alpha1 5 | kind: "Component" 6 | metadata: 7 | name: 'edx-notes-api' 8 | description: "A backend store for edX Student Notes" 9 | annotations: 10 | openedx.org/release: "master" 11 | spec: 12 | 13 | # (Required) This can be a group(`group:` or a user(`user:`) 14 | owner: 'group:axim-engineering' 15 | 16 | # (Required) Acceptable Type Values: service, website, library 17 | type: 'library' 18 | 19 | # (Required) Acceptable Lifecycle Values: experimental, production, deprecated 20 | lifecycle: 'production' 21 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.fixture(autouse=True) 5 | def enable_db_access_for_all_tests(db): 6 | pass 7 | -------------------------------------------------------------------------------- /db_keyword_overrides.yml: -------------------------------------------------------------------------------- 1 | 2 | # This file is used by the 'check_reserved_keywords' management command to allow specific field names to be overridden 3 | # when checking for conflicts with lists of restricted keywords used in various database/data warehouse tools. 4 | # For more information, see: https://github.com/openedx/edx-django-release-util/release_util/management/commands/check_reserved_keywords.py 5 | # 6 | # overrides should be added in the following format: 7 | # - ModelName.field_name 8 | --- 9 | MYSQL: 10 | SNOWFLAKE: 11 | STITCH: 12 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | from django.core.management import execute_from_command_line 7 | 8 | execute_from_command_line(sys.argv) 9 | -------------------------------------------------------------------------------- /notesapi/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/edx-notes-api/386f57ee2022fdf7d35a775d3e1c9b436336359a/notesapi/__init__.py -------------------------------------------------------------------------------- /notesapi/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | 3 | app_name = "notesapi.v1" 4 | 5 | urlpatterns = [ 6 | path('v1/', include('notesapi.v1.urls', namespace='v1')), 7 | ] 8 | -------------------------------------------------------------------------------- /notesapi/v1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/edx-notes-api/386f57ee2022fdf7d35a775d3e1c9b436336359a/notesapi/v1/__init__.py -------------------------------------------------------------------------------- /notesapi/v1/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/edx-notes-api/386f57ee2022fdf7d35a775d3e1c9b436336359a/notesapi/v1/management/__init__.py -------------------------------------------------------------------------------- /notesapi/v1/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/edx-notes-api/386f57ee2022fdf7d35a775d3e1c9b436336359a/notesapi/v1/management/commands/__init__.py -------------------------------------------------------------------------------- /notesapi/v1/management/commands/bulk_create_notes.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import json 3 | import os 4 | import random 5 | import uuid 6 | 7 | from django.core.management.base import BaseCommand, CommandError 8 | 9 | from notesapi.v1.models import Note 10 | 11 | 12 | def extract_comma_separated_list(option, value, parser): 13 | """Parse an option string as a comma separated list""" 14 | setattr(parser.values, option.dest, [course_id.strip() for course_id in value.split(',')]) 15 | 16 | 17 | class Command(BaseCommand): 18 | args = '' 19 | 20 | def add_arguments(self, parser): 21 | parser.add_argument( 22 | '--per_user', 23 | action='store', 24 | type=int, 25 | default=50, 26 | help='number of notes that should be attributed to each user (default 50)' 27 | ) 28 | 29 | parser.add_argument( 30 | '--course_ids', 31 | action='callback', 32 | callback=extract_comma_separated_list, 33 | type=str, 34 | default=['edX/DemoX/Demo_Course'], 35 | help='comma-separated list of course_ids for which notes should be randomly attributed' 36 | ) 37 | 38 | parser.add_argument( 39 | '--batch_size', 40 | action='store', 41 | type=int, 42 | default=1000, 43 | help='number of notes that should be bulk inserted at a time - useful for getting around the maximum SQL ' 44 | 'query size' 45 | ) 46 | 47 | help = 'Add N random notes to the database' 48 | 49 | def handle(self, *args, **options): 50 | if len(args) != 1: 51 | raise CommandError("bulk_create_notes takes the following arguments: " + self.args) 52 | 53 | total_notes = int(args[0]) 54 | notes_per_user = options['per_user'] 55 | course_ids = options['course_ids'] 56 | batch_size = options['batch_size'] 57 | 58 | # In production, there is a max SQL query size. Batch the bulk inserts 59 | # such that we don't exceed this limit. 60 | for notes_chunk in grouper_it(note_iter(total_notes, notes_per_user, course_ids), batch_size): 61 | Note.objects.bulk_create(notes_chunk) 62 | 63 | 64 | def note_iter(total_notes, notes_per_user, course_ids): 65 | """ 66 | Return an iterable of random notes data of length `total_notes`. 67 | 68 | Arguments: 69 | total_notes (int): total number of notes models to yield 70 | notes_per_user (int): number of notes to attribute to any one user 71 | course_ids (list): list of course_id strings to which notes will be 72 | randomly attributed 73 | 74 | Returns: 75 | generator: An iterable of note models. 76 | """ 77 | DATA_DIRECTORY = os.path.join(os.path.dirname(__file__), "data/") 78 | with open(os.path.join(DATA_DIRECTORY, 'basic_words.txt')) as f: 79 | notes_text = [word for line in f for word in line.split()] 80 | 81 | def weighted_get_words(weighted_num_words): 82 | """ 83 | Return random words of of a length of weighted probability. 84 | `weighted_num_words` should look like [(word_count, weight), (word_count, weight) ...] 85 | """ 86 | return random.sample( 87 | notes_text, 88 | random.choice([word_count for word_count, weight in weighted_num_words for i in range(weight)]) 89 | ) 90 | 91 | def get_new_user_id(): 92 | return uuid.uuid4().hex 93 | 94 | user_id = get_new_user_id() 95 | 96 | for note_count in range(total_notes): 97 | if note_count % notes_per_user == 0: 98 | user_id = get_new_user_id() 99 | # Notice that quote and ranges are arbitrary 100 | yield Note( 101 | user_id=user_id, 102 | course_id=random.choice(course_ids), 103 | usage_id=uuid.uuid4().hex, 104 | quote='foo bar baz', 105 | text=' '.join(weighted_get_words([(10, 5), (25, 3), (100, 2)])), 106 | ranges=json.dumps([{"start": "/div[1]/p[1]", "end": "/div[1]/p[1]", "startOffset": 0, "endOffset": 6}]), 107 | tags=json.dumps(weighted_get_words([(1, 40), (2, 30), (5, 15), (10, 10), (15, 5)])) 108 | ) 109 | 110 | 111 | def grouper_it(iterable, batch_size): 112 | """ 113 | Return an iterator of iterators. Each child iterator yields the 114 | next `batch_size`-many elements from `iterable`. 115 | """ 116 | while True: 117 | chunk_it = itertools.islice(iterable, batch_size) 118 | try: 119 | first_el = next(chunk_it) 120 | except StopIteration: 121 | break 122 | yield itertools.chain((first_el,), chunk_it) 123 | -------------------------------------------------------------------------------- /notesapi/v1/management/commands/data/basic_words.txt: -------------------------------------------------------------------------------- 1 | a about above across act active activity add afraid after again age ago agree air all alone along already always am amount an and angry another answer any anyone anything anytime appear apple are area arm army around arrive art as ask at attack aunt autumn away 2 | baby back bad bag ball bank base basket bath be bean bear beautiful bed bedroom beer behave before begin behind bell below besides best better between big bird birth birthday bit bite black bleed block blood blow blue board boat body boil bone book border born borrow both bottle bottom bowl box boy branch brave bread break breakfast breathe bridge bright bring brother brown brush build burn business bus busy but buy by 3 | cake call can candle cap car card care careful careless carry case cat catch central century certain chair chance change chase cheap cheese chicken child children chocolate choice choose circle city class clever clean clear climb clock cloth clothes cloud cloudy close coffee coat coin cold collect colour comb comfortable common compare come complete computer condition continue control cook cool copper corn corner correct cost contain count country course cover crash cross cry cup cupboard cut 4 | dance dangerous dark daughter day dead decide decrease deep deer depend desk destroy develop die different difficult dinner direction dirty discover dish do dog door double down draw dream dress drink drive drop dry duck dust duty 5 | each ear early earn earth east easy eat education effect egg eight either electric elephant else empty end enemy enjoy enough enter equal entrance escape even evening event ever every everyone exact everybody examination example except excited exercise expect expensive explain extremely eye 6 | face fact fail fall false family famous far farm father fast fat fault fear feed feel female fever few fight fill film find fine finger finish fire first fish fit five fix flag flat float floor flour flower fly fold food fool foot football for force foreign forest forget forgive fork form fox four free freedom freeze fresh friend friendly from front fruit full fun funny furniture further future 7 | game garden gate general gentleman get gift give glad glass go goat god gold good goodbye grandfather grandmother grass grave great green grey ground group grow gun 8 | hair half hall hammer hand happen happy hard hat hate have he head healthy hear heavy heart heaven height hello help hen her here hers hide high hill him his hit hobby hold hole holiday home hope horse hospital hot hotel house how hundred hungry hour hurry husband hurt 9 | I ice idea if important in increase inside into introduce invent iron invite is island it its 10 | jelly job join juice jump just 11 | keep key kill kind king kitchen knee knife knock know 12 | ladder lady lamp land large last late lately laugh lazy lead leaf learn leave leg left lend length less lesson let letter library lie life light like lion lip list listen little live lock lonely long look lose lot love low lower luck 13 | machine main make male man many map mark market marry matter may me meal mean measure meat medicine meet member mention method middle milk million mind minute miss mistake mix model modern moment money monkey month moon more morning most mother mountain mouth move much music must my 14 | name narrow nation nature near nearly neck need needle neighbour neither net never new news newspaper next nice night nine no noble noise none nor north nose not nothing notice now number 15 | obey object ocean of off offer office often oil old on one only open opposite or orange order other our out outside over own 16 | page pain paint pair pan paper parent park part partner party pass past path pay peace pen pencil people pepper per perfect period person petrol photograph piano pick picture piece pig pin pink place plane plant plastic plate play please pleased plenty pocket point poison police polite pool poor popular position possible potato pour power present press pretty prevent price prince prison private prize probably problem produce promise proper protect provide public pull punish pupil push put 17 | queen question quick quiet quite 18 | radio rain rainy raise reach read ready real really receive record red remember remind remove rent repair repeat reply report rest restaurant result return rice rich ride right ring rise road rob rock room round rubber rude rule ruler run rush 19 | sad safe sail salt same sand save say school science scissors search seat second see seem sell send sentence serve seven several shade shadow shake shape share sharp she sheep sheet shelf shine ship shirt shoe shoot shop short should shoulder shout show sick side signal silence silly silver similar simple single since sing sink sister sit six size skill skin skirt sky sleep slip slow small smell smile smoke snow so soap sock soft some someone something sometimes son soon sorry sound soup south space speak special speed spell spend spoon sport spread spring square stamp stand star start station stay steal steam step still stomach stone stop store storm story strange street strong structure student study stupid subject substance successful such sudden sugar suitable summer sun sunny support sure surprise sweet swim sword 20 | table take talk tall taste taxi tea teach team tear telephone television tell ten tennis terrible test than that the their then there therefore these thick thin thing think third this though threat three tidy tie title to today toe together tomorrow tonight too tool tooth top total touch town train tram travel tree trouble true trust twice try turn type 21 | ugly uncle under understand unit until up use useful usual usually 22 | vegetable very village voice visit 23 | wait wake walk want warm was wash waste watch water way we weak wear weather wedding week weight welcome were well west wet what wheel when where which while white who why wide wife wild will win wind window wine winter wire wise wish with without woman wonder word work world worry 24 | yard yell yesterday yet you young your 25 | zero zoo 26 | -------------------------------------------------------------------------------- /notesapi/v1/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | """ Initial migration file for creating Note model """ 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | """ Initial migration file for creating Note model """ 8 | 9 | dependencies = [ 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='Note', 15 | fields=[ 16 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 17 | ('user_id', models.CharField( 18 | help_text=b'Anonymized user id, not course specific', max_length=255, db_index=True 19 | )), 20 | ('course_id', models.CharField(max_length=255, db_index=True)), 21 | ('usage_id', models.CharField(help_text=b'ID of XBlock where the text comes from', max_length=255)), 22 | ('quote', models.TextField(default=b'')), 23 | ('text', models.TextField(default=b'', help_text=b"Student's thoughts on the quote", blank=True)), 24 | ('ranges', models.TextField(help_text=b'JSON, describes position of quote in the source text')), 25 | ('created', models.DateTimeField(auto_now_add=True)), 26 | ('updated', models.DateTimeField(auto_now=True)), 27 | ], 28 | options={ 29 | }, 30 | bases=(models.Model,), 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /notesapi/v1/migrations/0002_note_tags.py: -------------------------------------------------------------------------------- 1 | """ Add tags field to Note model """ 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | """ Add tags field to Note model """ 8 | 9 | dependencies = [ 10 | ('v1', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='note', 16 | name='tags', 17 | field=models.TextField(default=b'[]', help_text=b'JSON, list of comma-separated tags'), 18 | preserve_default=True, 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /notesapi/v1/migrations/0003_auto_20200703_1515.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.13 on 2020-07-03 15:15 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('v1', '0002_note_tags'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='note', 15 | name='quote', 16 | field=models.TextField(default=''), 17 | ), 18 | migrations.AlterField( 19 | model_name='note', 20 | name='ranges', 21 | field=models.TextField(help_text='JSON, describes position of quote in the source text'), 22 | ), 23 | migrations.AlterField( 24 | model_name='note', 25 | name='tags', 26 | field=models.TextField(default='[]', help_text='JSON, list of comma-separated tags'), 27 | ), 28 | migrations.AlterField( 29 | model_name='note', 30 | name='text', 31 | field=models.TextField(blank=True, default='', help_text="Student's thoughts on the quote"), 32 | ), 33 | migrations.AlterField( 34 | model_name='note', 35 | name='usage_id', 36 | field=models.CharField(help_text='ID of XBlock where the text comes from', max_length=255), 37 | ), 38 | migrations.AlterField( 39 | model_name='note', 40 | name='user_id', 41 | field=models.CharField(db_index=True, help_text='Anonymized user id, not course specific', max_length=255), 42 | ), 43 | ] 44 | -------------------------------------------------------------------------------- /notesapi/v1/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/edx-notes-api/386f57ee2022fdf7d35a775d3e1c9b436336359a/notesapi/v1/migrations/__init__.py -------------------------------------------------------------------------------- /notesapi/v1/models.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.core.exceptions import ValidationError 4 | from django.db import models 5 | 6 | 7 | class Note(models.Model): 8 | """ 9 | Annotation model. 10 | 11 | .. pii:: Stores 'text' and 'tags' about a particular course quote. 12 | .. pii_types:: other 13 | .. pii_retirement:: local_api, consumer_api 14 | """ 15 | user_id = models.CharField(max_length=255, db_index=True, help_text="Anonymized user id, not course specific") 16 | course_id = models.CharField(max_length=255, db_index=True) 17 | usage_id = models.CharField(max_length=255, help_text="ID of XBlock where the text comes from") 18 | quote = models.TextField(default="") 19 | text = models.TextField(default="", blank=True, help_text="Student's thoughts on the quote") 20 | ranges = models.TextField(help_text="JSON, describes position of quote in the source text") 21 | created = models.DateTimeField(auto_now_add=True) 22 | updated = models.DateTimeField(auto_now=True) 23 | tags = models.TextField(help_text="JSON, list of comma-separated tags", default="[]") 24 | 25 | @classmethod 26 | def create(cls, note_dict): 27 | """ 28 | Create the note object. 29 | """ 30 | if not isinstance(note_dict, dict): 31 | raise ValidationError('Note must be a dictionary.') 32 | 33 | if len(note_dict) == 0: 34 | raise ValidationError('Note must have a body.') 35 | 36 | ranges = note_dict.get('ranges', []) 37 | 38 | if len(ranges) < 1: 39 | raise ValidationError('Note must contain at least one range.') 40 | 41 | note_dict['ranges'] = json.dumps(ranges) 42 | note_dict['user_id'] = note_dict.pop('user', None) 43 | note_dict['tags'] = json.dumps(note_dict.get('tags', []), ensure_ascii=False) 44 | 45 | return cls(**note_dict) 46 | -------------------------------------------------------------------------------- /notesapi/v1/paginators.py: -------------------------------------------------------------------------------- 1 | """ 2 | Paginator for Notes where storage is mysql database. 3 | """ 4 | 5 | from rest_framework import pagination 6 | 7 | from .utils import NotesPaginatorMixin 8 | 9 | 10 | class NotesPaginator(NotesPaginatorMixin, pagination.PageNumberPagination): 11 | """ 12 | Student Notes Paginator. 13 | """ 14 | -------------------------------------------------------------------------------- /notesapi/v1/permissions.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import jwt 4 | from django.conf import settings 5 | from rest_framework.permissions import BasePermission 6 | from rest_framework_jwt.settings import api_settings 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class TokenWrongIssuer(Exception): 12 | pass 13 | 14 | 15 | class HasAccessToken(BasePermission): 16 | """ 17 | Allow requests having valid ID Token. 18 | 19 | https://tools.ietf.org/html/draft-ietf-oauth-json-web-token-31 20 | Expected Token: 21 | Header { 22 | "alg": "HS256", 23 | "typ": "JWT" 24 | } 25 | Claims { 26 | "sub": "", 27 | "exp": , 28 | "iat": , 29 | "aud": "[a-zA-Z0-9_-]+)/?$', 11 | AnnotationDetailView.as_view(), 12 | name='annotations_detail' 13 | ), 14 | path( 15 | 'search/', 16 | get_annotation_search_view_class().as_view(), 17 | name='annotations_search' 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /notesapi/v1/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utilities for notes api application 3 | """ 4 | 5 | from django.conf import settings 6 | from django.http import QueryDict 7 | from rest_framework.response import Response 8 | 9 | 10 | class NotesPaginatorMixin: 11 | """ 12 | Student Notes Paginator Mixin 13 | """ 14 | 15 | page_size = settings.DEFAULT_NOTES_PAGE_SIZE 16 | page_size_query_param = "page_size" 17 | 18 | def get_paginated_response(self, data): 19 | """ 20 | Annotate the response with pagination information. 21 | """ 22 | return Response( 23 | { 24 | 'start': (self.page.number - 1) * self.get_page_size(self.request), 25 | 'current_page': self.page.number, 26 | 'next': self.get_next_link(), 27 | 'previous': self.get_previous_link(), 28 | 'total': self.page.paginator.count, 29 | 'num_pages': self.page.paginator.num_pages, 30 | 'rows': data, 31 | } 32 | ) 33 | 34 | 35 | def dict_to_querydict(dict_): 36 | """ 37 | Converts a dict value into the Django's QueryDict object. 38 | """ 39 | query_dict = QueryDict('', mutable=True) 40 | for name, value in dict_.items(): 41 | if isinstance(name, list): 42 | query_dict.setlist(name, value) 43 | else: 44 | query_dict.appendlist(name, value) 45 | query_dict._mutable = False # pylint: disable=protected-access 46 | return query_dict 47 | 48 | 49 | class Request: 50 | """ 51 | Specifies custom behavior of the standard Django's request class. 52 | 53 | Implementation of the `duck typing` pattern. 54 | Using an object of class `Request` allows you to define the desired logic, 55 | which will be different from Django's Request. 56 | Those program components that are expecting a Django request, 57 | but they will use `Request` - will not notice the substitution at all. 58 | """ 59 | 60 | def __init__(self, query_params): 61 | self._query_params = query_params 62 | 63 | @property 64 | def query_params(self): 65 | """ 66 | Returns the Django's QueryDict object, which presents request's query params. 67 | """ 68 | return dict_to_querydict(self._query_params) 69 | -------------------------------------------------------------------------------- /notesapi/v1/views/__init__.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from django.conf import settings 3 | 4 | from .common import ( 5 | AnnotationDetailView, 6 | AnnotationListView, 7 | AnnotationRetireView, 8 | AnnotationSearchView 9 | ) 10 | 11 | from .exceptions import SearchViewRuntimeError 12 | 13 | 14 | # pylint: disable=import-outside-toplevel 15 | def get_annotation_search_view_class() -> t.Type[AnnotationSearchView]: 16 | """ 17 | Import views from either mysql, elasticsearch or meilisearch backend 18 | """ 19 | if settings.ES_DISABLED: 20 | if getattr(settings, "MEILISEARCH_ENABLED", False): 21 | from . import meilisearch 22 | return meilisearch.AnnotationSearchView 23 | else: 24 | return AnnotationSearchView 25 | from . import elasticsearch 26 | return elasticsearch.AnnotationSearchView 27 | -------------------------------------------------------------------------------- /notesapi/v1/views/common.py: -------------------------------------------------------------------------------- 1 | # pylint:disable=possibly-used-before-assignment 2 | import json 3 | import logging 4 | 5 | from django.conf import settings 6 | from django.core.exceptions import ValidationError 7 | from django.db.models import Q 8 | from django.urls import reverse 9 | from django.utils.translation import gettext as _ 10 | from edx_django_utils.monitoring import set_custom_attribute 11 | from rest_framework import status 12 | from rest_framework.generics import GenericAPIView, ListAPIView 13 | from rest_framework.response import Response 14 | from rest_framework.views import APIView 15 | 16 | from notesapi.v1.models import Note 17 | from notesapi.v1.serializers import NoteSerializer 18 | 19 | 20 | log = logging.getLogger(__name__) 21 | 22 | 23 | class AnnotationsLimitReachedError(Exception): 24 | """ 25 | Exception when trying to create more than allowed annotations 26 | """ 27 | 28 | 29 | class AnnotationSearchView(ListAPIView): 30 | """ 31 | **Use Case** 32 | 33 | * Search and return a list of annotations for a user. 34 | 35 | The annotations are always sorted in descending order by updated date. 36 | 37 | Response is paginated by default except usage_id based search. 38 | 39 | Each page in the list contains 25 annotations by default. The page 40 | size can be altered by passing parameter "page_size=". 41 | 42 | Http400 is returned if the format of the request is not correct. 43 | 44 | **Search Types** 45 | 46 | * There are two types of searches one can perform 47 | 48 | * Database 49 | 50 | If ElasticSearch is disabled or text query param is not present. 51 | 52 | * ElasticSearch 53 | 54 | **Example Requests** 55 | 56 | GET /api/v1/search/ 57 | GET /api/v1/search/?course_id={course_id}&user={user_id} 58 | GET /api/v1/search/?course_id={course_id}&user={user_id}&usage_id={usage_id} 59 | GET /api/v1/search/?course_id={course_id}&user={user_id}&usage_id={usage_id}&usage_id={usage_id} ... 60 | 61 | **Query Parameters for GET** 62 | 63 | All the parameters are optional. 64 | 65 | * course_id: Id of the course. 66 | 67 | * user: Anonymized user id. 68 | 69 | * usage_id: The identifier string of the annotations XBlock. 70 | 71 | * text: Student's thoughts on the quote 72 | 73 | * highlight: dict. Only used when search from ElasticSearch. It contains two keys: 74 | 75 | * highlight_tag: String. HTML tag to be used for highlighting the text. Default is "em" 76 | 77 | * highlight_class: String. CSS class to be used for highlighting the text. 78 | 79 | **Response Values for GET** 80 | 81 | * count: The number of annotations in a course. 82 | 83 | * next: The URI to the next page of annotations. 84 | 85 | * previous: The URI to the previous page of annotations. 86 | 87 | * current: Current page number. 88 | 89 | * num_pages: The number of pages listing annotations. 90 | 91 | * results: A list of annotations returned. Each collection in the list contains these fields. 92 | 93 | * id: String. The primary key of the note. 94 | 95 | * user: String. Anonymized id of the user. 96 | 97 | * course_id: String. The identifier string of the annotations course. 98 | 99 | * usage_id: String. The identifier string of the annotations XBlock. 100 | 101 | * quote: String. Quoted text. 102 | 103 | * text: String. Student's thoughts on the quote. 104 | 105 | * ranges: List. Describes position of quote. 106 | 107 | * tags: List. Comma separated tags. 108 | 109 | * created: DateTime. Creation datetime of annotation. 110 | 111 | * updated: DateTime. When was the last time annotation was updated. 112 | """ 113 | 114 | action = "" 115 | params = {} 116 | query_params = {} 117 | search_with_usage_id = False 118 | search_fields = ("text", "tags") 119 | ordering = ("-updated",) 120 | 121 | @property 122 | def is_text_search(self): 123 | """ 124 | We identify text search by the presence of a "text" parameter. Subclasses may 125 | want to have a different behaviour in such cases. 126 | """ 127 | return "text" in self.params 128 | 129 | def get_queryset(self): 130 | queryset = Note.objects.filter(**self.query_params).order_by("-updated") 131 | if "text" in self.params: 132 | qs_filter = Q(text__icontains=self.params["text"]) | Q( 133 | tags__icontains=self.params["text"] 134 | ) 135 | queryset = queryset.filter(qs_filter) 136 | return queryset 137 | 138 | def get_serializer_class(self): 139 | """ 140 | Return the class to use for the serializer. 141 | 142 | Defaults to `NoteSerializer`. 143 | """ 144 | return NoteSerializer 145 | 146 | @property 147 | def paginator(self): 148 | """ 149 | The paginator instance associated with the view and used data source, or `None`. 150 | """ 151 | if not hasattr(self, "_paginator"): 152 | # pylint: disable=attribute-defined-outside-init 153 | self._paginator = self.pagination_class() if self.pagination_class else None 154 | 155 | return self._paginator 156 | 157 | def filter_queryset(self, queryset): 158 | """ 159 | Given a queryset, filter it with whichever filter backend is in use. 160 | 161 | Do not filter additionally if mysql db used or use `CompoundSearchFilterBackend` 162 | and `HighlightBackend` if elasticsearch is the data source. 163 | """ 164 | filter_backends = self.get_filter_backends() 165 | for backend in filter_backends: 166 | queryset = backend().filter_queryset(self.request, queryset, view=self) 167 | return queryset 168 | 169 | def get_filter_backends(self): 170 | """ 171 | List of filter backends, each with a `filter_queryset` method. 172 | """ 173 | return [] 174 | 175 | def list(self, *args, **kwargs): 176 | """ 177 | Returns list of students notes. 178 | """ 179 | # Do not send paginated result if usage id based search. 180 | if self.search_with_usage_id: 181 | queryset = self.filter_queryset(self.get_queryset()) 182 | serializer = self.get_serializer(queryset, many=True) 183 | return Response(serializer.data, status=status.HTTP_200_OK) 184 | return super().list(*args, **kwargs) 185 | 186 | def build_query_params_state(self): 187 | """ 188 | Builds a custom query params. 189 | 190 | Use them in order to search annotations in most appropriate storage. 191 | """ 192 | self.query_params = {} 193 | self.params = self.request.query_params.dict() 194 | usage_ids = self.request.query_params.getlist("usage_id") 195 | if usage_ids: 196 | self.search_with_usage_id = True 197 | self.query_params["usage_id__in"] = usage_ids 198 | 199 | if "course_id" in self.params: 200 | self.query_params["course_id"] = self.params["course_id"] 201 | 202 | if "user" in self.params: 203 | self.query_params["user_id"] = self.params["user"] 204 | 205 | def get(self, *args, **kwargs): 206 | """ 207 | Search annotations in most appropriate storage 208 | """ 209 | self.search_with_usage_id = False 210 | self.build_query_params_state() 211 | 212 | return super().get(*args, **kwargs) 213 | 214 | @classmethod 215 | def selftest(cls): 216 | """ 217 | No-op. 218 | """ 219 | return {} 220 | 221 | @classmethod 222 | def heartbeat(cls): 223 | """ 224 | No-op 225 | """ 226 | return 227 | 228 | 229 | class AnnotationRetireView(GenericAPIView): 230 | """ 231 | Administrative functions for the notes service. 232 | """ 233 | 234 | def post(self, *args, **kwargs): 235 | """ 236 | Delete all annotations for a user. 237 | """ 238 | params = self.request.data 239 | if "user" not in params: 240 | return Response(status=status.HTTP_400_BAD_REQUEST) 241 | 242 | Note.objects.filter(user_id=params["user"]).delete() 243 | return Response(status=status.HTTP_204_NO_CONTENT) 244 | 245 | 246 | class AnnotationListView(GenericAPIView): 247 | """ 248 | **Use Case** 249 | 250 | * Get a paginated list of annotations for a user. 251 | 252 | The annotations are always sorted in descending order by updated date. 253 | 254 | Each page in the list contains 25 annotations by default. The page 255 | size can be altered by passing parameter "page_size=". 256 | 257 | HTTP 400 Bad Request: The format of the request is not correct. 258 | 259 | * Create a new annotation for a user. 260 | 261 | HTTP 400 Bad Request: The format of the request is not correct, or the maximum number of notes for a 262 | user has been reached. 263 | 264 | HTTP 201 Created: Success. 265 | 266 | * Delete all annotations for a user. 267 | 268 | HTTP 400 Bad Request: The format of the request is not correct. 269 | 270 | HTTP 200 OK: Either annotations from the user were deleted, or no annotations for the user were found. 271 | 272 | **Example Requests** 273 | 274 | GET /api/v1/annotations/?course_id={course_id}&user={user_id} 275 | 276 | POST /api/v1/annotations/ 277 | user={user_id}&course_id={course_id}&usage_id={usage_id}&ranges={ranges}"e={quote} 278 | 279 | DELETE /api/v1/annotations/ 280 | user={user_id} 281 | 282 | **Query Parameters for GET** 283 | 284 | Both the course_id and user must be provided. 285 | 286 | * course_id: Id of the course. 287 | 288 | * user: Anonymized user id. 289 | 290 | **Response Values for GET** 291 | 292 | * count: The number of annotations in a course. 293 | 294 | * next: The URI to the next page of annotations. 295 | 296 | * previous: The URI to the previous page of annotations. 297 | 298 | * current: Current page number. 299 | 300 | * num_pages: The number of pages listing annotations. 301 | 302 | * results: A list of annotations returned. Each collection in the list contains these fields. 303 | 304 | * id: String. The primary key of the note. 305 | 306 | * user: String. Anonymized id of the user. 307 | 308 | * course_id: String. The identifier string of the annotations course. 309 | 310 | * usage_id: String. The identifier string of the annotations XBlock. 311 | 312 | * quote: String. Quoted text. 313 | 314 | * text: String. Student's thoughts on the quote. 315 | 316 | * ranges: List. Describes position of quote. 317 | 318 | * tags: List. Comma separated tags. 319 | 320 | * created: DateTime. Creation datetime of annotation. 321 | 322 | * updated: DateTime. When was the last time annotation was updated. 323 | 324 | **Form-encoded data for POST** 325 | 326 | user, course_id, usage_id, ranges and quote fields must be provided. 327 | 328 | **Response Values for POST** 329 | 330 | * id: String. The primary key of the note. 331 | 332 | * user: String. Anonymized id of the user. 333 | 334 | * course_id: String. The identifier string of the annotations course. 335 | 336 | * usage_id: String. The identifier string of the annotations XBlock. 337 | 338 | * quote: String. Quoted text. 339 | 340 | * text: String. Student's thoughts on the quote. 341 | 342 | * ranges: List. Describes position of quote in the source text. 343 | 344 | * tags: List. Comma separated tags. 345 | 346 | * created: DateTime. Creation datetime of annotation. 347 | 348 | * updated: DateTime. When was the last time annotation was updated. 349 | 350 | **Form-encoded data for DELETE** 351 | 352 | * user: Anonymized user id. 353 | 354 | **Response Values for DELETE** 355 | 356 | * no content. 357 | 358 | """ 359 | 360 | serializer_class = NoteSerializer 361 | 362 | def get(self, *args, **kwargs): 363 | """ 364 | Get paginated list of all annotations. 365 | """ 366 | params = self.request.query_params.dict() 367 | 368 | if "course_id" not in params: 369 | return Response(status=status.HTTP_400_BAD_REQUEST) 370 | 371 | if "user" not in params: 372 | return Response(status=status.HTTP_400_BAD_REQUEST) 373 | 374 | notes = Note.objects.filter( 375 | course_id=params["course_id"], user_id=params["user"] 376 | ).order_by("-updated") 377 | page = self.paginate_queryset(notes) 378 | serializer = self.get_serializer(page, many=True) 379 | response = self.get_paginated_response(serializer.data) 380 | return response 381 | 382 | def post(self, *args, **kwargs): 383 | """ 384 | Create a new annotation. 385 | 386 | Returns 400 request if bad payload is sent or it was empty object. 387 | """ 388 | if not self.request.data or "id" in self.request.data: 389 | return Response(status=status.HTTP_400_BAD_REQUEST) 390 | 391 | try: 392 | total_notes = Note.objects.filter( 393 | user_id=self.request.data["user"], 394 | course_id=self.request.data["course_id"], 395 | ).count() 396 | if total_notes >= settings.MAX_NOTES_PER_COURSE: 397 | raise AnnotationsLimitReachedError 398 | 399 | note = Note.create(self.request.data) 400 | note.full_clean() 401 | 402 | set_custom_attribute("notes.count", total_notes) 403 | except ValidationError as error: 404 | log.debug(error, exc_info=True) 405 | return Response(status=status.HTTP_400_BAD_REQUEST) 406 | except AnnotationsLimitReachedError: 407 | error_message = _( 408 | "You can create up to {max_num_annotations_per_course} notes." 409 | " You must remove some notes before you can add new ones." 410 | ).format(max_num_annotations_per_course=settings.MAX_NOTES_PER_COURSE) 411 | log.info( 412 | "Attempted to create more than %s annotations", 413 | settings.MAX_NOTES_PER_COURSE, 414 | ) 415 | 416 | return Response( 417 | {"error_msg": error_message}, status=status.HTTP_400_BAD_REQUEST 418 | ) 419 | 420 | note.save() 421 | 422 | location = reverse( 423 | "api:v1:annotations_detail", kwargs={"annotation_id": note.id} 424 | ) 425 | serializer = NoteSerializer(note) 426 | return Response( 427 | serializer.data, 428 | status=status.HTTP_201_CREATED, 429 | headers={"Location": location}, 430 | ) 431 | 432 | 433 | class AnnotationDetailView(APIView): 434 | """ 435 | **Use Case** 436 | 437 | * Get a single annotation. 438 | 439 | * Update an annotation. 440 | 441 | * Delete an annotation. 442 | 443 | **Example Requests** 444 | 445 | GET /api/v1/annotations/ 446 | PUT /api/v1/annotations/ 447 | DELETE /api/v1/annotations/ 448 | 449 | **Query Parameters for GET** 450 | 451 | HTTP404 is returned if annotation_id is missing. 452 | 453 | * annotation_id: Annotation id 454 | 455 | **Query Parameters for PUT** 456 | 457 | HTTP404 is returned if annotation_id is missing and HTTP400 is returned if text and tags are missing. 458 | 459 | * annotation_id: String. Annotation id 460 | 461 | * text: String. Text to be updated 462 | 463 | * tags: List. Tags to be updated 464 | 465 | **Query Parameters for DELETE** 466 | 467 | HTTP404 is returned if annotation_id is missing. 468 | 469 | * annotation_id: Annotation id 470 | 471 | **Response Values for GET** 472 | 473 | * id: String. The primary key of the note. 474 | 475 | * user: String. Anonymized id of the user. 476 | 477 | * course_id: String. The identifier string of the annotations course. 478 | 479 | * usage_id: String. The identifier string of the annotations XBlock. 480 | 481 | * quote: String. Quoted text. 482 | 483 | * text: String. Student's thoughts on the quote. 484 | 485 | * ranges: List. Describes position of quote. 486 | 487 | * tags: List. Comma separated tags. 488 | 489 | * created: DateTime. Creation datetime of annotation. 490 | 491 | * updated: DateTime. When was the last time annotation was updated. 492 | 493 | **Response Values for PUT** 494 | 495 | * same as GET with updated values 496 | 497 | **Response Values for DELETE** 498 | 499 | * HTTP_204_NO_CONTENT is returned 500 | """ 501 | 502 | def get(self, *args, **kwargs): 503 | """ 504 | Get an existing annotation. 505 | """ 506 | note_id = self.kwargs.get("annotation_id") 507 | 508 | try: 509 | note = Note.objects.get(id=note_id) 510 | except Note.DoesNotExist: 511 | return Response("Annotation not found!", status=status.HTTP_404_NOT_FOUND) 512 | 513 | serializer = NoteSerializer(note) 514 | return Response(serializer.data) 515 | 516 | def put(self, *args, **kwargs): 517 | """ 518 | Update an existing annotation. 519 | """ 520 | note_id = self.kwargs.get("annotation_id") 521 | 522 | try: 523 | note = Note.objects.get(id=note_id) 524 | except Note.DoesNotExist: 525 | return Response( 526 | "Annotation not found! No update performed.", 527 | status=status.HTTP_404_NOT_FOUND, 528 | ) 529 | 530 | try: 531 | note.text = self.request.data["text"] 532 | note.tags = json.dumps(self.request.data["tags"]) 533 | note.full_clean() 534 | except KeyError as error: 535 | log.debug(error, exc_info=True) 536 | return Response(status=status.HTTP_400_BAD_REQUEST) 537 | 538 | note.save() 539 | 540 | serializer = NoteSerializer(note) 541 | return Response(serializer.data) 542 | 543 | def delete(self, *args, **kwargs): 544 | """ 545 | Delete an annotation. 546 | """ 547 | note_id = self.kwargs.get("annotation_id") 548 | 549 | try: 550 | note = Note.objects.get(id=note_id) 551 | except Note.DoesNotExist: 552 | return Response( 553 | "Annotation not found! No update performed.", 554 | status=status.HTTP_404_NOT_FOUND, 555 | ) 556 | 557 | note.delete() 558 | 559 | # Annotation deleted successfully. 560 | return Response(status=status.HTTP_204_NO_CONTENT) 561 | -------------------------------------------------------------------------------- /notesapi/v1/views/elasticsearch.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import traceback 3 | 4 | from django_elasticsearch_dsl_drf.constants import ( 5 | LOOKUP_FILTER_TERM, 6 | LOOKUP_QUERY_IN, 7 | SEPARATOR_LOOKUP_COMPLEX_VALUE, 8 | ) 9 | from django_elasticsearch_dsl_drf.filter_backends import ( 10 | DefaultOrderingFilterBackend, 11 | HighlightBackend, 12 | ) 13 | from elasticsearch.exceptions import TransportError 14 | from elasticsearch_dsl import Search 15 | from elasticsearch_dsl.connections import connections 16 | 17 | from notesapi.v1.search_indexes.backends import ( 18 | CompoundSearchFilterBackend, 19 | FilteringFilterBackend, 20 | ) 21 | from notesapi.v1.search_indexes.documents import NoteDocument 22 | from notesapi.v1.search_indexes.paginators import NotesPagination as ESNotesPagination 23 | from notesapi.v1.search_indexes.serializers import ( 24 | NoteDocumentSerializer as NotesElasticSearchSerializer, 25 | ) 26 | 27 | from .common import AnnotationSearchView as BaseAnnotationSearchView 28 | from .exceptions import SearchViewRuntimeError 29 | 30 | logger = logging.getLogger(__name__) 31 | 32 | 33 | class AnnotationSearchView(BaseAnnotationSearchView): 34 | 35 | # https://django-elasticsearch-dsl-drf.readthedocs.io/en/latest/advanced_usage_examples.html 36 | filter_fields = { 37 | "course_id": "course_id", 38 | "user": "user", 39 | "usage_id": { 40 | "field": "usage_id", 41 | "lookups": [ 42 | LOOKUP_QUERY_IN, 43 | LOOKUP_FILTER_TERM, 44 | ], 45 | }, 46 | } 47 | highlight_fields = { 48 | "text": { 49 | "enabled": True, 50 | "options": { 51 | "pre_tags": ["{elasticsearch_highlight_start}"], 52 | "post_tags": ["{elasticsearch_highlight_end}"], 53 | "number_of_fragments": 0, 54 | }, 55 | }, 56 | "tags": { 57 | "enabled": True, 58 | "options": { 59 | "pre_tags": ["{elasticsearch_highlight_start}"], 60 | "post_tags": ["{elasticsearch_highlight_end}"], 61 | "number_of_fragments": 0, 62 | }, 63 | }, 64 | } 65 | 66 | def __init__(self, *args, **kwargs): 67 | self.client = connections.get_connection( 68 | NoteDocument._get_using() 69 | ) # pylint: disable=protected-access 70 | self.index = NoteDocument._index._name # pylint: disable=protected-access 71 | self.mapping = ( 72 | NoteDocument._doc_type.mapping.properties.name 73 | ) # pylint: disable=protected-access 74 | # pylint: disable=protected-access 75 | self.search = Search( 76 | using=self.client, index=self.index, doc_type=NoteDocument._doc_type.name 77 | ) 78 | super().__init__(*args, **kwargs) 79 | 80 | def get_serializer_class(self): 81 | """ 82 | Use Elasticsearch-specific serializer. 83 | """ 84 | if not self.is_text_search: 85 | return super().get_serializer_class() 86 | return NotesElasticSearchSerializer 87 | 88 | def get_queryset(self): 89 | """ 90 | Hackish method that doesn't quite return a Django queryset. 91 | """ 92 | if not self.is_text_search: 93 | return super().get_queryset() 94 | queryset = self.search.query() 95 | queryset.model = NoteDocument.Django.model 96 | return queryset 97 | 98 | def get_filter_backends(self): 99 | if not self.is_text_search: 100 | return super().get_filter_backends() 101 | filter_backends = [ 102 | FilteringFilterBackend, 103 | CompoundSearchFilterBackend, 104 | DefaultOrderingFilterBackend, 105 | ] 106 | if self.params.get("highlight"): 107 | filter_backends.append(HighlightBackend) 108 | return filter_backends 109 | 110 | @property 111 | def pagination_class(self): 112 | if not self.is_text_search: 113 | return super().pagination_class 114 | return ESNotesPagination 115 | 116 | def build_query_params_state(self): 117 | super().build_query_params_state() 118 | if not self.is_text_search: 119 | return 120 | if "usage_id__in" in self.query_params: 121 | usage_ids = self.query_params["usage_id__in"] 122 | usage_ids = SEPARATOR_LOOKUP_COMPLEX_VALUE.join(usage_ids) 123 | self.query_params["usage_id__in"] = usage_ids 124 | 125 | if "user" in self.params: 126 | self.query_params["user"] = self.query_params.pop("user_id") 127 | 128 | @classmethod 129 | def heartbeat(cls): 130 | if not get_es().ping(): 131 | raise SearchViewRuntimeError("es") 132 | 133 | @classmethod 134 | def selftest(cls): 135 | try: 136 | return {"es": get_es().info()} 137 | except TransportError as e: 138 | raise SearchViewRuntimeError({"es_error": traceback.format_exc()}) from e 139 | 140 | 141 | def get_es(): 142 | return connections.get_connection() 143 | -------------------------------------------------------------------------------- /notesapi/v1/views/exceptions.py: -------------------------------------------------------------------------------- 1 | class SearchViewRuntimeError(RuntimeError): 2 | pass 3 | -------------------------------------------------------------------------------- /notesapi/v1/views/meilisearch.py: -------------------------------------------------------------------------------- 1 | """ 2 | Meilisearch views to search for annotations. 3 | 4 | To enable this backend, define the following settings: 5 | 6 | ES_DISABLED = True 7 | MEILISEARCH_ENABLED = True 8 | 9 | Then check the Client class for more information about Meilisearch credential settings. 10 | 11 | When you start using this backend, you might want to re-index all your content. To do that, run: 12 | 13 | ./manage.py shell -c "from notesapi.v1.views.meilisearch import reindex; reindex()" 14 | """ 15 | 16 | import traceback 17 | 18 | import meilisearch 19 | from django.conf import settings 20 | from django.core.paginator import Paginator 21 | from django.db.models import signals 22 | 23 | from notesapi.v1.models import Note 24 | 25 | from .common import AnnotationSearchView as BaseAnnotationSearchView 26 | from .exceptions import SearchViewRuntimeError 27 | 28 | 29 | class Client: 30 | """ 31 | Simple Meilisearch client class 32 | 33 | It depends on the following Django settings: 34 | 35 | - MEILISEARCH_URL 36 | - MEILISEARCH_API_KEY 37 | - MEILISEARCH_INDEX 38 | """ 39 | 40 | _CLIENT = None 41 | _INDEX = None 42 | FILTERABLES = ["user_id", "course_id"] 43 | 44 | @property 45 | def meilisearch_client(self) -> meilisearch.Client: 46 | """ 47 | Return a meilisearch client. 48 | """ 49 | if self._CLIENT is None: 50 | self._CLIENT = meilisearch.Client( 51 | getattr(settings, "MEILISEARCH_URL", "http://meilisearch:7700"), 52 | getattr(settings, "MEILISEARCH_API_KEY", ""), 53 | ) 54 | return self._CLIENT 55 | 56 | @property 57 | def meilisearch_index(self) -> meilisearch.index.Index: 58 | """ 59 | Return the meilisearch index used to store annotations. 60 | 61 | If the index does not exist, it is created. And if it does not have the right 62 | filterable fields, then it is updated. 63 | """ 64 | if self._INDEX is None: 65 | index_name = getattr(settings, "MEILISEARCH_INDEX", "student_notes") 66 | try: 67 | self._INDEX = self.meilisearch_client.get_index(index_name) 68 | except meilisearch.errors.MeilisearchApiError: 69 | task = self.meilisearch_client.create_index( 70 | index_name, {"primaryKey": "id"} 71 | ) 72 | self.meilisearch_client.wait_for_task(task.task_uid, timeout_in_ms=2000) 73 | self._INDEX = self.meilisearch_client.get_index(index_name) 74 | 75 | # Checking filterable attributes 76 | existing_filterables = set(self._INDEX.get_filterable_attributes()) 77 | if not set(self.FILTERABLES).issubset(existing_filterables): 78 | all_filterables = list(existing_filterables.union(self.FILTERABLES)) 79 | self._INDEX.update_filterable_attributes(all_filterables) 80 | 81 | return self._INDEX 82 | 83 | 84 | class AnnotationSearchView(BaseAnnotationSearchView): 85 | def get_queryset(self): 86 | """ 87 | Simple result filtering method based on test search. 88 | 89 | We simply include in the query only those that match the text search query. Note 90 | that this backend does not support highlighting (yet). 91 | """ 92 | if not self.is_text_search: 93 | return super().get_queryset() 94 | 95 | queryset = Note.objects.filter(**self.query_params).order_by("-updated") 96 | 97 | # Define meilisearch params 98 | filters = [ 99 | f"user_id = '{self.params['user']}'", 100 | f"course_id = '{self.params['course_id']}'", 101 | ] 102 | page_size = int(self.params["page_size"]) 103 | offset = (int(self.params["page"]) - 1) * page_size 104 | 105 | # Perform search 106 | search_results = Client().meilisearch_index.search( 107 | self.params["text"], 108 | {"offset": offset, "limit": page_size, "filter": filters}, 109 | ) 110 | 111 | # Limit to these ID 112 | queryset = queryset.filter(id__in=[r["id"] for r in search_results["hits"]]) 113 | return queryset 114 | 115 | @classmethod 116 | def heartbeat(cls): 117 | """ 118 | Check that the meilisearch client is healthy. 119 | """ 120 | if not Client().meilisearch_client.is_healthy(): 121 | raise SearchViewRuntimeError("meilisearch") 122 | 123 | @classmethod 124 | def selftest(cls): 125 | """ 126 | Check that we can access the meilisearch index. 127 | """ 128 | try: 129 | return {"meilisearch": Client().meilisearch_index.created_at} 130 | except meilisearch.errors.MeilisearchError as e: 131 | raise SearchViewRuntimeError( 132 | {"meilisearch_error": traceback.format_exc()} 133 | ) from e 134 | 135 | 136 | def on_note_save(sender, instance, **kwargs): # pylint: disable=unused-argument 137 | """ 138 | Create or update a document. 139 | """ 140 | add_documents([instance]) 141 | 142 | 143 | def on_note_delete(sender, instance, **kwargs): # pylint: disable=unused-argument 144 | """ 145 | Delete a document. 146 | """ 147 | Client().meilisearch_index.delete_document(instance.id) 148 | 149 | 150 | def connect_signals() -> None: 151 | """ 152 | Connect Django signal to meilisearch indexing. 153 | """ 154 | signals.post_save.connect(on_note_save, sender=Note) 155 | signals.post_delete.connect(on_note_delete, sender=Note) 156 | 157 | 158 | def disconnect_signals() -> None: 159 | """ 160 | Disconnect Django signals: this is necessary in unit tests. 161 | """ 162 | signals.post_save.disconnect(on_note_save, sender=Note) 163 | signals.post_delete.disconnect(on_note_delete, sender=Note) 164 | 165 | 166 | connect_signals() 167 | 168 | 169 | def reindex(): 170 | """ 171 | Re-index all notes, in batches of 100. 172 | """ 173 | paginator = Paginator(Note.objects.all(), 100) 174 | for page_number in paginator.page_range: 175 | page = paginator.page(page_number) 176 | add_documents(page.object_list) 177 | 178 | 179 | def add_documents(notes): 180 | """ 181 | Convert some Note objects and insert them in the index. 182 | """ 183 | documents_to_add = [ 184 | { 185 | "id": note.id, 186 | "user_id": note.user_id, 187 | "course_id": note.course_id, 188 | "text": note.text, 189 | } 190 | for note in notes 191 | ] 192 | if documents_to_add: 193 | Client().meilisearch_index.add_documents(documents_to_add) 194 | -------------------------------------------------------------------------------- /notesapi/v1/views/mysql.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/edx-notes-api/386f57ee2022fdf7d35a775d3e1c9b436336359a/notesapi/v1/views/mysql.py -------------------------------------------------------------------------------- /notesserver/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/edx-notes-api/386f57ee2022fdf7d35a775d3e1c9b436336359a/notesserver/__init__.py -------------------------------------------------------------------------------- /notesserver/docker-compose.test.yml: -------------------------------------------------------------------------------- 1 | services: 2 | mysql: 3 | image: mysql:8.0 4 | environment: 5 | MYSQL_ROOT_PASSWORD: 6 | MYSQL_ALLOW_EMPTY_PASSWORD: "yes" 7 | MYSQL_DATABASE: "edx_notes_api" 8 | ports: 9 | - 127.0.0.1:3306:3306 10 | 11 | elasticsearch: 12 | image: elasticsearch:7.13.4 13 | environment: 14 | discovery.type: single-node 15 | bootstrap.memory_lock: "true" 16 | ES_JAVA_OPTS: "-Xms512m -Xmx512m" 17 | ports: 18 | - 127.0.0.1:9200:9200 19 | -------------------------------------------------------------------------------- /notesserver/docker_gunicorn_configuration.py: -------------------------------------------------------------------------------- 1 | """ 2 | gunicorn configuration file: http://docs.gunicorn.org/en/develop/configure.html 3 | """ 4 | from django.conf import settings 5 | from django.core import cache as django_cache 6 | 7 | 8 | preload_app = True 9 | timeout = 300 10 | bind = "0.0.0.0:8120" 11 | 12 | workers = 2 13 | 14 | 15 | def pre_request(worker, req): 16 | worker.log.info("%s %s" % (req.method, req.path)) 17 | 18 | 19 | def close_all_caches(): 20 | # Close the cache so that newly forked workers cannot accidentally share 21 | # the socket with the processes they were forked from. This prevents a race 22 | # condition in which one worker could get a cache response intended for 23 | # another worker. 24 | # We do this in a way that is safe for 1.4 and 1.8 while we still have some 25 | # 1.4 installations. 26 | if hasattr(django_cache, 'caches'): 27 | get_cache = django_cache.caches.__getitem__ 28 | else: 29 | get_cache = django_cache.get_cache # pylint: disable=no-member 30 | for cache_name in settings.CACHES: 31 | cache = get_cache(cache_name) 32 | if hasattr(cache, 'close'): 33 | cache.close() 34 | 35 | # The 1.4 global default cache object needs to be closed also: 1.4 36 | # doesn't ensure you get the same object when requesting the same 37 | # cache. The global default is a separate Python object from the cache 38 | # you get with get_cache("default"), so it will have its own connection 39 | # that needs to be closed. 40 | cache = django_cache.cache 41 | if hasattr(cache, 'close'): 42 | cache.close() 43 | 44 | 45 | def post_fork(server, worker): # pylint: disable=unused-argument 46 | close_all_caches() 47 | -------------------------------------------------------------------------------- /notesserver/settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/edx-notes-api/386f57ee2022fdf7d35a775d3e1c9b436336359a/notesserver/settings/__init__.py -------------------------------------------------------------------------------- /notesserver/settings/common.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | DEBUG = False 4 | TEMPLATE_DEBUG = False 5 | DISABLE_TOKEN_CHECK = False 6 | USE_TZ = True 7 | TIME_ZONE = 'UTC' 8 | AUTH_USER_MODEL = 'auth.User' # pylint: disable=hard-coded-auth-user 9 | 10 | # This value needs to be overriden in production. 11 | SECRET_KEY = 'CHANGEME' 12 | ALLOWED_HOSTS = ['*'] 13 | 14 | # ID and Secret used for authenticating JWT Auth Tokens 15 | # should match those configured for `edx-notes` Client in EdX's /admin/oauth2/client/ 16 | CLIENT_ID = 'CHANGEME' 17 | CLIENT_SECRET = 'CHANGEME' 18 | 19 | ES_DISABLED = False 20 | 21 | ELASTICSEARCH_DSL = {'default': {'hosts': '127.0.0.1:9200'}} 22 | 23 | ELASTICSEARCH_DSL_INDEX_SETTINGS = {'number_of_shards': 1, 'number_of_replicas': 0} 24 | 25 | # Name of the Elasticsearch index 26 | ELASTICSEARCH_INDEX_NAMES = {'notesapi.v1.search_indexes.documents.note': 'edx_notes_api'} 27 | ELASTICSEARCH_DSL_SIGNAL_PROCESSOR = 'django_elasticsearch_dsl.signals.RealTimeSignalProcessor' 28 | 29 | # Number of rows to return by default in result. 30 | RESULTS_DEFAULT_SIZE = 25 31 | 32 | # Max number of rows to return in result. 33 | RESULTS_MAX_SIZE = 250 34 | 35 | ROOT_URLCONF = 'notesserver.urls' 36 | 37 | DEFAULT_HASHING_ALGORITHM = "sha1" 38 | 39 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 40 | 41 | 42 | MIDDLEWARE = ( 43 | 'edx_django_utils.monitoring.CookieMonitoringMiddleware', 44 | 'edx_django_utils.monitoring.DeploymentMonitoringMiddleware', 45 | 'edx_django_utils.cache.middleware.RequestCacheMiddleware', 46 | 'corsheaders.middleware.CorsMiddleware', 47 | 'django.middleware.common.CommonMiddleware', 48 | 'django.middleware.csrf.CsrfViewMiddleware', 49 | 'edx_rest_framework_extensions.middleware.RequestCustomAttributesMiddleware', 50 | 'edx_rest_framework_extensions.auth.jwt.middleware.EnsureJWTAuthSettingsMiddleware', 51 | ) 52 | 53 | ES_APPS = ('elasticsearch_dsl', 'django_elasticsearch_dsl', 'django_elasticsearch_dsl_drf',) 54 | 55 | INSTALLED_APPS = [ 56 | 'django.contrib.auth', 57 | 'django.contrib.contenttypes', 58 | 'django.contrib.staticfiles', 59 | 'rest_framework', 60 | 'corsheaders', 61 | 'notesapi.v1', 62 | # additional release utilities to ease automation 63 | 'release_util', 64 | 'drf_spectacular', 65 | ] 66 | if not ES_DISABLED: 67 | INSTALLED_APPS.extend(ES_APPS) 68 | 69 | STATIC_URL = '/static/' 70 | 71 | WSGI_APPLICATION = 'notesserver.wsgi.application' 72 | 73 | LOG_SETTINGS_LOG_DIR = '/var/tmp' 74 | LOG_SETTINGS_LOGGING_ENV = 'no_env' 75 | LOG_SETTINGS_DEV_ENV = False 76 | LOG_SETTINGS_DEBUG = False 77 | LOG_SETTINGS_LOCAL_LOGLEVEL = 'INFO' 78 | LOG_SETTINGS_EDX_FILENAME = "edx.log" 79 | LOG_SETTINGS_SERVICE_VARIANT = 'edx-notes-api' 80 | 81 | REST_FRAMEWORK = { 82 | 'DEFAULT_AUTHENTICATION_CLASSES': ['rest_framework.authentication.SessionAuthentication'], 83 | 'DEFAULT_PERMISSION_CLASSES': ['notesapi.v1.permissions.HasAccessToken'], 84 | 'DEFAULT_PAGINATION_CLASS': 'notesapi.v1.paginators.NotesPaginator', 85 | 'DEFAULT_RENDERER_CLASSES': ('rest_framework.renderers.JSONRenderer',), 86 | 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', 87 | } 88 | 89 | SPECTACULAR_SETTINGS = { 90 | 'TITLE': 'Edx Notes API', 91 | 'DESCRIPTION': 'Edx Notes API docs', 92 | 'VERSION': '1.0.0', 93 | 'SERVE_INCLUDE_SCHEMA': False, 94 | 'SCHEMA_PATH_PREFIX': '/api/' 95 | } 96 | 97 | # CORS is configured to allow all origins because requests to the 98 | # Notes API do not rely on ambient authority; instead, they are 99 | # authorized explicitly via an X-Annotator-Auth-Token header. (The 100 | # default permission class is HasAccessToken, which checks it.) 101 | CORS_ORIGIN_ALLOW_ALL = True 102 | CORS_ALLOW_HEADERS = ( 103 | 'x-requested-with', 104 | 'content-type', 105 | 'accept', 106 | 'origin', 107 | 'authorization', 108 | 'x-csrftoken', 109 | 'x-annotator-auth-token', 110 | ) 111 | 112 | # Base project path, where manage.py lives. 113 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) 114 | 115 | TEMPLATES = [ 116 | { 117 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 118 | 'APP_DIRS': True, # This ensures app templates are loadable, e.g. DRF views. 119 | 'DIRS': [ 120 | # The EdxNotes templates directory is not actually under any app 121 | # directory, so specify its absolute path. 122 | os.path.join(BASE_DIR, 'templates'), 123 | ], 124 | } 125 | ] 126 | 127 | DEFAULT_NOTES_PAGE_SIZE = 25 128 | 129 | # Maximum number of allowed notes for each student per course 130 | MAX_NOTES_PER_COURSE = 500 131 | 132 | ELASTICSEARCH_URL = 'localhost:9200' 133 | ELASTICSEARCH_INDEX = 'edx_notes' 134 | 135 | DATABASES = { 136 | 'default': { 137 | 'ENGINE': 'django.db.backends.mysql', 138 | 'HOST': 'localhost', 139 | 'NAME': 'edx_notes_api', 140 | 'OPTIONS': {'connect_timeout': 10}, 141 | 'PASSWORD': 'secret', 142 | 'PORT': 3306, 143 | 'USER': 'notes001', 144 | } 145 | } 146 | 147 | USERNAME_REPLACEMENT_WORKER = 'OVERRIDE THIS WITH A VALID USERNAME' 148 | 149 | JWT_AUTH = { 150 | 'JWT_AUTH_HEADER_PREFIX': 'JWT', 151 | 'JWT_ISSUER': [ 152 | {'AUDIENCE': 'SET-ME-PLEASE', 'ISSUER': 'http://127.0.0.1:8000/oauth2', 'SECRET_KEY': 'SET-ME-PLEASE'}, 153 | ], 154 | 'JWT_PUBLIC_SIGNING_JWK_SET': None, 155 | 'JWT_AUTH_COOKIE_HEADER_PAYLOAD': 'edx-jwt-cookie-header-payload', 156 | 'JWT_AUTH_COOKIE_SIGNATURE': 'edx-jwt-cookie-signature', 157 | 'JWT_ALGORITHM': 'HS256', 158 | } 159 | 160 | CSRF_TRUSTED_ORIGINS = [] 161 | 162 | # Django 4.0+ uses zoneinfo if this is not set. We can remove this and 163 | # migrate to zoneinfo after Django 4.2 upgrade. See more on following url 164 | # https://docs.djangoproject.com/en/4.2/releases/4.0/#zoneinfo-default-timezone-implementation 165 | USE_DEPRECATED_PYTZ = True 166 | -------------------------------------------------------------------------------- /notesserver/settings/dev.py: -------------------------------------------------------------------------------- 1 | from notesserver.settings.logger import build_logging_config 2 | 3 | from .common import * # pylint: disable=wildcard-import 4 | 5 | DEBUG = True 6 | LOG_SETTINGS_DEBUG = True 7 | LOG_SETTINGS_DEV_ENV = True 8 | 9 | ELASTICSEARCH_INDEX_NAMES = {'notesapi.v1.search_indexes.documents.note': 'notes_index_dev'} 10 | 11 | DATABASES = { 12 | 'default': { 13 | 'ENGINE': 'django.db.backends.sqlite3', 14 | 'NAME': 'default.db', 15 | } 16 | } 17 | 18 | LOGGING = build_logging_config() 19 | -------------------------------------------------------------------------------- /notesserver/settings/devstack.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from notesserver.settings.logger import build_logging_config 4 | 5 | from .common import * # pylint: disable=wildcard-import 6 | 7 | DEBUG = True 8 | LOG_SETTINGS_DEBUG = True 9 | LOG_SETTINGS_DEV_ENV = True 10 | 11 | ALLOWED_HOSTS = ['*'] 12 | 13 | # These values are derived from provision-ida-user.sh in the edx/devstack repo. 14 | CLIENT_ID = 'edx_notes_api-backend-service-key' 15 | CLIENT_SECRET = 'edx_notes_api-backend-service-secret' 16 | 17 | ELASTICSEARCH_INDEX_NAMES = {'notesapi.v1.search_indexes.documents.note': 'notes_index'} 18 | ELASTICSEARCH_DSL['default']['hosts'] = os.environ.get('ELASTICSEARCH_DSL', 'edx.devstack.elasticsearch7:9200') 19 | 20 | DATABASES = { 21 | 'default': { 22 | 'ENGINE': 'django.db.backends.mysql', 23 | 'NAME': os.environ.get('DB_NAME', 'notes'), 24 | 'USER': os.environ.get('DB_USER', 'notes001'), 25 | 'PASSWORD': os.environ.get('DB_PASSWORD', 'password'), 26 | 'HOST': os.environ.get('DB_HOST', 'edx.devstack.mysql'), 27 | 'PORT': os.environ.get('DB_PORT', 3306), 28 | 'CONN_MAX_AGE': 60, 29 | } 30 | } 31 | 32 | JWT_AUTH = {} 33 | 34 | LOGGING = build_logging_config() 35 | -------------------------------------------------------------------------------- /notesserver/settings/logger.py: -------------------------------------------------------------------------------- 1 | """ 2 | Logging configuration 3 | """ 4 | 5 | import os 6 | import platform 7 | import sys 8 | from logging.handlers import SysLogHandler 9 | 10 | from django.conf import settings 11 | 12 | 13 | def build_logging_config(): 14 | 15 | """ 16 | Return the appropriate logging config dictionary. You should assign the 17 | result of this to the LOGGING var in your settings. 18 | If dev_env is set to true logging will not be done via local rsyslogd, 19 | instead, application logs will be dropped in log_dir. 20 | "edx_filename" is ignored unless dev_env is set to true since otherwise 21 | logging is handled by rsyslogd. 22 | """ 23 | # Revert to INFO if an invalid string is passed in 24 | 25 | log_dir = settings.LOG_SETTINGS_LOG_DIR 26 | logging_env = settings.LOG_SETTINGS_LOGGING_ENV 27 | edx_filename = settings.LOG_SETTINGS_EDX_FILENAME 28 | dev_env = settings.LOG_SETTINGS_DEV_ENV 29 | debug = settings.LOG_SETTINGS_DEBUG 30 | local_loglevel = settings.LOG_SETTINGS_LOCAL_LOGLEVEL 31 | service_variant = settings.LOG_SETTINGS_SERVICE_VARIANT 32 | 33 | if local_loglevel not in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']: 34 | local_loglevel = 'INFO' 35 | 36 | hostname = platform.node().split(".")[0] 37 | syslog_format = ( 38 | "[service_variant={service_variant}]" 39 | "[%(name)s][env:{logging_env}] %(levelname)s " 40 | "[{hostname} %(process)d] [%(filename)s:%(lineno)d] " 41 | "- %(message)s" 42 | ).format(service_variant=service_variant, logging_env=logging_env, hostname=hostname) 43 | 44 | if debug: 45 | handlers = ['console'] 46 | else: 47 | handlers = ['local'] 48 | 49 | logger_config = { 50 | 'version': 1, 51 | 'disable_existing_loggers': False, 52 | 'formatters': { 53 | 'standard': { 54 | 'format': '%(asctime)s %(levelname)s %(process)d ' '[%(name)s] %(filename)s:%(lineno)d - %(message)s', 55 | }, 56 | 'syslog_format': {'format': syslog_format}, 57 | 'raw': {'format': '%(message)s'}, 58 | }, 59 | 'handlers': { 60 | 'console': { 61 | 'level': 'DEBUG' if debug else 'INFO', 62 | 'class': 'logging.StreamHandler', 63 | 'formatter': 'standard', 64 | 'stream': sys.stdout, 65 | }, 66 | }, 67 | 'loggers': { 68 | 'django': {'handlers': handlers, 'propagate': True, 'level': 'INFO'}, 69 | "elasticsearch.trace": {'handlers': handlers, 'level': 'WARNING', 'propagate': False}, 70 | '': {'handlers': handlers, 'level': 'DEBUG', 'propagate': False}, 71 | }, 72 | } 73 | 74 | if dev_env: 75 | edx_file_loc = os.path.join(log_dir, edx_filename) 76 | logger_config['handlers'].update( 77 | { 78 | 'local': { 79 | 'class': 'logging.handlers.RotatingFileHandler', 80 | 'level': local_loglevel, 81 | 'formatter': 'standard', 82 | 'filename': edx_file_loc, 83 | 'maxBytes': 1024 * 1024 * 2, 84 | 'backupCount': 5, 85 | } 86 | } 87 | ) 88 | else: 89 | logger_config['handlers'].update( 90 | { 91 | 'local': { 92 | 'level': local_loglevel, 93 | 'class': 'logging.handlers.SysLogHandler', 94 | # Use a different address for Mac OS X 95 | 'address': '/var/run/syslog' if sys.platform == "darwin" else '/dev/log', 96 | 'formatter': 'syslog_format', 97 | 'facility': SysLogHandler.LOG_LOCAL0, 98 | } 99 | } 100 | ) 101 | 102 | return logger_config 103 | -------------------------------------------------------------------------------- /notesserver/settings/test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from .common import * # pylint: disable=wildcard-import 5 | 6 | DATABASES = { 7 | 'default': { 8 | 'ENGINE': os.environ.get('DB_ENGINE', 'django.db.backends.sqlite3'), 9 | 'NAME': os.environ.get('DB_NAME', 'default.db'), 10 | 'USER': os.environ.get('DB_USER', ''), 11 | 'PASSWORD': os.environ.get('DB_PASSWORD', ''), 12 | 'HOST': os.environ.get('DB_HOST', ''), 13 | 'PORT': os.environ.get('DB_PORT', ''), 14 | 'CONN_MAX_AGE': int(os.environ.get('CONN_MAX_AGE', 0)), 15 | } 16 | } 17 | 18 | DISABLE_TOKEN_CHECK = False 19 | 20 | JWT_AUTH = {} 21 | 22 | ELASTICSEARCH_DSL = {'default': {'hosts': os.environ.get('ELASTICSEARCH_URL', 'localhost:9200')}} 23 | 24 | # Name of the Elasticsearch index 25 | ELASTICSEARCH_INDEX_NAMES = {'notesapi.v1.search_indexes.documents.note': 'notes_index_test'} 26 | 27 | LOGGING = { 28 | 'version': 1, 29 | 'disable_existing_loggers': False, 30 | 'handlers': {'console': {'level': 'DEBUG', 'class': 'logging.StreamHandler', 'stream': sys.stderr}}, 31 | 'loggers': { 32 | 'django': {'handlers': ['console'], 'level': 'ERROR', 'propagate': False}, 33 | 'elasticsearch.trace': {'handlers': ['console'], 'level': 'ERROR', 'propagate': False}, 34 | }, 35 | } 36 | 37 | DEFAULT_NOTES_PAGE_SIZE = 10 38 | -------------------------------------------------------------------------------- /notesserver/settings/test_es_disabled.py: -------------------------------------------------------------------------------- 1 | from .test import * # pylint: disable=wildcard-import 2 | 3 | ES_DISABLED = True 4 | ELASTICSEARCH_DSL = {'default': {}} 5 | -------------------------------------------------------------------------------- /notesserver/settings/yaml_config.py: -------------------------------------------------------------------------------- 1 | from os import environ 2 | 3 | import yaml 4 | from django.core.exceptions import ImproperlyConfigured 5 | from path import Path 6 | 7 | from notesserver.settings.logger import build_logging_config 8 | 9 | from .common import * # pylint: disable=wildcard-import 10 | 11 | ############################################################################### 12 | # Explicitly declare here in case someone changes common.py. 13 | ############################################################################### 14 | DEBUG = False 15 | TEMPLATE_DEBUG = False 16 | DISABLE_TOKEN_CHECK = False 17 | ############################################################################### 18 | 19 | EDXNOTES_CONFIG_ROOT = environ.get('EDXNOTES_CONFIG_ROOT') 20 | 21 | if not EDXNOTES_CONFIG_ROOT: 22 | raise ImproperlyConfigured("EDXNOTES_CONFIG_ROOT must be defined in the environment.") 23 | 24 | CONFIG_ROOT = Path(EDXNOTES_CONFIG_ROOT) 25 | 26 | with open(CONFIG_ROOT / "edx_notes_api.yml") as yaml_file: 27 | config_from_yaml = yaml.safe_load(yaml_file) 28 | 29 | vars().update(config_from_yaml) 30 | 31 | # Support environment overrides for migrations 32 | DB_OVERRIDES = { 33 | "PASSWORD": environ.get("DB_MIGRATION_PASS", DATABASES["default"]["PASSWORD"]), 34 | "ENGINE": environ.get("DB_MIGRATION_ENGINE", DATABASES["default"]["ENGINE"]), 35 | "USER": environ.get("DB_MIGRATION_USER", DATABASES["default"]["USER"]), 36 | "NAME": environ.get("DB_MIGRATION_NAME", DATABASES["default"]["NAME"]), 37 | "HOST": environ.get("DB_MIGRATION_HOST", DATABASES["default"]["HOST"]), 38 | "PORT": environ.get("DB_MIGRATION_PORT", DATABASES["default"]["PORT"]), 39 | } 40 | 41 | for override, value in DB_OVERRIDES.items(): 42 | DATABASES['default'][override] = value 43 | 44 | if ES_DISABLED: 45 | ELASTICSEARCH_DSL = {} 46 | 47 | LOGGING = build_logging_config() 48 | -------------------------------------------------------------------------------- /notesserver/test_views.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | from unittest import skipIf 4 | from unittest.mock import Mock, patch 5 | 6 | from django.conf import settings 7 | from django.urls import reverse 8 | from elasticsearch.exceptions import TransportError 9 | from rest_framework.test import APITestCase 10 | 11 | 12 | class OperationalEndpointsTest(APITestCase): 13 | """ 14 | Tests for operational endpoints. 15 | """ 16 | def test_heartbeat(self): 17 | """ 18 | Heartbeat endpoint success. 19 | """ 20 | response = self.client.get(reverse('heartbeat')) 21 | self.assertEqual(response.status_code, 200) 22 | self.assertEqual(json.loads(bytes.decode(response.content, 'utf-8')), {"OK": True}) 23 | 24 | @skipIf(settings.ES_DISABLED, "Do not test if Elasticsearch service is disabled.") 25 | @patch("notesapi.v1.views.elasticsearch.get_es") 26 | def test_heartbeat_failure_es(self, mocked_get_es): 27 | """ 28 | Elasticsearch is not reachable. 29 | """ 30 | mocked_get_es.return_value.ping.return_value = False 31 | response = self.client.get(reverse('heartbeat')) 32 | self.assertEqual(response.status_code, 500) 33 | self.assertEqual(json.loads(bytes.decode(response.content, 'utf-8')), {"OK": False, "check": "es"}) 34 | 35 | @patch("django.db.backends.utils.CursorWrapper") 36 | def test_heartbeat_failure_db(self, mocked_cursor_wrapper): 37 | """ 38 | Database is not reachable. 39 | """ 40 | mocked_cursor_wrapper.side_effect = Exception 41 | response = self.client.get(reverse('heartbeat')) 42 | self.assertEqual(response.status_code, 500) 43 | self.assertEqual(json.loads(bytes.decode(response.content, 'utf-8')), {"OK": False, "check": "db"}) 44 | 45 | def test_root(self): 46 | """ 47 | Test root endpoint. 48 | """ 49 | response = self.client.get(reverse('root')) 50 | self.assertEqual(response.status_code, 200) 51 | self.assertEqual( 52 | response.data, 53 | { 54 | "name": "edX Notes API", 55 | "version": "1" 56 | } 57 | ) 58 | 59 | def test_selftest_status(self): 60 | """ 61 | Test status success. 62 | """ 63 | response = self.client.get(reverse('selftest')) 64 | self.assertEqual(response.status_code, 200) 65 | 66 | @skipIf(settings.ES_DISABLED, "Do not test if Elasticsearch service is disabled.") 67 | @patch('notesserver.views.datetime', datetime=Mock(wraps=datetime.datetime)) 68 | @patch("notesapi.v1.views.elasticsearch.get_es") 69 | def test_selftest_data(self, mocked_get_es, mocked_datetime): 70 | """ 71 | Test returned data on success. 72 | """ 73 | mocked_datetime.datetime.now.return_value = datetime.datetime(2014, 12, 11) 74 | mocked_get_es.return_value.info.return_value = {} 75 | response = self.client.get(reverse('selftest')) 76 | self.assertEqual(response.status_code, 200) 77 | self.assertEqual( 78 | response.data, 79 | { 80 | "es": {}, 81 | "db": "OK", 82 | "time_elapsed": 0.0 83 | } 84 | ) 85 | 86 | @patch('django.conf.settings.ES_DISABLED', True) 87 | @patch('notesserver.views.datetime', datetime=Mock(wraps=datetime.datetime)) 88 | def test_selftest_data_es_disabled(self, mocked_datetime): 89 | """ 90 | Test returned data on success. 91 | """ 92 | mocked_datetime.datetime.now.return_value = datetime.datetime(2014, 12, 11) 93 | response = self.client.get(reverse('selftest')) 94 | self.assertEqual(response.status_code, 200) 95 | self.assertEqual( 96 | response.data, 97 | { 98 | "db": "OK", 99 | "time_elapsed": 0.0 100 | } 101 | ) 102 | 103 | @skipIf(settings.ES_DISABLED, "Do not test if Elasticsearch service is disabled.") 104 | @patch("notesapi.v1.views.elasticsearch.get_es") 105 | def test_selftest_failure_es(self, mocked_get_es): 106 | """ 107 | Elasticsearch is not reachable on selftest. 108 | """ 109 | mocked_get_es.return_value.info.side_effect = TransportError() 110 | response = self.client.get(reverse('selftest')) 111 | self.assertEqual(response.status_code, 500) 112 | self.assertIn('es_error', response.data) 113 | 114 | @patch("django.db.backends.utils.CursorWrapper") 115 | def test_selftest_failure_db(self, mocked_cursor_wrapper): 116 | """ 117 | Database is not reachable on selftest. 118 | """ 119 | mocked_cursor_wrapper.side_effect = Exception 120 | response = self.client.get(reverse('selftest')) 121 | self.assertEqual(response.status_code, 500) 122 | self.assertIn('db_error', response.data) 123 | -------------------------------------------------------------------------------- /notesserver/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path, re_path 2 | from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView 3 | 4 | import notesserver.views 5 | 6 | urlpatterns = [ 7 | path('heartbeat/', notesserver.views.heartbeat, name='heartbeat'), 8 | path('selftest/', notesserver.views.selftest, name='selftest'), 9 | re_path(r'^robots.txt$', notesserver.views.robots, name='robots'), 10 | path('', notesserver.views.root, name='root'), 11 | path('api/', include('notesapi.urls', namespace='api')), 12 | path('api/schema/', SpectacularAPIView.as_view(), name='schema'), 13 | path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), 14 | path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), 15 | ] 16 | -------------------------------------------------------------------------------- /notesserver/views.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import traceback 3 | 4 | from django.db import connection 5 | from django.http import JsonResponse 6 | from django.http import HttpResponse 7 | from edx_django_utils.monitoring import ignore_transaction 8 | from rest_framework import status 9 | from rest_framework.decorators import api_view, permission_classes 10 | from rest_framework.permissions import AllowAny 11 | from rest_framework.response import Response 12 | 13 | from notesapi.v1.views import get_annotation_search_view_class 14 | from notesapi.v1.views import SearchViewRuntimeError 15 | 16 | 17 | @api_view(['GET']) 18 | @permission_classes([AllowAny]) 19 | def root(request): 20 | """ 21 | Root view. 22 | """ 23 | return Response({ 24 | "name": "edX Notes API", 25 | "version": "1" 26 | }) 27 | 28 | 29 | @api_view(['GET']) 30 | @permission_classes([AllowAny]) 31 | def robots(request): 32 | """ 33 | robots.txt 34 | """ 35 | return HttpResponse("User-agent: * Disallow: /", content_type="text/plain") 36 | 37 | 38 | @api_view(['GET']) 39 | @permission_classes([AllowAny]) 40 | def heartbeat(request): 41 | """ 42 | ElasticSearch and database are reachable and ready to handle requests. 43 | """ 44 | ignore_transaction() # no need to record telemetry for heartbeats 45 | try: 46 | db_status() 47 | except Exception: # pylint: disable=broad-exception-caught 48 | return JsonResponse({"OK": False, "check": "db"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) 49 | 50 | try: 51 | get_annotation_search_view_class().heartbeat() 52 | except SearchViewRuntimeError as e: 53 | return JsonResponse({"OK": False, "check": e.args[0]}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) 54 | 55 | return JsonResponse({"OK": True}, status=status.HTTP_200_OK) 56 | 57 | 58 | @api_view(['GET']) 59 | @permission_classes([AllowAny]) 60 | def selftest(request): 61 | """ 62 | Manual test endpoint. 63 | """ 64 | start = datetime.datetime.now() 65 | 66 | response = {} 67 | try: 68 | response.update(get_annotation_search_view_class().selftest()) 69 | except SearchViewRuntimeError as e: 70 | return Response( 71 | e.args[0], 72 | status=status.HTTP_500_INTERNAL_SERVER_ERROR 73 | ) 74 | 75 | try: 76 | db_status() 77 | response["db"] = "OK" 78 | except Exception: # pylint: disable=broad-exception-caught 79 | return Response( 80 | {"db_error": traceback.format_exc()}, 81 | status=status.HTTP_500_INTERNAL_SERVER_ERROR 82 | ) 83 | 84 | end = datetime.datetime.now() 85 | delta = end - start 86 | response["time_elapsed"] = int(delta.total_seconds() * 1000) # In milliseconds. 87 | 88 | return Response(response) 89 | 90 | 91 | def db_status(): 92 | """ 93 | Return database status. 94 | """ 95 | with connection.cursor() as cursor: 96 | cursor.execute("SELECT 1") 97 | cursor.fetchone() 98 | -------------------------------------------------------------------------------- /notesserver/wsgi.py: -------------------------------------------------------------------------------- 1 | from django.core.wsgi import get_wsgi_application 2 | 3 | application = get_wsgi_application() 4 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | # *************************** 2 | # ** DO NOT EDIT THIS FILE ** 3 | # *************************** 4 | # 5 | # This file was generated by edx-lint: https://github.com/openedx/edx-lint 6 | # 7 | # If you want to change this file, you have two choices, depending on whether 8 | # you want to make a local change that applies only to this repo, or whether 9 | # you want to make a central change that applies to all repos using edx-lint. 10 | # 11 | # Note: If your pylintrc file is simply out-of-date relative to the latest 12 | # pylintrc in edx-lint, ensure you have the latest edx-lint installed 13 | # and then follow the steps for a "LOCAL CHANGE". 14 | # 15 | # LOCAL CHANGE: 16 | # 17 | # 1. Edit the local pylintrc_tweaks file to add changes just to this 18 | # repo's file. 19 | # 20 | # 2. Run: 21 | # 22 | # $ edx_lint write pylintrc 23 | # 24 | # 3. This will modify the local file. Submit a pull request to get it 25 | # checked in so that others will benefit. 26 | # 27 | # 28 | # CENTRAL CHANGE: 29 | # 30 | # 1. Edit the pylintrc file in the edx-lint repo at 31 | # https://github.com/openedx/edx-lint/blob/master/edx_lint/files/pylintrc 32 | # 33 | # 2. install the updated version of edx-lint (in edx-lint): 34 | # 35 | # $ pip install . 36 | # 37 | # 3. Run (in edx-lint): 38 | # 39 | # $ edx_lint write pylintrc 40 | # 41 | # 4. Make a new version of edx_lint, submit and review a pull request with the 42 | # pylintrc update, and after merging, update the edx-lint version and 43 | # publish the new version. 44 | # 45 | # 5. In your local repo, install the newer version of edx-lint. 46 | # 47 | # 6. Run: 48 | # 49 | # $ edx_lint write pylintrc 50 | # 51 | # 7. This will modify the local file. Submit a pull request to get it 52 | # checked in so that others will benefit. 53 | # 54 | # 55 | # 56 | # 57 | # 58 | # STAY AWAY FROM THIS FILE! 59 | # 60 | # 61 | # 62 | # 63 | # 64 | # SERIOUSLY. 65 | # 66 | # ------------------------------ 67 | # Generated by edx-lint version: 5.4.1 68 | # ------------------------------ 69 | [MASTER] 70 | ignore = .git, .tox, migrations 71 | persistent = yes 72 | load-plugins = edx_lint.pylint,pylint_django 73 | 74 | [MESSAGES CONTROL] 75 | enable = 76 | blacklisted-name, 77 | line-too-long, 78 | 79 | abstract-class-instantiated, 80 | abstract-method, 81 | access-member-before-definition, 82 | anomalous-backslash-in-string, 83 | anomalous-unicode-escape-in-string, 84 | arguments-differ, 85 | assert-on-tuple, 86 | assigning-non-slot, 87 | assignment-from-no-return, 88 | assignment-from-none, 89 | attribute-defined-outside-init, 90 | bad-except-order, 91 | bad-format-character, 92 | bad-format-string-key, 93 | bad-format-string, 94 | bad-open-mode, 95 | bad-reversed-sequence, 96 | bad-staticmethod-argument, 97 | bad-str-strip-call, 98 | bad-super-call, 99 | binary-op-exception, 100 | boolean-datetime, 101 | catching-non-exception, 102 | cell-var-from-loop, 103 | confusing-with-statement, 104 | continue-in-finally, 105 | dangerous-default-value, 106 | duplicate-argument-name, 107 | duplicate-bases, 108 | duplicate-except, 109 | duplicate-key, 110 | expression-not-assigned, 111 | format-combined-specification, 112 | format-needs-mapping, 113 | function-redefined, 114 | global-variable-undefined, 115 | import-error, 116 | import-self, 117 | inconsistent-mro, 118 | inherit-non-class, 119 | init-is-generator, 120 | invalid-all-object, 121 | invalid-format-index, 122 | invalid-length-returned, 123 | invalid-sequence-index, 124 | invalid-slice-index, 125 | invalid-slots-object, 126 | invalid-slots, 127 | invalid-unary-operand-type, 128 | logging-too-few-args, 129 | logging-too-many-args, 130 | logging-unsupported-format, 131 | lost-exception, 132 | method-hidden, 133 | misplaced-bare-raise, 134 | misplaced-future, 135 | missing-format-argument-key, 136 | missing-format-attribute, 137 | missing-format-string-key, 138 | no-member, 139 | no-method-argument, 140 | no-name-in-module, 141 | no-self-argument, 142 | no-value-for-parameter, 143 | non-iterator-returned, 144 | non-parent-method-called, 145 | nonexistent-operator, 146 | not-a-mapping, 147 | not-an-iterable, 148 | not-callable, 149 | not-context-manager, 150 | not-in-loop, 151 | pointless-statement, 152 | pointless-string-statement, 153 | raising-bad-type, 154 | raising-non-exception, 155 | redefined-builtin, 156 | redefined-outer-name, 157 | redundant-keyword-arg, 158 | repeated-keyword, 159 | return-arg-in-generator, 160 | return-in-init, 161 | return-outside-function, 162 | signature-differs, 163 | super-init-not-called, 164 | super-method-not-called, 165 | syntax-error, 166 | test-inherits-tests, 167 | too-few-format-args, 168 | too-many-format-args, 169 | too-many-function-args, 170 | translation-of-non-string, 171 | truncated-format-string, 172 | undefined-all-variable, 173 | undefined-loop-variable, 174 | undefined-variable, 175 | unexpected-keyword-arg, 176 | unexpected-special-method-signature, 177 | unpacking-non-sequence, 178 | unreachable, 179 | unsubscriptable-object, 180 | unsupported-binary-operation, 181 | unsupported-membership-test, 182 | unused-format-string-argument, 183 | unused-format-string-key, 184 | used-before-assignment, 185 | using-constant-test, 186 | yield-outside-function, 187 | 188 | astroid-error, 189 | fatal, 190 | method-check-failed, 191 | parse-error, 192 | raw-checker-failed, 193 | 194 | empty-docstring, 195 | invalid-characters-in-docstring, 196 | missing-docstring, 197 | wrong-spelling-in-comment, 198 | wrong-spelling-in-docstring, 199 | 200 | unused-argument, 201 | unused-import, 202 | unused-variable, 203 | 204 | eval-used, 205 | exec-used, 206 | 207 | bad-classmethod-argument, 208 | bad-mcs-classmethod-argument, 209 | bad-mcs-method-argument, 210 | bare-except, 211 | broad-except, 212 | consider-iterating-dictionary, 213 | consider-using-enumerate, 214 | global-at-module-level, 215 | global-variable-not-assigned, 216 | literal-used-as-attribute, 217 | logging-format-interpolation, 218 | logging-not-lazy, 219 | multiple-imports, 220 | multiple-statements, 221 | no-classmethod-decorator, 222 | no-staticmethod-decorator, 223 | protected-access, 224 | redundant-unittest-assert, 225 | reimported, 226 | simplifiable-if-statement, 227 | simplifiable-range, 228 | singleton-comparison, 229 | superfluous-parens, 230 | unidiomatic-typecheck, 231 | unnecessary-lambda, 232 | unnecessary-pass, 233 | unnecessary-semicolon, 234 | unneeded-not, 235 | useless-else-on-loop, 236 | wrong-assert-type, 237 | 238 | deprecated-method, 239 | deprecated-module, 240 | 241 | too-many-boolean-expressions, 242 | too-many-nested-blocks, 243 | too-many-statements, 244 | 245 | wildcard-import, 246 | wrong-import-order, 247 | wrong-import-position, 248 | 249 | missing-final-newline, 250 | mixed-line-endings, 251 | trailing-newlines, 252 | trailing-whitespace, 253 | unexpected-line-ending-format, 254 | 255 | bad-inline-option, 256 | bad-option-value, 257 | deprecated-pragma, 258 | unrecognized-inline-option, 259 | useless-suppression, 260 | disable = 261 | bad-indentation, 262 | broad-exception-raised, 263 | consider-using-f-string, 264 | duplicate-code, 265 | file-ignored, 266 | fixme, 267 | global-statement, 268 | invalid-name, 269 | locally-disabled, 270 | no-else-return, 271 | suppressed-message, 272 | too-few-public-methods, 273 | too-many-ancestors, 274 | too-many-arguments, 275 | too-many-branches, 276 | too-many-instance-attributes, 277 | too-many-lines, 278 | too-many-locals, 279 | too-many-public-methods, 280 | too-many-return-statements, 281 | ungrouped-imports, 282 | unspecified-encoding, 283 | unused-wildcard-import, 284 | use-maxsplit-arg, 285 | 286 | feature-toggle-needs-doc, 287 | illegal-waffle-usage, 288 | 289 | logging-fstring-interpolation, 290 | missing-function-docstring, 291 | missing-module-docstring, 292 | missing-class-docstring 293 | 294 | [REPORTS] 295 | output-format = text 296 | reports = no 297 | score = no 298 | 299 | [BASIC] 300 | module-rgx = (([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 301 | const-rgx = (([A-Z_][A-Z0-9_]*)|(__.*__)|log|urlpatterns)$ 302 | class-rgx = [A-Z_][a-zA-Z0-9]+$ 303 | function-rgx = ([a-z_][a-z0-9_]{2,40}|test_[a-z0-9_]+)$ 304 | 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_]+)$ 305 | attr-rgx = [a-z_][a-z0-9_]{2,30}$ 306 | argument-rgx = [a-z_][a-z0-9_]{2,30}$ 307 | variable-rgx = [a-z_][a-z0-9_]{2,30}$ 308 | class-attribute-rgx = ([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 309 | inlinevar-rgx = [A-Za-z_][A-Za-z0-9_]*$ 310 | good-names = f,i,j,k,db,ex,Run,_,__ 311 | bad-names = foo,bar,baz,toto,tutu,tata 312 | no-docstring-rgx = __.*__$|test_.+|setUp$|setUpClass$|tearDown$|tearDownClass$|Meta$ 313 | docstring-min-length = 5 314 | 315 | [FORMAT] 316 | max-line-length = 120 317 | ignore-long-lines = ^\s*(# )?((?)|(\.\. \w+: .*))$ 318 | single-line-if-stmt = no 319 | max-module-lines = 1000 320 | indent-string = ' ' 321 | 322 | [MISCELLANEOUS] 323 | notes = FIXME,XXX,TODO 324 | 325 | [SIMILARITIES] 326 | min-similarity-lines = 4 327 | ignore-comments = yes 328 | ignore-docstrings = yes 329 | ignore-imports = no 330 | 331 | [TYPECHECK] 332 | ignore-mixin-members = yes 333 | ignored-classes = SQLObject 334 | unsafe-load-any-extension = yes 335 | generated-members = 336 | REQUEST, 337 | acl_users, 338 | aq_parent, 339 | objects, 340 | DoesNotExist, 341 | can_read, 342 | can_write, 343 | get_url, 344 | size, 345 | content, 346 | status_code, 347 | create, 348 | build, 349 | fields, 350 | tag, 351 | org, 352 | course, 353 | category, 354 | name, 355 | revision, 356 | _meta, 357 | 358 | [VARIABLES] 359 | init-import = no 360 | dummy-variables-rgx = _|dummy|unused|.*_unused 361 | additional-builtins = 362 | 363 | [CLASSES] 364 | defining-attr-methods = __init__,__new__,setUp 365 | valid-classmethod-first-arg = cls 366 | valid-metaclass-classmethod-first-arg = mcs 367 | 368 | [DESIGN] 369 | max-args = 5 370 | ignored-argument-names = _.* 371 | max-locals = 15 372 | max-returns = 6 373 | max-branches = 12 374 | max-statements = 50 375 | max-parents = 7 376 | max-attributes = 7 377 | min-public-methods = 2 378 | max-public-methods = 20 379 | 380 | [IMPORTS] 381 | deprecated-modules = regsub,TERMIOS,Bastion,rexec 382 | import-graph = 383 | ext-import-graph = 384 | int-import-graph = 385 | 386 | [EXCEPTIONS] 387 | overgeneral-exceptions = builtins.Exception 388 | 389 | # 4d62b5911eb751d4b086b0c163b1f165d0680bae 390 | -------------------------------------------------------------------------------- /pylintrc_tweaks: -------------------------------------------------------------------------------- 1 | # pylintrc tweaks for use with edx_lint. 2 | [MASTER] 3 | ignore+ = .git, .tox, migrations 4 | load-plugins = edx_lint.pylint,pylint_django 5 | 6 | [MESSAGES CONTROL] 7 | disable+ = 8 | missing-function-docstring, 9 | missing-module-docstring, 10 | missing-class-docstring 11 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = notesserver.settings.test 3 | addopts = --cov=notesserver --cov=notesapi --cov-report term --cov-config=.coveragerc --no-cov-on-fail 4 | testpaths = notesserver notesapi 5 | -------------------------------------------------------------------------------- /requirements/base.in: -------------------------------------------------------------------------------- 1 | 2 | -c constraints.txt 3 | 4 | Django 5 | requests 6 | djangorestframework 7 | drf-spectacular 8 | elasticsearch 9 | elasticsearch-dsl 10 | django-elasticsearch-dsl 11 | django-elasticsearch-dsl-drf 12 | django-cors-headers 13 | meilisearch 14 | mysqlclient 15 | PyJWT 16 | gunicorn # MIT 17 | path.py 18 | python-dateutil 19 | edx-django-release-util 20 | edx-django-utils 21 | edx-drf-extensions 22 | pytz 23 | setuptools 24 | -------------------------------------------------------------------------------- /requirements/base.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # make upgrade 6 | # 7 | annotated-types==0.7.0 8 | # via pydantic 9 | asgiref==3.8.1 10 | # via 11 | # django 12 | # django-cors-headers 13 | attrs==25.3.0 14 | # via 15 | # jsonschema 16 | # referencing 17 | camel-converter[pydantic]==4.0.1 18 | # via meilisearch 19 | certifi==2025.4.26 20 | # via 21 | # elasticsearch 22 | # requests 23 | cffi==1.17.1 24 | # via 25 | # cryptography 26 | # pynacl 27 | charset-normalizer==3.4.2 28 | # via requests 29 | click==8.2.1 30 | # via edx-django-utils 31 | cryptography==45.0.3 32 | # via pyjwt 33 | django==4.2.22 34 | # via 35 | # -c https://raw.githubusercontent.com/openedx/edx-lint/master/edx_lint/files/common_constraints.txt 36 | # -r requirements/base.in 37 | # django-cors-headers 38 | # django-crum 39 | # django-nine 40 | # django-waffle 41 | # djangorestframework 42 | # drf-jwt 43 | # drf-spectacular 44 | # edx-django-release-util 45 | # edx-django-utils 46 | # edx-drf-extensions 47 | django-cors-headers==4.7.0 48 | # via -r requirements/base.in 49 | django-crum==0.7.9 50 | # via edx-django-utils 51 | django-elasticsearch-dsl==7.4 52 | # via 53 | # -r requirements/base.in 54 | # django-elasticsearch-dsl-drf 55 | django-elasticsearch-dsl-drf==0.22.5 56 | # via -r requirements/base.in 57 | django-nine==0.2.7 58 | # via django-elasticsearch-dsl-drf 59 | django-waffle==4.2.0 60 | # via 61 | # edx-django-utils 62 | # edx-drf-extensions 63 | djangorestframework==3.16.0 64 | # via 65 | # -r requirements/base.in 66 | # django-elasticsearch-dsl-drf 67 | # drf-jwt 68 | # drf-spectacular 69 | # edx-drf-extensions 70 | dnspython==2.7.0 71 | # via pymongo 72 | drf-jwt==1.19.2 73 | # via edx-drf-extensions 74 | drf-spectacular==0.28.0 75 | # via -r requirements/base.in 76 | edx-django-release-util==1.5.0 77 | # via -r requirements/base.in 78 | edx-django-utils==8.0.0 79 | # via 80 | # -r requirements/base.in 81 | # edx-drf-extensions 82 | edx-drf-extensions==10.6.0 83 | # via -r requirements/base.in 84 | edx-opaque-keys==3.0.0 85 | # via edx-drf-extensions 86 | elasticsearch==7.13.4 87 | # via 88 | # -c https://raw.githubusercontent.com/openedx/edx-lint/master/edx_lint/files/common_constraints.txt 89 | # -r requirements/base.in 90 | # django-elasticsearch-dsl-drf 91 | # elasticsearch-dsl 92 | elasticsearch-dsl==7.4.1 93 | # via 94 | # -r requirements/base.in 95 | # django-elasticsearch-dsl 96 | # django-elasticsearch-dsl-drf 97 | gunicorn==23.0.0 98 | # via -r requirements/base.in 99 | idna==3.10 100 | # via requests 101 | inflection==0.5.1 102 | # via drf-spectacular 103 | jsonschema==4.24.0 104 | # via drf-spectacular 105 | jsonschema-specifications==2025.4.1 106 | # via jsonschema 107 | meilisearch==0.34.1 108 | # via -r requirements/base.in 109 | mysqlclient==2.2.7 110 | # via -r requirements/base.in 111 | packaging==25.0 112 | # via 113 | # django-nine 114 | # gunicorn 115 | path==17.1.0 116 | # via path-py 117 | path-py==12.5.0 118 | # via -r requirements/base.in 119 | pbr==6.1.1 120 | # via stevedore 121 | psutil==7.0.0 122 | # via edx-django-utils 123 | pycparser==2.22 124 | # via cffi 125 | pydantic==2.11.5 126 | # via camel-converter 127 | pydantic-core==2.33.2 128 | # via pydantic 129 | pyjwt[crypto]==2.10.1 130 | # via 131 | # -r requirements/base.in 132 | # drf-jwt 133 | # edx-drf-extensions 134 | pymongo==4.13.0 135 | # via edx-opaque-keys 136 | pynacl==1.5.0 137 | # via edx-django-utils 138 | python-dateutil==2.9.0.post0 139 | # via 140 | # -r requirements/base.in 141 | # elasticsearch-dsl 142 | pytz==2025.2 143 | # via -r requirements/base.in 144 | pyyaml==6.0.2 145 | # via 146 | # drf-spectacular 147 | # edx-django-release-util 148 | referencing==0.36.2 149 | # via 150 | # jsonschema 151 | # jsonschema-specifications 152 | requests==2.32.3 153 | # via 154 | # -r requirements/base.in 155 | # edx-drf-extensions 156 | # meilisearch 157 | rpds-py==0.25.1 158 | # via 159 | # jsonschema 160 | # referencing 161 | semantic-version==2.10.0 162 | # via edx-drf-extensions 163 | six==1.17.0 164 | # via 165 | # django-elasticsearch-dsl 166 | # django-elasticsearch-dsl-drf 167 | # edx-django-release-util 168 | # elasticsearch-dsl 169 | # python-dateutil 170 | sqlparse==0.5.3 171 | # via django 172 | stevedore==5.4.1 173 | # via 174 | # edx-django-utils 175 | # edx-opaque-keys 176 | typing-extensions==4.14.0 177 | # via 178 | # edx-opaque-keys 179 | # elasticsearch-dsl 180 | # pydantic 181 | # pydantic-core 182 | # referencing 183 | # typing-inspection 184 | typing-inspection==0.4.1 185 | # via pydantic 186 | uritemplate==4.2.0 187 | # via drf-spectacular 188 | urllib3==1.26.20 189 | # via 190 | # -c https://raw.githubusercontent.com/openedx/edx-lint/master/edx_lint/files/common_constraints.txt 191 | # elasticsearch 192 | # requests 193 | 194 | # The following packages are considered to be unsafe in a requirements file: 195 | setuptools==80.9.0 196 | # via 197 | # -r requirements/base.in 198 | # pbr 199 | -------------------------------------------------------------------------------- /requirements/ci.in: -------------------------------------------------------------------------------- 1 | # Requirements for running tests in CI 2 | 3 | -c constraints.txt 4 | 5 | tox # Virtualenv management for tests 6 | -------------------------------------------------------------------------------- /requirements/ci.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # make upgrade 6 | # 7 | cachetools==6.0.0 8 | # via tox 9 | chardet==5.2.0 10 | # via tox 11 | colorama==0.4.6 12 | # via tox 13 | distlib==0.3.9 14 | # via virtualenv 15 | filelock==3.18.0 16 | # via 17 | # tox 18 | # virtualenv 19 | packaging==25.0 20 | # via 21 | # pyproject-api 22 | # tox 23 | platformdirs==4.3.8 24 | # via 25 | # tox 26 | # virtualenv 27 | pluggy==1.6.0 28 | # via tox 29 | pyproject-api==1.9.1 30 | # via tox 31 | tox==4.26.0 32 | # via -r requirements/ci.in 33 | virtualenv==20.31.2 34 | # via tox 35 | -------------------------------------------------------------------------------- /requirements/constraints.txt: -------------------------------------------------------------------------------- 1 | # Version constraints for pip installation. 2 | # 3 | # This file doesn't install any packages. It specifies version constraints 4 | # that will be applied if a package is needed. 5 | # 6 | # When pinning something here, please provide an explanation of why. Ideally, 7 | # link to other information that will help people in the future to remove the 8 | # pin when possible. Writing an issue against the offending project and 9 | # linking to it here is good. 10 | 11 | # Common constraints for edx repos 12 | -c https://raw.githubusercontent.com/openedx/edx-lint/master/edx_lint/files/common_constraints.txt 13 | -------------------------------------------------------------------------------- /requirements/django.txt: -------------------------------------------------------------------------------- 1 | django==4.2.22 2 | -------------------------------------------------------------------------------- /requirements/pip-tools.in: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile 3 | # To update, run: 4 | # 5 | # make upgrade 6 | # 7 | pip-tools 8 | -------------------------------------------------------------------------------- /requirements/pip-tools.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # make upgrade 6 | # 7 | build==1.2.2.post1 8 | # via pip-tools 9 | click==8.2.1 10 | # via pip-tools 11 | packaging==25.0 12 | # via build 13 | pip-tools==7.4.1 14 | # via -r requirements/pip-tools.in 15 | pyproject-hooks==1.2.0 16 | # via 17 | # build 18 | # pip-tools 19 | wheel==0.45.1 20 | # via pip-tools 21 | 22 | # The following packages are considered to be unsafe in a requirements file: 23 | # pip 24 | # setuptools 25 | -------------------------------------------------------------------------------- /requirements/pip.in: -------------------------------------------------------------------------------- 1 | -c constraints.txt 2 | # Core dependencies for installing other packages 3 | 4 | pip 5 | setuptools 6 | wheel 7 | 8 | -------------------------------------------------------------------------------- /requirements/pip.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # make upgrade 6 | # 7 | wheel==0.45.1 8 | # via -r requirements/pip.in 9 | 10 | # The following packages are considered to be unsafe in a requirements file: 11 | pip==24.2 12 | # via 13 | # -c https://raw.githubusercontent.com/openedx/edx-lint/master/edx_lint/files/common_constraints.txt 14 | # -r requirements/pip.in 15 | setuptools==80.9.0 16 | # via -r requirements/pip.in 17 | -------------------------------------------------------------------------------- /requirements/quality.in: -------------------------------------------------------------------------------- 1 | -c constraints.txt 2 | 3 | -r base.txt 4 | -r test.txt 5 | 6 | code-annotations 7 | pycodestyle 8 | pylint 9 | edx_lint 10 | -------------------------------------------------------------------------------- /requirements/quality.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # make upgrade 6 | # 7 | annotated-types==0.7.0 8 | # via 9 | # -r requirements/base.txt 10 | # -r requirements/test.txt 11 | # pydantic 12 | asgiref==3.8.1 13 | # via 14 | # -r requirements/base.txt 15 | # -r requirements/test.txt 16 | # django 17 | # django-cors-headers 18 | astroid==3.3.10 19 | # via 20 | # -r requirements/test.txt 21 | # pylint 22 | # pylint-celery 23 | attrs==25.3.0 24 | # via 25 | # -r requirements/base.txt 26 | # -r requirements/test.txt 27 | # jsonschema 28 | # referencing 29 | cachetools==6.0.0 30 | # via 31 | # -r requirements/test.txt 32 | # tox 33 | camel-converter[pydantic]==4.0.1 34 | # via 35 | # -r requirements/base.txt 36 | # -r requirements/test.txt 37 | # meilisearch 38 | certifi==2025.4.26 39 | # via 40 | # -r requirements/base.txt 41 | # -r requirements/test.txt 42 | # elasticsearch 43 | # requests 44 | cffi==1.17.1 45 | # via 46 | # -r requirements/base.txt 47 | # -r requirements/test.txt 48 | # cryptography 49 | # pynacl 50 | chardet==5.2.0 51 | # via 52 | # -r requirements/test.txt 53 | # diff-cover 54 | # tox 55 | charset-normalizer==3.4.2 56 | # via 57 | # -r requirements/base.txt 58 | # -r requirements/test.txt 59 | # requests 60 | click==8.2.1 61 | # via 62 | # -r requirements/base.txt 63 | # -r requirements/test.txt 64 | # click-log 65 | # code-annotations 66 | # edx-django-utils 67 | # edx-lint 68 | click-log==0.4.0 69 | # via edx-lint 70 | code-annotations==2.3.0 71 | # via 72 | # -r requirements/quality.in 73 | # -r requirements/test.txt 74 | # edx-lint 75 | colorama==0.4.6 76 | # via 77 | # -r requirements/test.txt 78 | # tox 79 | coverage[toml]==7.8.2 80 | # via 81 | # -r requirements/test.txt 82 | # pytest-cov 83 | cryptography==45.0.3 84 | # via 85 | # -r requirements/base.txt 86 | # -r requirements/test.txt 87 | # pyjwt 88 | ddt==1.7.2 89 | # via -r requirements/test.txt 90 | diff-cover==9.3.2 91 | # via -r requirements/test.txt 92 | dill==0.4.0 93 | # via 94 | # -r requirements/test.txt 95 | # pylint 96 | distlib==0.3.9 97 | # via 98 | # -r requirements/test.txt 99 | # virtualenv 100 | django==4.2.22 101 | # via 102 | # -c https://raw.githubusercontent.com/openedx/edx-lint/master/edx_lint/files/common_constraints.txt 103 | # -r requirements/base.txt 104 | # -r requirements/test.txt 105 | # django-cors-headers 106 | # django-crum 107 | # django-nine 108 | # django-waffle 109 | # djangorestframework 110 | # drf-jwt 111 | # drf-spectacular 112 | # edx-django-release-util 113 | # edx-django-utils 114 | # edx-drf-extensions 115 | django-cors-headers==4.7.0 116 | # via 117 | # -r requirements/base.txt 118 | # -r requirements/test.txt 119 | django-crum==0.7.9 120 | # via 121 | # -r requirements/base.txt 122 | # -r requirements/test.txt 123 | # edx-django-utils 124 | django-elasticsearch-dsl==7.4 125 | # via 126 | # -r requirements/base.txt 127 | # -r requirements/test.txt 128 | # django-elasticsearch-dsl-drf 129 | django-elasticsearch-dsl-drf==0.22.5 130 | # via 131 | # -r requirements/base.txt 132 | # -r requirements/test.txt 133 | django-nine==0.2.7 134 | # via 135 | # -r requirements/base.txt 136 | # -r requirements/test.txt 137 | # django-elasticsearch-dsl-drf 138 | django-waffle==4.2.0 139 | # via 140 | # -r requirements/base.txt 141 | # -r requirements/test.txt 142 | # edx-django-utils 143 | # edx-drf-extensions 144 | djangorestframework==3.16.0 145 | # via 146 | # -r requirements/base.txt 147 | # -r requirements/test.txt 148 | # django-elasticsearch-dsl-drf 149 | # drf-jwt 150 | # drf-spectacular 151 | # edx-drf-extensions 152 | dnspython==2.7.0 153 | # via 154 | # -r requirements/base.txt 155 | # -r requirements/test.txt 156 | # pymongo 157 | drf-jwt==1.19.2 158 | # via 159 | # -r requirements/base.txt 160 | # -r requirements/test.txt 161 | # edx-drf-extensions 162 | drf-spectacular==0.28.0 163 | # via 164 | # -r requirements/base.txt 165 | # -r requirements/test.txt 166 | edx-django-release-util==1.5.0 167 | # via 168 | # -r requirements/base.txt 169 | # -r requirements/test.txt 170 | edx-django-utils==8.0.0 171 | # via 172 | # -r requirements/base.txt 173 | # -r requirements/test.txt 174 | # edx-drf-extensions 175 | edx-drf-extensions==10.6.0 176 | # via 177 | # -r requirements/base.txt 178 | # -r requirements/test.txt 179 | edx-lint==5.6.0 180 | # via -r requirements/quality.in 181 | edx-opaque-keys==3.0.0 182 | # via 183 | # -r requirements/base.txt 184 | # -r requirements/test.txt 185 | # edx-drf-extensions 186 | elasticsearch==7.13.4 187 | # via 188 | # -c https://raw.githubusercontent.com/openedx/edx-lint/master/edx_lint/files/common_constraints.txt 189 | # -r requirements/base.txt 190 | # -r requirements/test.txt 191 | # django-elasticsearch-dsl-drf 192 | # elasticsearch-dsl 193 | elasticsearch-dsl==7.4.1 194 | # via 195 | # -r requirements/base.txt 196 | # -r requirements/test.txt 197 | # django-elasticsearch-dsl 198 | # django-elasticsearch-dsl-drf 199 | factory-boy==3.3.3 200 | # via -r requirements/test.txt 201 | faker==37.3.0 202 | # via 203 | # -r requirements/test.txt 204 | # factory-boy 205 | filelock==3.18.0 206 | # via 207 | # -r requirements/test.txt 208 | # tox 209 | # virtualenv 210 | gunicorn==23.0.0 211 | # via 212 | # -r requirements/base.txt 213 | # -r requirements/test.txt 214 | idna==3.10 215 | # via 216 | # -r requirements/base.txt 217 | # -r requirements/test.txt 218 | # requests 219 | inflection==0.5.1 220 | # via 221 | # -r requirements/base.txt 222 | # -r requirements/test.txt 223 | # drf-spectacular 224 | iniconfig==2.1.0 225 | # via 226 | # -r requirements/test.txt 227 | # pytest 228 | isort==6.0.1 229 | # via 230 | # -r requirements/test.txt 231 | # pylint 232 | jinja2==3.1.6 233 | # via 234 | # -r requirements/test.txt 235 | # code-annotations 236 | # diff-cover 237 | jsonschema==4.24.0 238 | # via 239 | # -r requirements/base.txt 240 | # -r requirements/test.txt 241 | # drf-spectacular 242 | jsonschema-specifications==2025.4.1 243 | # via 244 | # -r requirements/base.txt 245 | # -r requirements/test.txt 246 | # jsonschema 247 | markupsafe==3.0.2 248 | # via 249 | # -r requirements/test.txt 250 | # jinja2 251 | mccabe==0.7.0 252 | # via 253 | # -r requirements/test.txt 254 | # pylint 255 | meilisearch==0.34.1 256 | # via 257 | # -r requirements/base.txt 258 | # -r requirements/test.txt 259 | more-itertools==10.7.0 260 | # via -r requirements/test.txt 261 | mysqlclient==2.2.7 262 | # via 263 | # -r requirements/base.txt 264 | # -r requirements/test.txt 265 | packaging==25.0 266 | # via 267 | # -r requirements/base.txt 268 | # -r requirements/test.txt 269 | # django-nine 270 | # gunicorn 271 | # pyproject-api 272 | # pytest 273 | # tox 274 | path==17.1.0 275 | # via 276 | # -r requirements/base.txt 277 | # -r requirements/test.txt 278 | # path-py 279 | path-py==12.5.0 280 | # via 281 | # -r requirements/base.txt 282 | # -r requirements/test.txt 283 | pbr==6.1.1 284 | # via 285 | # -r requirements/base.txt 286 | # -r requirements/test.txt 287 | # stevedore 288 | pep8==1.7.1 289 | # via -r requirements/test.txt 290 | platformdirs==4.3.8 291 | # via 292 | # -r requirements/test.txt 293 | # pylint 294 | # tox 295 | # virtualenv 296 | pluggy==1.6.0 297 | # via 298 | # -r requirements/test.txt 299 | # diff-cover 300 | # pytest 301 | # tox 302 | psutil==7.0.0 303 | # via 304 | # -r requirements/base.txt 305 | # -r requirements/test.txt 306 | # edx-django-utils 307 | pycodestyle==2.13.0 308 | # via -r requirements/quality.in 309 | pycparser==2.22 310 | # via 311 | # -r requirements/base.txt 312 | # -r requirements/test.txt 313 | # cffi 314 | pydantic==2.11.5 315 | # via 316 | # -r requirements/base.txt 317 | # -r requirements/test.txt 318 | # camel-converter 319 | pydantic-core==2.33.2 320 | # via 321 | # -r requirements/base.txt 322 | # -r requirements/test.txt 323 | # pydantic 324 | pygments==2.19.1 325 | # via 326 | # -r requirements/test.txt 327 | # diff-cover 328 | # pytest 329 | pyjwt[crypto]==2.10.1 330 | # via 331 | # -r requirements/base.txt 332 | # -r requirements/test.txt 333 | # drf-jwt 334 | # edx-drf-extensions 335 | pylint==3.3.7 336 | # via 337 | # -r requirements/quality.in 338 | # -r requirements/test.txt 339 | # edx-lint 340 | # pylint-celery 341 | # pylint-django 342 | # pylint-plugin-utils 343 | pylint-celery==0.3 344 | # via edx-lint 345 | pylint-django==2.6.1 346 | # via edx-lint 347 | pylint-plugin-utils==0.8.2 348 | # via 349 | # pylint-celery 350 | # pylint-django 351 | pymongo==4.13.0 352 | # via 353 | # -r requirements/base.txt 354 | # -r requirements/test.txt 355 | # edx-opaque-keys 356 | pynacl==1.5.0 357 | # via 358 | # -r requirements/base.txt 359 | # -r requirements/test.txt 360 | # edx-django-utils 361 | pyproject-api==1.9.1 362 | # via 363 | # -r requirements/test.txt 364 | # tox 365 | pytest==8.4.0 366 | # via 367 | # -r requirements/test.txt 368 | # pytest-cov 369 | # pytest-django 370 | pytest-cov==6.1.1 371 | # via -r requirements/test.txt 372 | pytest-django==4.11.1 373 | # via -r requirements/test.txt 374 | python-dateutil==2.9.0.post0 375 | # via 376 | # -r requirements/base.txt 377 | # -r requirements/test.txt 378 | # elasticsearch-dsl 379 | python-slugify==8.0.4 380 | # via 381 | # -r requirements/test.txt 382 | # code-annotations 383 | pytz==2025.2 384 | # via 385 | # -r requirements/base.txt 386 | # -r requirements/test.txt 387 | pyyaml==6.0.2 388 | # via 389 | # -r requirements/base.txt 390 | # -r requirements/test.txt 391 | # code-annotations 392 | # drf-spectacular 393 | # edx-django-release-util 394 | referencing==0.36.2 395 | # via 396 | # -r requirements/base.txt 397 | # -r requirements/test.txt 398 | # jsonschema 399 | # jsonschema-specifications 400 | requests==2.32.3 401 | # via 402 | # -r requirements/base.txt 403 | # -r requirements/test.txt 404 | # edx-drf-extensions 405 | # meilisearch 406 | rpds-py==0.25.1 407 | # via 408 | # -r requirements/base.txt 409 | # -r requirements/test.txt 410 | # jsonschema 411 | # referencing 412 | semantic-version==2.10.0 413 | # via 414 | # -r requirements/base.txt 415 | # -r requirements/test.txt 416 | # edx-drf-extensions 417 | six==1.17.0 418 | # via 419 | # -r requirements/base.txt 420 | # -r requirements/test.txt 421 | # django-elasticsearch-dsl 422 | # django-elasticsearch-dsl-drf 423 | # edx-django-release-util 424 | # edx-lint 425 | # elasticsearch-dsl 426 | # python-dateutil 427 | sqlparse==0.5.3 428 | # via 429 | # -r requirements/base.txt 430 | # -r requirements/test.txt 431 | # django 432 | stevedore==5.4.1 433 | # via 434 | # -r requirements/base.txt 435 | # -r requirements/test.txt 436 | # code-annotations 437 | # edx-django-utils 438 | # edx-opaque-keys 439 | text-unidecode==1.3 440 | # via 441 | # -r requirements/test.txt 442 | # python-slugify 443 | tomlkit==0.13.3 444 | # via 445 | # -r requirements/test.txt 446 | # pylint 447 | tox==4.26.0 448 | # via -r requirements/test.txt 449 | typing-extensions==4.14.0 450 | # via 451 | # -r requirements/base.txt 452 | # -r requirements/test.txt 453 | # edx-opaque-keys 454 | # pydantic 455 | # pydantic-core 456 | # referencing 457 | # typing-inspection 458 | typing-inspection==0.4.1 459 | # via 460 | # -r requirements/base.txt 461 | # -r requirements/test.txt 462 | # pydantic 463 | tzdata==2025.2 464 | # via 465 | # -r requirements/test.txt 466 | # faker 467 | uritemplate==4.2.0 468 | # via 469 | # -r requirements/base.txt 470 | # -r requirements/test.txt 471 | # drf-spectacular 472 | urllib3==1.26.20 473 | # via 474 | # -c https://raw.githubusercontent.com/openedx/edx-lint/master/edx_lint/files/common_constraints.txt 475 | # -r requirements/base.txt 476 | # -r requirements/test.txt 477 | # elasticsearch 478 | # requests 479 | virtualenv==20.31.2 480 | # via 481 | # -r requirements/test.txt 482 | # tox 483 | 484 | # The following packages are considered to be unsafe in a requirements file: 485 | setuptools==80.9.0 486 | # via 487 | # -r requirements/base.txt 488 | # -r requirements/test.txt 489 | # pbr 490 | -------------------------------------------------------------------------------- /requirements/test.in: -------------------------------------------------------------------------------- 1 | -c constraints.txt 2 | 3 | -r base.txt 4 | 5 | astroid 6 | code-annotations 7 | coverage 8 | more-itertools 9 | pep8 10 | pylint 11 | diff-cover 12 | factory_boy 13 | ddt 14 | pytest 15 | pytest-cov 16 | pytest-django 17 | tox 18 | -------------------------------------------------------------------------------- /requirements/test.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # make upgrade 6 | # 7 | annotated-types==0.7.0 8 | # via 9 | # -r requirements/base.txt 10 | # pydantic 11 | asgiref==3.8.1 12 | # via 13 | # -r requirements/base.txt 14 | # django 15 | # django-cors-headers 16 | astroid==3.3.10 17 | # via 18 | # -r requirements/test.in 19 | # pylint 20 | attrs==25.3.0 21 | # via 22 | # -r requirements/base.txt 23 | # jsonschema 24 | # referencing 25 | cachetools==6.0.0 26 | # via tox 27 | camel-converter[pydantic]==4.0.1 28 | # via 29 | # -r requirements/base.txt 30 | # meilisearch 31 | certifi==2025.4.26 32 | # via 33 | # -r requirements/base.txt 34 | # elasticsearch 35 | # requests 36 | cffi==1.17.1 37 | # via 38 | # -r requirements/base.txt 39 | # cryptography 40 | # pynacl 41 | chardet==5.2.0 42 | # via 43 | # diff-cover 44 | # tox 45 | charset-normalizer==3.4.2 46 | # via 47 | # -r requirements/base.txt 48 | # requests 49 | click==8.2.1 50 | # via 51 | # -r requirements/base.txt 52 | # code-annotations 53 | # edx-django-utils 54 | code-annotations==2.3.0 55 | # via -r requirements/test.in 56 | colorama==0.4.6 57 | # via tox 58 | coverage[toml]==7.8.2 59 | # via 60 | # -r requirements/test.in 61 | # pytest-cov 62 | cryptography==45.0.3 63 | # via 64 | # -r requirements/base.txt 65 | # pyjwt 66 | ddt==1.7.2 67 | # via -r requirements/test.in 68 | diff-cover==9.3.2 69 | # via -r requirements/test.in 70 | dill==0.4.0 71 | # via pylint 72 | distlib==0.3.9 73 | # via virtualenv 74 | # via 75 | # -c https://raw.githubusercontent.com/openedx/edx-lint/master/edx_lint/files/common_constraints.txt 76 | # -r requirements/base.txt 77 | # django-cors-headers 78 | # django-crum 79 | # django-nine 80 | # django-waffle 81 | # djangorestframework 82 | # drf-jwt 83 | # drf-spectacular 84 | # edx-django-release-util 85 | # edx-django-utils 86 | # edx-drf-extensions 87 | django-cors-headers==4.7.0 88 | # via -r requirements/base.txt 89 | django-crum==0.7.9 90 | # via 91 | # -r requirements/base.txt 92 | # edx-django-utils 93 | django-elasticsearch-dsl==7.4 94 | # via 95 | # -r requirements/base.txt 96 | # django-elasticsearch-dsl-drf 97 | django-elasticsearch-dsl-drf==0.22.5 98 | # via -r requirements/base.txt 99 | django-nine==0.2.7 100 | # via 101 | # -r requirements/base.txt 102 | # django-elasticsearch-dsl-drf 103 | django-waffle==4.2.0 104 | # via 105 | # -r requirements/base.txt 106 | # edx-django-utils 107 | # edx-drf-extensions 108 | djangorestframework==3.16.0 109 | # via 110 | # -r requirements/base.txt 111 | # django-elasticsearch-dsl-drf 112 | # drf-jwt 113 | # drf-spectacular 114 | # edx-drf-extensions 115 | dnspython==2.7.0 116 | # via 117 | # -r requirements/base.txt 118 | # pymongo 119 | drf-jwt==1.19.2 120 | # via 121 | # -r requirements/base.txt 122 | # edx-drf-extensions 123 | drf-spectacular==0.28.0 124 | # via -r requirements/base.txt 125 | edx-django-release-util==1.5.0 126 | # via -r requirements/base.txt 127 | edx-django-utils==8.0.0 128 | # via 129 | # -r requirements/base.txt 130 | # edx-drf-extensions 131 | edx-drf-extensions==10.6.0 132 | # via -r requirements/base.txt 133 | edx-opaque-keys==3.0.0 134 | # via 135 | # -r requirements/base.txt 136 | # edx-drf-extensions 137 | elasticsearch==7.13.4 138 | # via 139 | # -c https://raw.githubusercontent.com/openedx/edx-lint/master/edx_lint/files/common_constraints.txt 140 | # -r requirements/base.txt 141 | # django-elasticsearch-dsl-drf 142 | # elasticsearch-dsl 143 | elasticsearch-dsl==7.4.1 144 | # via 145 | # -r requirements/base.txt 146 | # django-elasticsearch-dsl 147 | # django-elasticsearch-dsl-drf 148 | factory-boy==3.3.3 149 | # via -r requirements/test.in 150 | faker==37.3.0 151 | # via factory-boy 152 | filelock==3.18.0 153 | # via 154 | # tox 155 | # virtualenv 156 | gunicorn==23.0.0 157 | # via -r requirements/base.txt 158 | idna==3.10 159 | # via 160 | # -r requirements/base.txt 161 | # requests 162 | inflection==0.5.1 163 | # via 164 | # -r requirements/base.txt 165 | # drf-spectacular 166 | iniconfig==2.1.0 167 | # via pytest 168 | isort==6.0.1 169 | # via pylint 170 | jinja2==3.1.6 171 | # via 172 | # code-annotations 173 | # diff-cover 174 | jsonschema==4.24.0 175 | # via 176 | # -r requirements/base.txt 177 | # drf-spectacular 178 | jsonschema-specifications==2025.4.1 179 | # via 180 | # -r requirements/base.txt 181 | # jsonschema 182 | markupsafe==3.0.2 183 | # via jinja2 184 | mccabe==0.7.0 185 | # via pylint 186 | meilisearch==0.34.1 187 | # via -r requirements/base.txt 188 | more-itertools==10.7.0 189 | # via -r requirements/test.in 190 | mysqlclient==2.2.7 191 | # via -r requirements/base.txt 192 | packaging==25.0 193 | # via 194 | # -r requirements/base.txt 195 | # django-nine 196 | # gunicorn 197 | # pyproject-api 198 | # pytest 199 | # tox 200 | path==17.1.0 201 | # via 202 | # -r requirements/base.txt 203 | # path-py 204 | path-py==12.5.0 205 | # via -r requirements/base.txt 206 | pbr==6.1.1 207 | # via 208 | # -r requirements/base.txt 209 | # stevedore 210 | pep8==1.7.1 211 | # via -r requirements/test.in 212 | platformdirs==4.3.8 213 | # via 214 | # pylint 215 | # tox 216 | # virtualenv 217 | pluggy==1.6.0 218 | # via 219 | # diff-cover 220 | # pytest 221 | # tox 222 | psutil==7.0.0 223 | # via 224 | # -r requirements/base.txt 225 | # edx-django-utils 226 | pycparser==2.22 227 | # via 228 | # -r requirements/base.txt 229 | # cffi 230 | pydantic==2.11.5 231 | # via 232 | # -r requirements/base.txt 233 | # camel-converter 234 | pydantic-core==2.33.2 235 | # via 236 | # -r requirements/base.txt 237 | # pydantic 238 | pygments==2.19.1 239 | # via 240 | # diff-cover 241 | # pytest 242 | pyjwt[crypto]==2.10.1 243 | # via 244 | # -r requirements/base.txt 245 | # drf-jwt 246 | # edx-drf-extensions 247 | pylint==3.3.7 248 | # via -r requirements/test.in 249 | pymongo==4.13.0 250 | # via 251 | # -r requirements/base.txt 252 | # edx-opaque-keys 253 | pynacl==1.5.0 254 | # via 255 | # -r requirements/base.txt 256 | # edx-django-utils 257 | pyproject-api==1.9.1 258 | # via tox 259 | pytest==8.4.0 260 | # via 261 | # -r requirements/test.in 262 | # pytest-cov 263 | # pytest-django 264 | pytest-cov==6.1.1 265 | # via -r requirements/test.in 266 | pytest-django==4.11.1 267 | # via -r requirements/test.in 268 | python-dateutil==2.9.0.post0 269 | # via 270 | # -r requirements/base.txt 271 | # elasticsearch-dsl 272 | python-slugify==8.0.4 273 | # via code-annotations 274 | pytz==2025.2 275 | # via -r requirements/base.txt 276 | pyyaml==6.0.2 277 | # via 278 | # -r requirements/base.txt 279 | # code-annotations 280 | # drf-spectacular 281 | # edx-django-release-util 282 | referencing==0.36.2 283 | # via 284 | # -r requirements/base.txt 285 | # jsonschema 286 | # jsonschema-specifications 287 | requests==2.32.3 288 | # via 289 | # -r requirements/base.txt 290 | # edx-drf-extensions 291 | # meilisearch 292 | rpds-py==0.25.1 293 | # via 294 | # -r requirements/base.txt 295 | # jsonschema 296 | # referencing 297 | semantic-version==2.10.0 298 | # via 299 | # -r requirements/base.txt 300 | # edx-drf-extensions 301 | six==1.17.0 302 | # via 303 | # -r requirements/base.txt 304 | # django-elasticsearch-dsl 305 | # django-elasticsearch-dsl-drf 306 | # edx-django-release-util 307 | # elasticsearch-dsl 308 | # python-dateutil 309 | sqlparse==0.5.3 310 | # via 311 | # -r requirements/base.txt 312 | # django 313 | stevedore==5.4.1 314 | # via 315 | # -r requirements/base.txt 316 | # code-annotations 317 | # edx-django-utils 318 | # edx-opaque-keys 319 | text-unidecode==1.3 320 | # via python-slugify 321 | tomlkit==0.13.3 322 | # via pylint 323 | tox==4.26.0 324 | # via -r requirements/test.in 325 | typing-extensions==4.14.0 326 | # via 327 | # -r requirements/base.txt 328 | # edx-opaque-keys 329 | # pydantic 330 | # pydantic-core 331 | # referencing 332 | # typing-inspection 333 | typing-inspection==0.4.1 334 | # via 335 | # -r requirements/base.txt 336 | # pydantic 337 | tzdata==2025.2 338 | # via faker 339 | uritemplate==4.2.0 340 | # via 341 | # -r requirements/base.txt 342 | # drf-spectacular 343 | urllib3==1.26.20 344 | # via 345 | # -c https://raw.githubusercontent.com/openedx/edx-lint/master/edx_lint/files/common_constraints.txt 346 | # -r requirements/base.txt 347 | # elasticsearch 348 | # requests 349 | virtualenv==20.31.2 350 | # via tox 351 | 352 | # The following packages are considered to be unsafe in a requirements file: 353 | setuptools==80.9.0 354 | # via 355 | # -r requirements/base.txt 356 | # pbr 357 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py{311,312}-django{42}, quality, pii_check, check_keywords 3 | skipsdist = true 4 | isolated_build = true # Enable isolated build environments 5 | 6 | [testenv] 7 | envdir = {toxworkdir}/{envname} 8 | deps = 9 | django42: Django>=4.2,<5.0 10 | passenv = 11 | CONN_MAX_AGE 12 | DB_ENGINE 13 | DB_HOST 14 | DB_NAME 15 | DB_PASSWORD 16 | DB_PORT 17 | DB_USER 18 | ENABLE_DJANGO_TOOLBAR 19 | ELASTICSEARCH_URL 20 | allowlist_externals = 21 | make 22 | commands = 23 | make validate 24 | 25 | [testenv:quality] 26 | envdir = {toxworkdir}/{envname} 27 | allowlist_externals = 28 | make 29 | deps = 30 | -r{toxinidir}/requirements/quality.txt 31 | commands = 32 | make quality 33 | 34 | [testenv:pii_check] 35 | envdir = {toxworkdir}/{envname} 36 | allowlist_externals = 37 | make 38 | deps = 39 | Django>=4.2,<5.0 40 | commands = 41 | make pii_check 42 | 43 | [testenv:check_keywords] 44 | envdir = {toxworkdir}/{envname} 45 | setenv = 46 | DJANGO_SETTINGS_MODULE = notesserver.settings.test 47 | allowlist_externals = 48 | make 49 | deps = 50 | -r{toxinidir}/requirements/test.txt 51 | commands = 52 | make check_keywords 53 | --------------------------------------------------------------------------------