├── .coveragerc ├── .editorconfig ├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── README.rst ├── TERMS.md ├── pyproject.toml ├── setup.py ├── testmanage.py ├── tox.ini └── wagtailinventory ├── __init__.py ├── apps.py ├── fixtures └── test_blocks.json ├── helpers.py ├── management ├── __init__.py └── commands │ ├── __init__.py │ └── block_inventory.py ├── migrations ├── 0001_initial.py ├── 0002_pageblock_unique_constraint.py ├── 0003_pageblock_id_bigautofield.py └── __init__.py ├── models.py ├── tests ├── __init__.py ├── settings.py ├── test_helpers.py ├── test_models.py ├── test_views.py ├── test_wagtail_hooks.py ├── testapp │ ├── __init__.py │ ├── blocks.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_nested_stream_block_page.py │ │ ├── 0003_single_content_block_optional.py │ │ └── __init__.py │ └── models.py └── urls.py ├── views.py └── wagtail_hooks.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source= 3 | wagtailinventory 4 | omit= 5 | setup.py 6 | wagtailinventory/tests/* 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | # Matches multiple files with brace expansion notation 13 | # Set default charset 14 | # Set max line length 15 | # 4 space indentation 16 | [*.py] 17 | charset = utf-8 18 | max_line_length = 79 19 | indent_style = space 20 | indent_size = 4 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Short description explaining the high-level reason for the new issue. 2 | 3 | ## Current behavior 4 | 5 | 6 | ## Expected behavior 7 | 8 | 9 | ## Steps to replicate behavior (include URLs) 10 | 11 | 1. 12 | 13 | 14 | ## Screenshots 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | [Short description explaining the high-level reason for the pull request] 2 | 3 | ## Additions 4 | 5 | - 6 | 7 | ## Removals 8 | 9 | - 10 | 11 | ## Changes 12 | 13 | - 14 | 15 | ## Testing 16 | 17 | 1. 18 | 19 | ## Screenshots 20 | 21 | 22 | ## Notes 23 | 24 | - 25 | 26 | ## Todos 27 | 28 | - 29 | 30 | ## Checklist 31 | 32 | - [ ] PR has an informative and human-readable title 33 | - [ ] Changes are limited to a single goal (no scope creep) 34 | - [ ] Code can be automatically merged (no conflicts) 35 | - [ ] Code follows the standards laid out in the [development playbook](https://github.com/cfpb/development) 36 | - [ ] Passes all existing automated tests 37 | - [ ] Any _change_ in functionality is tested 38 | - [ ] New functions are documented (with a description, list of inputs, and expected output) 39 | - [ ] Placeholder code is flagged / future todos are captured in comments 40 | - [ ] Visually tested in supported browsers and devices (see checklist below :point_down:) 41 | - [ ] Project documentation has been updated (including the "Unreleased" section of the CHANGELOG) 42 | - [ ] Reviewers requested with the [Reviewers tool](https://help.github.com/articles/requesting-a-pull-request-review/) :arrow_right: 43 | 44 | ## Testing checklist 45 | 46 | ### Browsers 47 | 48 | - [ ] Chrome 49 | - [ ] Firefox 50 | - [ ] Safari 51 | - [ ] Internet Explorer 8, 9, 10, and 11 52 | - [ ] Edge 53 | - [ ] iOS Safari 54 | - [ ] Chrome for Android 55 | 56 | ### Accessibility 57 | 58 | - [ ] Keyboard friendly 59 | - [ ] Screen reader friendly 60 | 61 | ### Other 62 | 63 | - [ ] Is useable without CSS 64 | - [ ] Is useable without JS 65 | - [ ] Flexible from small to large screens 66 | - [ ] No linting errors or warnings 67 | - [ ] JavaScript tests are passing 68 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | on: 3 | release: 4 | types: [published] 5 | workflow_dispatch: 6 | 7 | jobs: 8 | build: 9 | name: Build and publish release to PyPI 10 | runs-on: ubuntu-latest 11 | # This uses PyPI trusted publishing 12 | environment: 13 | name: pypi 14 | url: https://pypi.org/p/wagtail-inventory 15 | permissions: 16 | id-token: write 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Set up Python 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: 3.12 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip build 26 | - name: Build the package 27 | run: | 28 | python -m build 29 | - name: Publish to PyPI 30 | uses: pypa/gh-action-pypi-publish@release/v1 31 | 32 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: [pull_request, push] 4 | 5 | jobs: 6 | 7 | lint: 8 | name: lint 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - name: Set up Python 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: 3.12 17 | 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install --upgrade pip 21 | pip install tox 22 | 23 | - name: Run tox -e lint 24 | run: tox 25 | env: 26 | TOXENV: lint 27 | 28 | test: 29 | name: unittests 30 | runs-on: ubuntu-latest 31 | 32 | strategy: 33 | matrix: 34 | python: ['3.12'] 35 | django: ['4.2', '5.0'] 36 | wagtail: ['6.2'] 37 | include: 38 | - python: '3.8' 39 | django: '4.2' 40 | wagtail: '6.2' 41 | - python: '3.12' 42 | django: '5.1' 43 | wagtail: '6.3' 44 | 45 | steps: 46 | - uses: actions/checkout@v4 47 | 48 | - name: Set up Python 49 | uses: actions/setup-python@v5 50 | with: 51 | python-version: ${{ matrix.python }} 52 | 53 | - name: Install dependencies 54 | run: | 55 | python -m pip install --upgrade pip 56 | pip install tox coveralls 57 | 58 | - name: Run tox 59 | run: tox 60 | env: 61 | TOXENV: python${{ matrix.python }}-django${{ matrix.django }}-wagtail${{ matrix.wagtail }} 62 | 63 | - name: Store test coverage 64 | uses: actions/upload-artifact@v4 65 | with: 66 | name: coverage-${{ matrix.python }}-${{ matrix.django }}-${{ matrix.wagtail }} 67 | path: .coverage.* 68 | include-hidden-files: true 69 | 70 | coverage: 71 | name: coverage 72 | runs-on: ubuntu-latest 73 | needs: 74 | - test 75 | 76 | steps: 77 | - uses: actions/checkout@v4 78 | with: 79 | fetch-depth: 0 80 | 81 | - name: Set up Python 82 | uses: actions/setup-python@v5 83 | with: 84 | python-version: "3.12" 85 | 86 | - name: Install dependencies 87 | run: | 88 | python -m pip install --upgrade pip 89 | pip install tox 90 | 91 | - name: Retrieve test coverage 92 | uses: actions/download-artifact@v4 93 | with: 94 | merge-multiple: true 95 | 96 | - name: Check coverage 97 | run: tox -e coverage 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled source # 2 | ################### 3 | *.com 4 | *.class 5 | *.dll 6 | *.exe 7 | *.o 8 | *.so 9 | _site/ 10 | 11 | # Packages # 12 | ############ 13 | # it's better to unpack these files and commit the raw source 14 | # git has its own built in compression methods 15 | *.7z 16 | *.dmg 17 | *.gz 18 | *.iso 19 | *.jar 20 | *.rar 21 | *.tar 22 | *.zip 23 | 24 | # Logs and databases # 25 | ###################### 26 | *.log 27 | *.sql 28 | *.sqlite 29 | 30 | # OS generated files # 31 | ###################### 32 | .DS_Store 33 | .DS_Store? 34 | .Spotlight-V100 35 | .Trashes 36 | Icon? 37 | ehthumbs.db 38 | Thumbs.db 39 | 40 | # Vim swap files # 41 | ################## 42 | *.swp 43 | 44 | # Python # 45 | ################# 46 | *.pyc 47 | *.egg-info/ 48 | __pycache__/ 49 | *.py[cod] 50 | .env 51 | .eggs 52 | .python-version 53 | build/ 54 | 55 | # Django # 56 | ################# 57 | *.egg-info 58 | .installed.cfg 59 | 60 | # Unit test / coverage reports 61 | ################# 62 | htmlcov/ 63 | .tox/ 64 | .coverage 65 | .cache 66 | nosetests.xml 67 | coverage.xml 68 | 69 | # Front-End # 70 | ############# 71 | node_modules/ 72 | bower_components/ 73 | .grunt/ 74 | src/vendor/ 75 | dist/ 76 | 77 | # IDEs 78 | .idea/ 79 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/charliermarsh/ruff-pre-commit 3 | rev: v0.5.0 4 | hooks: 5 | # Run the linter. 6 | - id: ruff 7 | args: ['--fix'] 8 | # Run the formatter. 9 | - id: ruff-format 10 | - repo: https://github.com/PyCQA/bandit 11 | rev: 1.7.8 12 | hooks: 13 | - id: bandit 14 | args: ['-c', 'pyproject.toml', '--recursive'] 15 | additional_dependencies: ['bandit[toml]'] 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | See the project repository release history at: 2 | 3 | https://github.com/cfpb/wagtail-inventory/releases 4 | 5 | or, from the command line: 6 | 7 | ``` 8 | git show 1.1 9 | ``` 10 | 11 | To show the 1.1 release changes. 12 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Guidance on how to contribute 2 | 3 | > All contributions to this project will be released under the CC0 public domain 4 | > dedication. By submitting a pull request or filing a bug, issue, or 5 | > feature request, you are agreeing to comply with this waiver of copyright interest. 6 | > Details can be found in our [TERMS](TERMS.md) and [LICENCE](LICENSE). 7 | 8 | 9 | There are two primary ways to help: 10 | - Using the issue tracker, and 11 | - Changing the code-base. 12 | 13 | 14 | ## Using the issue tracker 15 | 16 | Use the issue tracker to suggest feature requests, report bugs, and ask questions. 17 | This is also a great way to connect with the developers of the project as well 18 | as others who are interested in this solution. 19 | 20 | Use the issue tracker to find ways to contribute. Find a bug or a feature, mention in 21 | the issue that you will take on that effort, then follow the _Changing the code-base_ 22 | guidance below. 23 | 24 | 25 | ## Changing the code-base 26 | 27 | Generally speaking, you should fork this repository, make changes in your 28 | own fork, and then submit a pull-request. All new code should have associated unit 29 | tests that validate implemented features and the presence or lack of defects. 30 | Additionally, the code should follow any stylistic and architectural guidelines 31 | prescribed by the project. In the absence of such guidelines, mimic the styles 32 | and patterns in the existing code-base. 33 | 34 | 35 | ## Style 36 | 37 | This project uses [`black`](https://github.com/psf/black) to format code, 38 | [`isort`](https://github.com/timothycrosley/isort) to format imports, 39 | and [`ruff`](https://github.com/charliermarsh/ruff). 40 | 41 | You can format code and imports by calling: 42 | 43 | ``` 44 | black wagtailinventory 45 | isort --recursive wagtailinventory 46 | ``` 47 | 48 | And you can check for style, import order, and other linting by using: 49 | 50 | ``` 51 | tox -e lint 52 | ``` 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. 122 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | recursive-include wagtailinventory/templates *.html 4 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://github.com/cfpb/wagtail-inventory/workflows/test/badge.svg 2 | :alt: Build Status 3 | :target: https://github.com/cfpb/wagtail-inventory/actions/workflows/test.yml 4 | 5 | wagtail-inventory 6 | ================= 7 | 8 | Search Wagtail pages by block type. 9 | 10 | Wagtail Inventory adds the ability to search pages in your Wagtail site by the StreamField block types they contain. It adds a new report to the Wagtail admin site that allows you to search for pages that do or do not contain certain blocks. It supports searching both by Wagtail built-in blocks (like ``CharBlock``) as well as any custom blocks you might define. 11 | 12 | Setup 13 | ----- 14 | 15 | Install the package using pip: 16 | 17 | .. code-block:: bash 18 | 19 | $ pip install wagtail-inventory 20 | 21 | Add `wagtailinventory`` as an installed app in your Django settings: 22 | 23 | .. code-block:: python 24 | 25 | # in settings.py 26 | INSTALLED_APPS = ( 27 | ... 28 | 'wagtailinventory', 29 | ... 30 | ) 31 | 32 | Run migrations to create required database tables: 33 | 34 | .. code-block:: bash 35 | 36 | $ manage.py migrate wagtailinventory 37 | 38 | Run a management command to initialize database tables with current pages: 39 | 40 | .. code-block:: bash 41 | 42 | $ manage.py block_inventory 43 | 44 | Admin users should now be able to search pages in the Wagtail admin site, under Reports > Block Inventory. 45 | 46 | Other user groups may be granted access to the report by giving them the "Can view" "Page block" permission in Wagtail Group settings. 47 | 48 | Compatibility 49 | ------------- 50 | 51 | This code has been tested for compatibility with: 52 | 53 | * Python 3.8, 3.12 54 | * Django 4.2 (LTS), 5.0, 5.1 55 | * Wagtail 6.2, 6.3 (LTS) 56 | 57 | It should be compatible with all intermediate versions, as well. 58 | If you find that it is not, please `file an issue `_. 59 | 60 | Testing 61 | ------- 62 | 63 | Running project unit tests requires `tox `_: 64 | 65 | .. code-block:: bash 66 | 67 | $ tox 68 | 69 | To run the test app interactively, run: 70 | 71 | .. code-block:: bash 72 | 73 | $ tox -e interactive 74 | 75 | Now you can visit http://localhost:8000/admin/ in a browser and log in with ``admin`` / ``changeme``. 76 | 77 | Open source licensing info 78 | -------------------------- 79 | 80 | #. `TERMS `_ 81 | #. `LICENSE `_ 82 | #. `CFPB Source Code Policy `_ 83 | -------------------------------------------------------------------------------- /TERMS.md: -------------------------------------------------------------------------------- 1 | As a work of the United States Government, this package (excluding any 2 | exceptions listed below) is in the public domain within the United States. 3 | Additionally, we waive copyright and related rights in the work worldwide 4 | through the [CC0 1.0 Universal public domain dedication][CC0]. 5 | 6 | Software source code previously released under an open source license and then 7 | modified by CFPB staff or its contractors is considered a "joint work" 8 | (see 17 USC § 101); it is partially copyrighted, partially public domain, 9 | and as a whole is protected by the copyrights of the non-government authors and 10 | must be released according to the terms of the original open-source license. 11 | Segments written by CFPB staff, and by contractors who are developing software 12 | on behalf of CFPB are also in the public domain, and copyright and related 13 | rights for that work are waived through the CC0 1.0 Universal dedication. 14 | 15 | For further details, please see the CFPB [Source Code Policy][policy]. 16 | 17 | 18 | ## CC0 1.0 Universal Summary 19 | 20 | This is a human-readable summary of the [Legal Code (read the full text)][CC0]. 21 | 22 | ### No Copyright 23 | 24 | The person who associated a work with this deed has dedicated the work to 25 | the public domain by waiving all of his or her rights to the work worldwide 26 | under copyright law, including all related and neighboring rights, to the 27 | extent allowed by law. 28 | 29 | You can copy, modify, distribute and perform the work, even for commercial 30 | purposes, all without asking permission. See Other Information below. 31 | 32 | ### Other Information 33 | 34 | In no way are the patent or trademark rights of any person affected by CC0, 35 | nor are the rights that other persons may have in the work or in how the 36 | work is used, such as publicity or privacy rights. 37 | 38 | Unless expressly stated otherwise, the person who associated a work with 39 | this deed makes no warranties about the work, and disclaims liability for 40 | all uses of the work, to the fullest extent permitted by applicable law. 41 | When using or citing the work, you should not imply endorsement by the 42 | author or the affirmer. 43 | 44 | [policy]: https://github.com/cfpb/source-code-policy/ 45 | [CC0]: http://creativecommons.org/publicdomain/zero/1.0/legalcode 46 | 47 | 48 | ## Exceptions 49 | 50 | _Source code or other assets that are excluded from the TERMS should be listed 51 | here. These may include dependencies that may be licensed differently or are 52 | not in the public domain._ 53 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=63", "setuptools_scm[toml]>=8"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "wagtail-inventory" 7 | dynamic = ["version"] 8 | description = "Wagtail report to filter pages by block content" 9 | readme = "README.rst" 10 | requires-python = ">=3.8" 11 | license = {text = "CC0"} 12 | authors = [ 13 | {name = "CFPB", email = "tech@cfpb.gov" } 14 | ] 15 | dependencies = [ 16 | "tqdm>=4.15.0,<5", 17 | "wagtail>=6.2", 18 | ] 19 | classifiers = [ 20 | "Framework :: Django", 21 | "Framework :: Django :: 4.2", 22 | "Framework :: Django :: 5.0", 23 | "Framework :: Django :: 5.1", 24 | "Framework :: Wagtail", 25 | "Framework :: Wagtail :: 6", 26 | "License :: CC0 1.0 Universal (CC0 1.0) Public Domain Dedication", 27 | "License :: Public Domain", 28 | "Programming Language :: Python", 29 | "Programming Language :: Python :: 3", 30 | ] 31 | 32 | [project.optional-dependencies] 33 | testing = [ 34 | "coverage[toml]", 35 | ] 36 | 37 | [project.urls] 38 | "Homepage" = "https://github.com/cfpb/wagtail-inventory" 39 | "Bug Reports" = "https://github.com/cfpb/wagtail-inventory/issues" 40 | "Source" = "https://github.com/cfpb/wagtail-inventory" 41 | 42 | [tool.setuptools.package-data] 43 | inventory = [ 44 | "templates/wagtailinventory/*", 45 | ] 46 | 47 | [tool.setuptools_scm] 48 | 49 | [tool.ruff] 50 | # Use PEP8 line-length 51 | line-length = 79 52 | # Exclude common paths 53 | exclude = [ 54 | ".git", 55 | ".tox", 56 | "__pycache__", 57 | "**/migrations/*.py", 58 | ] 59 | 60 | [tool.ruff.lint] 61 | ignore = ["E731", ] 62 | # Select specific rulesets to use 63 | select = [ 64 | # pycodestyle 65 | "E", 66 | # pyflakes 67 | "F", 68 | # flake8-bugbear 69 | "B", 70 | # pyupgrade 71 | "UP", 72 | # flake8-simplify 73 | "SIM", 74 | # isort 75 | "I", 76 | ] 77 | 78 | [tool.ruff.lint.isort.sections] 79 | "django" = ["django"] 80 | "wagtail" = ["wagtail"] 81 | 82 | [tool.ruff.lint.isort] 83 | lines-after-imports = 2 84 | section-order = [ 85 | "future", 86 | "standard-library", 87 | "django", 88 | "wagtail", 89 | "third-party", 90 | "first-party", 91 | "local-folder", 92 | ] 93 | 94 | [tool.coverage.run] 95 | omit = [ 96 | "wagtailinventory/tests/*", 97 | ] 98 | 99 | [tool.bandit] 100 | exclude_dirs = [ 101 | "*/tests/*", 102 | ] 103 | 104 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | 4 | setup() 5 | -------------------------------------------------------------------------------- /testmanage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | import os 5 | import shutil 6 | import sys 7 | import warnings 8 | 9 | from django.core.management import execute_from_command_line 10 | 11 | 12 | os.environ["DJANGO_SETTINGS_MODULE"] = "wagtailinventory.tests.settings" 13 | 14 | 15 | def make_parser(): 16 | parser = argparse.ArgumentParser() 17 | parser.add_argument( 18 | "--deprecation", 19 | choices=["all", "pending", "imminent", "none"], 20 | default="imminent", 21 | ) 22 | return parser 23 | 24 | 25 | def parse_args(args=None): 26 | return make_parser().parse_known_args(args) 27 | 28 | 29 | def runtests(): 30 | args, rest = parse_args() 31 | 32 | only_wagtail = r"^wagtail(\.|$)" 33 | if args.deprecation == "all": 34 | # Show all deprecation warnings from all packages 35 | warnings.simplefilter("default", DeprecationWarning) 36 | warnings.simplefilter("default", PendingDeprecationWarning) 37 | elif args.deprecation == "pending": 38 | # Show all deprecation warnings from wagtail 39 | warnings.filterwarnings( 40 | "default", category=DeprecationWarning, module=only_wagtail 41 | ) 42 | warnings.filterwarnings( 43 | "default", category=PendingDeprecationWarning, module=only_wagtail 44 | ) 45 | elif args.deprecation == "imminent": 46 | # Show only imminent deprecation warnings from wagtail 47 | warnings.filterwarnings( 48 | "default", category=DeprecationWarning, module=only_wagtail 49 | ) 50 | elif args.deprecation == "none": 51 | # Deprecation warnings are ignored by default 52 | pass 53 | 54 | argv = [sys.argv[0]] + rest 55 | 56 | try: 57 | execute_from_command_line(argv) 58 | finally: 59 | from wagtail.test.settings import MEDIA_ROOT, STATIC_ROOT 60 | 61 | shutil.rmtree(STATIC_ROOT, ignore_errors=True) 62 | shutil.rmtree(MEDIA_ROOT, ignore_errors=True) 63 | 64 | 65 | if __name__ == "__main__": 66 | runtests() 67 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | skipsdist=True 3 | envlist= 4 | lint, 5 | python3.8-django{4.2}-wagtail{6.2} 6 | python3.12-django{4.2,5.0}-wagtail{6.2,6.3} 7 | python3.12-django5.1-wagtail6.3 8 | coverage 9 | 10 | [testenv] 11 | install_command=pip install -e ".[testing]" -U {opts} {packages} 12 | commands= 13 | python -b -m coverage run --parallel-mode --source='wagtailinventory' {toxinidir}/testmanage.py test {posargs} 14 | 15 | basepython= 16 | python3.8: python3.8 17 | python3.12: python3.12 18 | 19 | deps= 20 | django4.2: Django>=4.2,<4.3 21 | django5.0: Django>=5.0,<5.1 22 | django5.1: Django>=5.1,<5.2 23 | wagtail6.2: wagtail>=6.2,<6.3 24 | wagtail6.3: wagtail>=6.3,<6.4 25 | 26 | [testenv:lint] 27 | basepython=python3.12 28 | deps= 29 | ruff 30 | bandit 31 | commands= 32 | ruff format --check 33 | ruff check wagtailinventory testmanage.py 34 | bandit -c "pyproject.toml" -r wagtailinventory testmanage.py 35 | 36 | [testenv:coverage] 37 | basepython=python3.12 38 | deps= 39 | coverage[toml] 40 | diff_cover 41 | commands= 42 | coverage combine 43 | coverage report -m 44 | coverage xml 45 | diff-cover coverage.xml --compare-branch=origin/main --fail-under=100 46 | 47 | [testenv:interactive] 48 | basepython=python3.12 49 | deps= 50 | Django>=5.0,<5.2 51 | 52 | commands_pre= 53 | python {toxinidir}/testmanage.py makemigrations 54 | python {toxinidir}/testmanage.py migrate 55 | python {toxinidir}/testmanage.py shell -c "from django.contrib.auth.models import User;(not User.objects.filter(username='admin').exists()) and User.objects.create_superuser('admin', 'super@example.com', 'changeme')" 56 | python {toxinidir}/testmanage.py loaddata wagtailinventory/fixtures/test_blocks.json 57 | python {toxinidir}/testmanage.py block_inventory 58 | 59 | commands= 60 | {posargs:python testmanage.py runserver 0.0.0.0:8000} 61 | 62 | setenv= 63 | INTERACTIVE=1 64 | -------------------------------------------------------------------------------- /wagtailinventory/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cfpb/wagtail-inventory/4bb9e32240170e23f0ee590ccb795abe28f91624/wagtailinventory/__init__.py -------------------------------------------------------------------------------- /wagtailinventory/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class WagtailInventoryAppConfig(AppConfig): 5 | name = "wagtailinventory" 6 | verbose_name = "Wagtail Inventory" 7 | default_auto_field = "django.db.models.BigAutoField" 8 | -------------------------------------------------------------------------------- /wagtailinventory/fixtures/test_blocks.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "wagtailcore.page", 4 | "pk": 3, 5 | "fields": { 6 | "path": "000100010001", 7 | "depth": 3, 8 | "numchild": 0, 9 | "title": "Single StreamField page with no content", 10 | "draft_title": "Single StreamField page with no content", 11 | "slug": "single-streamfield-page-no-content", 12 | "content_type": [ 13 | "testapp", 14 | "singlestreamfieldpage" 15 | ], 16 | "live": false, 17 | "has_unpublished_changes": true, 18 | "url_path": "/home/single-streamfield-page-no-content/", 19 | "seo_title": "", 20 | "show_in_menus": false, 21 | "search_description": "", 22 | "go_live_at": null, 23 | "expire_at": null, 24 | "expired": false, 25 | "locked": false, 26 | "first_published_at": null, 27 | "last_published_at": null, 28 | "latest_revision_created_at": "2018-06-11T11:43:21.991Z", 29 | "live_revision": null 30 | } 31 | }, 32 | { 33 | "model": "wagtailcore.page", 34 | "pk": 4, 35 | "fields": { 36 | "path": "000100010002", 37 | "depth": 3, 38 | "numchild": 0, 39 | "title": "Single StreamField page with content", 40 | "draft_title": "Single StreamField page with content", 41 | "slug": "single-streamfield-page-content", 42 | "content_type": [ 43 | "testapp", 44 | "singlestreamfieldpage" 45 | ], 46 | "live": false, 47 | "has_unpublished_changes": true, 48 | "url_path": "/home/single-streamfield-page-content/", 49 | "seo_title": "", 50 | "show_in_menus": false, 51 | "search_description": "", 52 | "go_live_at": null, 53 | "expire_at": null, 54 | "expired": false, 55 | "locked": false, 56 | "first_published_at": null, 57 | "last_published_at": null, 58 | "latest_revision_created_at": "2018-06-11T11:43:38.252Z", 59 | "live_revision": null 60 | } 61 | }, 62 | { 63 | "model": "wagtailcore.page", 64 | "pk": 5, 65 | "fields": { 66 | "path": "000100010003", 67 | "depth": 3, 68 | "numchild": 0, 69 | "title": "Multiple StreamFields page", 70 | "draft_title": "Multiple StreamFields page", 71 | "slug": "multiple-streamfields-page", 72 | "content_type": [ 73 | "testapp", 74 | "multiplestreamfieldspage" 75 | ], 76 | "live": false, 77 | "has_unpublished_changes": true, 78 | "url_path": "/home/multiple-streamfields-page/", 79 | "seo_title": "", 80 | "show_in_menus": false, 81 | "search_description": "", 82 | "go_live_at": null, 83 | "expire_at": null, 84 | "expired": false, 85 | "locked": false, 86 | "first_published_at": null, 87 | "last_published_at": null, 88 | "latest_revision_created_at": "2018-06-11T11:44:22.660Z", 89 | "live_revision": null 90 | } 91 | }, 92 | { 93 | "model": "wagtailcore.page", 94 | "pk": 6, 95 | "fields": { 96 | "path": "000100010004", 97 | "depth": 3, 98 | "numchild": 0, 99 | "title": "Nested StreamBlock page", 100 | "draft_title": "Nested StreamBlock page", 101 | "slug": "nested-streamblock-page", 102 | "content_type": [ 103 | "testapp", 104 | "nestedstreamblockpage" 105 | ], 106 | "live": false, 107 | "has_unpublished_changes": true, 108 | "url_path": "/home/nested-streamblock-page/", 109 | "seo_title": "", 110 | "show_in_menus": false, 111 | "search_description": "", 112 | "go_live_at": null, 113 | "expire_at": null, 114 | "expired": false, 115 | "locked": false, 116 | "first_published_at": null, 117 | "last_published_at": null, 118 | "latest_revision_created_at": "2018-06-11T11:44:47.772Z", 119 | "live_revision": null 120 | } 121 | }, 122 | { 123 | "model": "wagtailcore.page", 124 | "pk": 7, 125 | "fields": { 126 | "path": "000100010005", 127 | "depth": 3, 128 | "numchild": 0, 129 | "title": "No StreamFields on this page", 130 | "draft_title": "No StreamFields on this page", 131 | "slug": "no-streamfields-page", 132 | "content_type": [ 133 | "testapp", 134 | "nostreamfieldspage" 135 | ], 136 | "live": false, 137 | "has_unpublished_changes": true, 138 | "url_path": "/home/no-streamfields-page/", 139 | "seo_title": "", 140 | "show_in_menus": false, 141 | "search_description": "", 142 | "go_live_at": null, 143 | "expire_at": null, 144 | "expired": false, 145 | "locked": false, 146 | "first_published_at": null, 147 | "last_published_at": null, 148 | "latest_revision_created_at": "2018-06-11T11:45:01.819Z", 149 | "live_revision": null 150 | } 151 | }, 152 | { 153 | "model": "testapp.nostreamfieldspage", 154 | "pk": 7, 155 | "fields": { 156 | "content": "Only text." 157 | } 158 | }, 159 | { 160 | "model": "testapp.singlestreamfieldpage", 161 | "pk": 3, 162 | "fields": { 163 | "content": "[]" 164 | } 165 | }, 166 | { 167 | "model": "testapp.singlestreamfieldpage", 168 | "pk": 4, 169 | "fields": { 170 | "content": "[{\"type\": \"atom\", \"value\": {\"title\": \"Atom\"}, \"id\": \"07422894-413b-4528-8f67-d7113b3bfe7a\"}]" 171 | } 172 | }, 173 | { 174 | "model": "testapp.multiplestreamfieldspage", 175 | "pk": 5, 176 | "fields": { 177 | "first": "[{\"type\": \"molecule\", \"value\": {\"title\": \"Molecule 1\", \"atoms\": [{\"title\": \"Atom 1\"}]}, \"id\": \"1596e627-b83d-4e33-83bf-78d741cc1456\"}]", 178 | "second": "[{\"type\": \"organism\", \"value\": {\"molecules\": [{\"title\": \"Molecule 2\", \"atoms\": [{\"title\": \"Atom 2\"}]}]}, \"id\": \"7f9b97e9-fd2a-4720-a0a5-85f097c170ed\"}]" 179 | } 180 | }, 181 | { 182 | "model": "testapp.nestedstreamblockpage", 183 | "pk": 6, 184 | "fields": { 185 | "content": "[{\"type\": \"streamblock\", \"value\": [{\"type\": \"text\", \"value\": \"Text 1\", \"id\": \"66b7fe1b-47e5-461c-b032-f7ee28733ac0\"}], \"id\": \"0bf78fa8-2bfa-4d6b-b146-c93a4d89bc4f\"}, {\"type\": \"streamblock\", \"value\": [{\"type\": \"atom\", \"value\": {\"title\": \"Text 2\"}, \"id\": \"a5b900bd-5c10-42ef-b304-c826b25a915e\"}], \"id\": \"4b80a8f0-e202-4ac9-b93a-e1fb1d10235e\"}]" 186 | } 187 | } 188 | ] 189 | -------------------------------------------------------------------------------- /wagtailinventory/helpers.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | 3 | from wagtail.blocks import ListBlock, StreamBlock, StructBlock 4 | from wagtail.fields import StreamField 5 | 6 | from wagtailinventory.models import PageBlock 7 | 8 | 9 | def get_block_name(block): 10 | return block.__module__ + "." + block.__class__.__name__ 11 | 12 | 13 | def get_page_blocks(page): 14 | blocks = [] 15 | 16 | for field in page.specific._meta.fields: 17 | if not isinstance(field, StreamField): 18 | continue 19 | 20 | for stream_child in getattr(page.specific, field.name): 21 | blocks.extend(get_field_blocks(stream_child)) 22 | 23 | return sorted(set(map(get_block_name, blocks))) 24 | 25 | 26 | def get_field_blocks(value): 27 | block = getattr(value, "block", None) 28 | blocks = [block] if block else [] 29 | 30 | if isinstance(block, StructBlock): 31 | if hasattr(value, "bound_blocks"): 32 | child_blocks = value.bound_blocks.values() 33 | else: 34 | child_blocks = [value.value] 35 | elif isinstance(block, (ListBlock, StreamBlock)): 36 | child_blocks = value.value 37 | else: 38 | child_blocks = [] 39 | 40 | blocks.extend(chain(*map(get_field_blocks, child_blocks))) 41 | 42 | return blocks 43 | 44 | 45 | def get_page_inventory(page=None): 46 | inventory = PageBlock.objects.all() 47 | 48 | if page: 49 | inventory = inventory.filter(page=page) 50 | 51 | return inventory 52 | 53 | 54 | def create_page_inventory(page): 55 | page_blocks = get_page_blocks(page) 56 | 57 | return [ 58 | PageBlock.objects.get_or_create(page=page, block=block)[0] 59 | for block in page_blocks 60 | ] 61 | 62 | 63 | def delete_page_inventory(page=None): 64 | get_page_inventory(page).delete() 65 | 66 | 67 | def update_page_inventory(page): 68 | page_blocks = create_page_inventory(page) 69 | 70 | for page_block in get_page_inventory(page): 71 | if page_block not in page_blocks: 72 | page_block.delete() 73 | -------------------------------------------------------------------------------- /wagtailinventory/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cfpb/wagtail-inventory/4bb9e32240170e23f0ee590ccb795abe28f91624/wagtailinventory/management/__init__.py -------------------------------------------------------------------------------- /wagtailinventory/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cfpb/wagtail-inventory/4bb9e32240170e23f0ee590ccb795abe28f91624/wagtailinventory/management/commands/__init__.py -------------------------------------------------------------------------------- /wagtailinventory/management/commands/block_inventory.py: -------------------------------------------------------------------------------- 1 | from django.core.management import BaseCommand 2 | 3 | from wagtail.models import Page 4 | 5 | from tqdm import tqdm 6 | 7 | from wagtailinventory.helpers import ( 8 | create_page_inventory, 9 | delete_page_inventory, 10 | ) 11 | 12 | 13 | class Command(BaseCommand): 14 | def handle(self, *args, **options): 15 | delete_page_inventory() 16 | 17 | pages = Page.objects.all() 18 | 19 | if options.get("verbosity"): # pragma: no cover 20 | pages = tqdm(pages) 21 | 22 | for page in pages: 23 | create_page_inventory(page) 24 | -------------------------------------------------------------------------------- /wagtailinventory/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import django.db.models.deletion 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ('wagtailcore', '0032_add_bulk_delete_page_permission'), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='PageBlock', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('block', models.CharField(db_index=True, max_length=255)), 20 | ('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_blocks', to='wagtailcore.Page')), 21 | ], 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /wagtailinventory/migrations/0002_pageblock_unique_constraint.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | from django.db.models import Count, Min 3 | 4 | 5 | def remove_duplicates(apps, schema_editor): # pragma: no cover 6 | PageBlock = apps.get_model('wagtailinventory', 'PageBlock') 7 | 8 | duplicate_pks_to_keep = ( 9 | PageBlock.objects 10 | .values('page', 'block') 11 | .annotate(Min('pk'), count=Count('pk')) 12 | .filter(count__gt=1) 13 | .values_list('pk__min', flat=True) 14 | ) 15 | 16 | duplicates_to_keep = PageBlock.objects.in_bulk(duplicate_pks_to_keep) 17 | 18 | for duplicate_to_keep in duplicates_to_keep.values(): 19 | duplicates_to_delete = PageBlock.objects.filter( 20 | page=duplicate_to_keep.page, 21 | block=duplicate_to_keep.block 22 | ).exclude(pk=duplicate_to_keep.pk) 23 | 24 | duplicates_to_delete.delete() 25 | 26 | 27 | class Migration(migrations.Migration): 28 | 29 | dependencies = [ 30 | ('wagtailinventory', '0001_initial'), 31 | ] 32 | 33 | operations = [ 34 | migrations.RunPython(remove_duplicates, migrations.RunPython.noop), 35 | migrations.AddConstraint( 36 | model_name='pageblock', 37 | constraint=models.UniqueConstraint(fields=('page', 'block'), name='unique_page_block'), 38 | ), 39 | ] 40 | -------------------------------------------------------------------------------- /wagtailinventory/migrations/0003_pageblock_id_bigautofield.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.4 on 2022-05-11 15:22 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('wagtailinventory', '0002_pageblock_unique_constraint'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='pageblock', 15 | name='id', 16 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /wagtailinventory/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cfpb/wagtail-inventory/4bb9e32240170e23f0ee590ccb795abe28f91624/wagtailinventory/migrations/__init__.py -------------------------------------------------------------------------------- /wagtailinventory/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from wagtail.models import Page 4 | 5 | 6 | class PageBlock(models.Model): 7 | page = models.ForeignKey( 8 | Page, related_name="page_blocks", on_delete=models.CASCADE 9 | ) 10 | block = models.CharField(max_length=255, db_index=True) 11 | 12 | class Meta: 13 | constraints = [ 14 | models.UniqueConstraint( 15 | fields=["page", "block"], name="unique_page_block" 16 | ), 17 | ] 18 | 19 | wagtail_reference_index_ignore = True 20 | 21 | def __str__(self): 22 | return f"<{self.page}, {self.block}>" 23 | -------------------------------------------------------------------------------- /wagtailinventory/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cfpb/wagtail-inventory/4bb9e32240170e23f0ee590ccb795abe28f91624/wagtailinventory/tests/__init__.py -------------------------------------------------------------------------------- /wagtailinventory/tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 5 | BASE_DIR = os.path.dirname(PROJECT_DIR) 6 | 7 | DEBUG = True 8 | 9 | ALLOWED_HOSTS = ["*"] 10 | 11 | SECRET_KEY = "not needed" 12 | 13 | DATABASES = { 14 | "default": { 15 | "ENGINE": "django.db.backends.sqlite3", 16 | "NAME": "wagtailinventory.sqlite", 17 | }, 18 | } 19 | 20 | WAGTAIL_APPS = ( 21 | "wagtail.contrib.forms", 22 | "wagtail.contrib.settings", 23 | "wagtail.admin", 24 | "wagtail", 25 | "wagtail.documents", 26 | "wagtail.images", 27 | "wagtail.sites", 28 | "wagtail.users", 29 | ) 30 | 31 | WAGTAILADMIN_RICH_TEXT_EDITORS = { 32 | "default": {"WIDGET": "wagtail.admin.rich_text.DraftailRichTextArea"}, 33 | "custom": {"WIDGET": "wagtail.test.testapp.rich_text.CustomRichTextArea"}, 34 | } 35 | 36 | MIDDLEWARE = ( 37 | "django.middleware.common.CommonMiddleware", 38 | "django.contrib.sessions.middleware.SessionMiddleware", 39 | "django.middleware.csrf.CsrfViewMiddleware", 40 | "django.contrib.auth.middleware.AuthenticationMiddleware", 41 | "django.contrib.messages.middleware.MessageMiddleware", 42 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 43 | ) 44 | 45 | INSTALLED_APPS = ( 46 | ( 47 | "django.contrib.admin", 48 | "django.contrib.auth", 49 | "django.contrib.contenttypes", 50 | "django.contrib.messages", 51 | "django.contrib.sessions", 52 | "django.contrib.staticfiles", 53 | "taggit", 54 | ) 55 | + WAGTAIL_APPS 56 | + ( 57 | "wagtailinventory", 58 | "wagtailinventory.tests.testapp", 59 | ) 60 | ) 61 | 62 | STATIC_ROOT = os.path.join(BASE_DIR, "static") 63 | STATIC_URL = "/static/" 64 | 65 | MEDIA_ROOT = os.path.join(BASE_DIR, "media") 66 | MEDIA_URL = "/media/" 67 | 68 | TEMPLATES = [ 69 | { 70 | "BACKEND": "django.template.backends.django.DjangoTemplates", 71 | "DIRS": [], 72 | "APP_DIRS": True, 73 | "OPTIONS": { 74 | "context_processors": [ 75 | "django.template.context_processors.debug", 76 | "django.template.context_processors.request", 77 | "django.contrib.auth.context_processors.auth", 78 | "django.contrib.messages.context_processors.messages", 79 | "django.template.context_processors.request", 80 | ], 81 | "debug": True, 82 | }, 83 | }, 84 | ] 85 | 86 | WAGTAIL_SITE_NAME = "Test Site" 87 | 88 | ROOT_URLCONF = "wagtailinventory.tests.urls" 89 | 90 | WAGTAILADMIN_BASE_URL = "http://localhost:8000" 91 | 92 | USE_TZ = True 93 | -------------------------------------------------------------------------------- /wagtailinventory/tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | from django.core.management import call_command 2 | from django.test import TestCase 3 | 4 | from wagtail.models import Page 5 | 6 | from wagtailinventory.helpers import ( 7 | get_page_blocks, 8 | get_page_inventory, 9 | update_page_inventory, 10 | ) 11 | 12 | 13 | class TestGetPageBlocks(TestCase): 14 | fixtures = ["test_blocks.json"] 15 | 16 | def test_page_with_no_streamfields_returns_empty_list(self): 17 | page = Page.objects.get(slug="no-streamfields-page") 18 | self.assertEqual(get_page_blocks(page), []) 19 | 20 | def test_empty_streamfield_returns_empty_list(self): 21 | page = Page.objects.get(slug="single-streamfield-page-no-content") 22 | self.assertEqual(get_page_blocks(page), []) 23 | 24 | def test_streamfield_with_single_block(self): 25 | page = Page.objects.get(slug="single-streamfield-page-content") 26 | self.assertEqual( 27 | get_page_blocks(page), 28 | [ 29 | "wagtail.blocks.field_block.CharBlock", 30 | "wagtailinventory.tests.testapp.blocks.Atom", 31 | ], 32 | ) 33 | 34 | def test_multiple_streamfields(self): 35 | page = Page.objects.get(slug="multiple-streamfields-page") 36 | self.assertEqual( 37 | get_page_blocks(page), 38 | [ 39 | "wagtail.blocks.field_block.CharBlock", 40 | "wagtail.blocks.list_block.ListBlock", 41 | "wagtailinventory.tests.testapp.blocks.Atom", 42 | "wagtailinventory.tests.testapp.blocks.Molecule", 43 | "wagtailinventory.tests.testapp.blocks.Organism", 44 | ], 45 | ) 46 | 47 | def test_nested_streamblocks(self): 48 | page = Page.objects.get(slug="nested-streamblock-page") 49 | self.assertEqual( 50 | get_page_blocks(page), 51 | [ 52 | "wagtail.blocks.field_block.CharBlock", 53 | "wagtail.blocks.stream_block.StreamBlock", 54 | "wagtailinventory.tests.testapp.blocks.Atom", 55 | ], 56 | ) 57 | 58 | 59 | class TestPageInventoryHelpers(TestCase): 60 | fixtures = ["test_blocks.json"] 61 | 62 | def setUp(self): 63 | call_command("block_inventory", verbosity=0) 64 | 65 | def test_get_all_pageblocks(self): 66 | self.assertEqual(get_page_inventory().count(), 10) 67 | 68 | def test_get_pageblocks_filtered_by_page(self): 69 | page = Page.objects.get(slug="single-streamfield-page-content") 70 | self.assertEqual(get_page_inventory(page).count(), 2) 71 | 72 | def test_update_page(self): 73 | # First the page has 2 blocks. 74 | page = Page.objects.get(slug="single-streamfield-page-content") 75 | self.assertEqual(get_page_inventory(page).count(), 2) 76 | 77 | # Delete the page's blocks. 78 | page = page.specific 79 | page.content = [] 80 | page.save_revision().publish() 81 | 82 | # Updating the page should remove the block inventory. 83 | update_page_inventory(page) 84 | self.assertEqual(get_page_inventory(page).count(), 0) 85 | -------------------------------------------------------------------------------- /wagtailinventory/tests/test_models.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from wagtail.models import Page 4 | 5 | from wagtailinventory.models import PageBlock 6 | 7 | 8 | class TestPageBlock(TestCase): 9 | def test_page_str(self): 10 | page_block = PageBlock( 11 | page=Page(title="Title", slug="title"), block="path.to.block" 12 | ) 13 | self.assertEqual(str(page_block), "") 14 | -------------------------------------------------------------------------------- /wagtailinventory/tests/test_views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.contrib.auth.models import Permission 3 | from django.test import TestCase 4 | from django.urls import reverse 5 | 6 | from wagtail.models import Page 7 | from wagtail.test.utils import WagtailTestUtils 8 | 9 | 10 | User = get_user_model() 11 | 12 | 13 | class BlockInventoryReportViewTestCase(WagtailTestUtils, TestCase): 14 | fixtures = ["test_blocks.json"] 15 | 16 | def test_view(self): 17 | self.login() 18 | 19 | response = self.client.get( 20 | reverse("wagtailinventory:block_inventory_report") 21 | ) 22 | self.assertIn("object_list", response.context) 23 | 24 | # Right now our queryset just returns all pages, to be filtered by the 25 | # superclass. We might want to put some guardrails around that later, 26 | # in which case this test will be more useful. For now this test just 27 | # tests that it does that. 28 | view_qs = response.context["object_list"] 29 | page_qs = Page.objects.order_by("title") 30 | self.assertEqual(list(view_qs), list(page_qs)) 31 | 32 | def test_view_no_permissions(self): 33 | # Create a user that can access the Wagtail admin but doesn't have 34 | # permission to view the block inventory report. 35 | user_without_permission = User.objects.create_user( 36 | username="noperm", email="", password="password" 37 | ) 38 | user_without_permission.user_permissions.add( 39 | Permission.objects.get( 40 | content_type__app_label="wagtailadmin", codename="access_admin" 41 | ) 42 | ) 43 | 44 | self.client.login(username="noperm", password="password") 45 | response = self.client.get( 46 | reverse("wagtailinventory:block_inventory_report") 47 | ) 48 | 49 | self.assertRedirects(response, reverse("wagtailadmin_home")) 50 | self.assertEqual( 51 | response.context["message"], 52 | "Sorry, you do not have permission to access this area.", 53 | ) 54 | -------------------------------------------------------------------------------- /wagtailinventory/tests/test_wagtail_hooks.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.contrib.auth.models import AnonymousUser, Permission 3 | from django.http import HttpRequest 4 | from django.test import TestCase 5 | from django.urls import reverse 6 | 7 | from wagtail.models import Page, Site 8 | from wagtail.test.utils import WagtailTestUtils 9 | 10 | from wagtailinventory.models import PageBlock 11 | from wagtailinventory.wagtail_hooks import ( 12 | CanViewBlockInventoryMenuItem, 13 | register_permissions, 14 | ) 15 | 16 | 17 | User = get_user_model() 18 | 19 | 20 | class TestWagtailHooks(TestCase, WagtailTestUtils): 21 | def setUp(self): 22 | self.root_page = Site.objects.get(is_default_site=True).root_page 23 | 24 | self.login() 25 | 26 | def test_page_edit_hooks(self): 27 | self.assertEqual(PageBlock.objects.all().count(), 0) 28 | 29 | # Creating a page should create its inventory with 2 blocks. 30 | post_data = { 31 | "title": "test", 32 | "slug": "test", 33 | "content-count": 2, 34 | "content-0-deleted": "", 35 | "content-0-order": 0, 36 | "content-0-type": "atom", 37 | "content-0-value-title": "atom", 38 | "content-1-deleted": "", 39 | "content-1-order": 1, 40 | "content-1-type": "molecule", 41 | "content-1-value-title": "molecule", 42 | "content-1-value-atoms-count": 0, 43 | } 44 | 45 | response = self.client.post( 46 | reverse( 47 | "wagtailadmin_pages:add", 48 | args=("testapp", "singlestreamfieldpage", self.root_page.id), 49 | ), 50 | post_data, 51 | ) 52 | self.assertEqual(response.status_code, 302) 53 | 54 | page = Page.objects.get(slug="test") 55 | 56 | self.assertEqual( 57 | list(PageBlock.objects.values_list("page", flat=True)), 58 | [page.pk] * 4, 59 | ) 60 | self.assertEqual( 61 | list( 62 | PageBlock.objects.order_by("block").values_list( 63 | "block", flat=True 64 | ) 65 | ), 66 | [ 67 | "wagtail.blocks.field_block.CharBlock", 68 | "wagtail.blocks.list_block.ListBlock", 69 | "wagtailinventory.tests.testapp.blocks.Atom", 70 | "wagtailinventory.tests.testapp.blocks.Molecule", 71 | ], 72 | ) 73 | 74 | # Updating the page should update its inventory. 75 | post_data = { 76 | "title": "test", 77 | "slug": "test", 78 | "content-count": 1, 79 | "content-0-deleted": "", 80 | "content-0-order": 0, 81 | "content-0-type": "atom", 82 | "content-0-value-title": "modified", 83 | "action-publish": "Publish", 84 | } 85 | 86 | response = self.client.post( 87 | reverse("wagtailadmin_pages:edit", args=[page.pk]), 88 | post_data, 89 | ) 90 | self.assertEqual(response.status_code, 302) 91 | 92 | self.assertEqual( 93 | list(PageBlock.objects.values_list("page", flat=True)), 94 | [page.pk] * 2, 95 | ) 96 | self.assertEqual( 97 | list( 98 | PageBlock.objects.order_by("block").values_list( 99 | "block", flat=True 100 | ) 101 | ), 102 | [ 103 | "wagtail.blocks.field_block.CharBlock", 104 | "wagtailinventory.tests.testapp.blocks.Atom", 105 | ], 106 | ) 107 | 108 | # Deleting the page should delete its inventory. 109 | response = self.client.post( 110 | reverse("wagtailadmin_pages:delete", args=[page.pk]), 111 | ) 112 | self.assertEqual(response.status_code, 302) 113 | 114 | self.assertEqual(PageBlock.objects.count(), 0) 115 | 116 | def test_menu_item(self): 117 | item = CanViewBlockInventoryMenuItem("Test", "/admin/test/") 118 | 119 | admin_request = HttpRequest() 120 | admin_request.user = User.objects.filter(is_superuser=True).first() 121 | self.assertTrue(item.is_shown(admin_request)) 122 | 123 | insufficient_permissions_request = HttpRequest() 124 | insufficient_permissions_request.user = AnonymousUser() 125 | self.assertFalse(item.is_shown(insufficient_permissions_request)) 126 | 127 | def test_register_permissions(self): 128 | self.assertQuerySetEqual( 129 | register_permissions(), 130 | Permission.objects.filter( 131 | content_type__app_label="wagtailinventory", 132 | codename__in=["view_pageblock"], 133 | ), 134 | ) 135 | -------------------------------------------------------------------------------- /wagtailinventory/tests/testapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cfpb/wagtail-inventory/4bb9e32240170e23f0ee590ccb795abe28f91624/wagtailinventory/tests/testapp/__init__.py -------------------------------------------------------------------------------- /wagtailinventory/tests/testapp/blocks.py: -------------------------------------------------------------------------------- 1 | from wagtail import blocks 2 | 3 | 4 | class Atom(blocks.StructBlock): 5 | title = blocks.CharBlock() 6 | 7 | 8 | class Molecule(blocks.StructBlock): 9 | title = blocks.CharBlock() 10 | atoms = blocks.ListBlock(Atom()) 11 | 12 | 13 | class Organism(blocks.StructBlock): 14 | molecules = blocks.ListBlock(Molecule()) 15 | -------------------------------------------------------------------------------- /wagtailinventory/tests/testapp/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import django.db.models.deletion 3 | from django.db import migrations, models 4 | 5 | from wagtail import blocks as core_blocks 6 | from wagtail import fields as core_fields 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ('wagtailcore', '0032_add_bulk_delete_page_permission'), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='MultipleStreamFieldsPage', 20 | fields=[ 21 | ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')), 22 | ('first', core_fields.StreamField([('atom', core_blocks.StructBlock([('title', core_blocks.CharBlock())])), ('molecule', core_blocks.StructBlock([('title', core_blocks.CharBlock()), ('atoms', core_blocks.ListBlock(core_blocks.StructBlock([('title', core_blocks.CharBlock())])))])), ('organism', core_blocks.StructBlock([('molecules', core_blocks.ListBlock(core_blocks.StructBlock([('title', core_blocks.CharBlock()), ('atoms', core_blocks.ListBlock(core_blocks.StructBlock([('title', core_blocks.CharBlock())])))])))]))], use_json_field=True)), 23 | ('second', core_fields.StreamField([('atom', core_blocks.StructBlock([('title', core_blocks.CharBlock())])), ('molecule', core_blocks.StructBlock([('title', core_blocks.CharBlock()), ('atoms', core_blocks.ListBlock(core_blocks.StructBlock([('title', core_blocks.CharBlock())])))])), ('organism', core_blocks.StructBlock([('molecules', core_blocks.ListBlock(core_blocks.StructBlock([('title', core_blocks.CharBlock()), ('atoms', core_blocks.ListBlock(core_blocks.StructBlock([('title', core_blocks.CharBlock())])))])))]))], use_json_field=True)), 24 | ], 25 | options={ 26 | 'abstract': False, 27 | }, 28 | bases=('wagtailcore.page',), 29 | ), 30 | migrations.CreateModel( 31 | name='NoStreamFieldsPage', 32 | fields=[ 33 | ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')), 34 | ('content', models.TextField()), 35 | ], 36 | options={ 37 | 'abstract': False, 38 | }, 39 | bases=('wagtailcore.page',), 40 | ), 41 | migrations.CreateModel( 42 | name='SingleStreamFieldPage', 43 | fields=[ 44 | ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')), 45 | ('content', core_fields.StreamField([('text', core_blocks.CharBlock()), ('atom', core_blocks.StructBlock([('title', core_blocks.CharBlock())])), ('molecule', core_blocks.StructBlock([('title', core_blocks.CharBlock()), ('atoms', core_blocks.ListBlock(core_blocks.StructBlock([('title', core_blocks.CharBlock())])))])), ('organism', core_blocks.StructBlock([('molecules', core_blocks.ListBlock(core_blocks.StructBlock([('title', core_blocks.CharBlock()), ('atoms', core_blocks.ListBlock(core_blocks.StructBlock([('title', core_blocks.CharBlock())])))])))]))], use_json_field=True)), 46 | ], 47 | options={ 48 | 'abstract': False, 49 | }, 50 | bases=('wagtailcore.page',), 51 | ), 52 | ] 53 | -------------------------------------------------------------------------------- /wagtailinventory/tests/testapp/migrations/0002_nested_stream_block_page.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import django.db.models.deletion 3 | from django.db import migrations, models 4 | 5 | from wagtail import blocks as core_blocks 6 | from wagtail import fields as core_fields 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('testapp', '0001_initial'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='NestedStreamBlockPage', 18 | fields=[ 19 | ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')), 20 | ('content', core_fields.StreamField([('streamblock', core_blocks.StreamBlock([('text', core_blocks.CharBlock()), ('atom', core_blocks.StructBlock([('title', core_blocks.CharBlock())]))]))], use_json_field=True)), 21 | ], 22 | options={ 23 | 'abstract': False, 24 | }, 25 | bases=('wagtailcore.page',), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /wagtailinventory/tests/testapp/migrations/0003_single_content_block_optional.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.db import migrations 3 | 4 | from wagtail import blocks as core_blocks 5 | from wagtail import fields as core_fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('testapp', '0002_nested_stream_block_page'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='singlestreamfieldpage', 17 | name='content', 18 | field=core_fields.StreamField([('atom', core_blocks.StructBlock([('title', core_blocks.CharBlock())])), ('molecule', core_blocks.StructBlock([('title', core_blocks.CharBlock()), ('atoms', core_blocks.ListBlock(core_blocks.StructBlock([('title', core_blocks.CharBlock())])))])), ('organism', core_blocks.StructBlock([('molecules', core_blocks.ListBlock(core_blocks.StructBlock([('title', core_blocks.CharBlock()), ('atoms', core_blocks.ListBlock(core_blocks.StructBlock([('title', core_blocks.CharBlock())])))])))]))], blank=True, use_json_field=True), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /wagtailinventory/tests/testapp/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cfpb/wagtail-inventory/4bb9e32240170e23f0ee590ccb795abe28f91624/wagtailinventory/tests/testapp/migrations/__init__.py -------------------------------------------------------------------------------- /wagtailinventory/tests/testapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from wagtail import blocks as wagtail_blocks 4 | from wagtail.admin.panels import FieldPanel 5 | from wagtail.fields import StreamField 6 | from wagtail.models import Page 7 | 8 | from wagtailinventory.tests.testapp import blocks 9 | 10 | 11 | class NoStreamFieldsPage(Page): 12 | content = models.TextField() 13 | 14 | content_panels = Page.content_panels + [ 15 | FieldPanel("content"), 16 | ] 17 | 18 | 19 | class SingleStreamFieldPage(Page): 20 | content = StreamField( 21 | [ 22 | ("atom", blocks.Atom()), 23 | ("molecule", blocks.Molecule()), 24 | ("organism", blocks.Organism()), 25 | ], 26 | blank=True, 27 | use_json_field=True, 28 | ) 29 | 30 | content_panels = Page.content_panels + [ 31 | FieldPanel("content"), 32 | ] 33 | 34 | 35 | class MultipleStreamFieldsPage(Page): 36 | first = StreamField( 37 | [ 38 | ("atom", blocks.Atom()), 39 | ("molecule", blocks.Molecule()), 40 | ("organism", blocks.Organism()), 41 | ], 42 | use_json_field=True, 43 | ) 44 | second = StreamField( 45 | [ 46 | ("atom", blocks.Atom()), 47 | ("molecule", blocks.Molecule()), 48 | ("organism", blocks.Organism()), 49 | ], 50 | use_json_field=True, 51 | ) 52 | 53 | content_panels = Page.content_panels + [ 54 | FieldPanel("first"), 55 | FieldPanel("second"), 56 | ] 57 | 58 | 59 | class NestedStreamBlockPage(Page): 60 | content = StreamField( 61 | [ 62 | ( 63 | "streamblock", 64 | wagtail_blocks.StreamBlock( 65 | [ 66 | ("text", wagtail_blocks.CharBlock()), 67 | ("atom", blocks.Atom()), 68 | ] 69 | ), 70 | ), 71 | ], 72 | use_json_field=True, 73 | ) 74 | 75 | content_panels = Page.content_panels + [ 76 | FieldPanel("content"), 77 | ] 78 | -------------------------------------------------------------------------------- /wagtailinventory/tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | from wagtail import urls as wagtailcore_urls 4 | from wagtail.admin import urls as wagtailadmin_urls 5 | from wagtail.documents import urls as wagtaildocs_urls 6 | 7 | 8 | try: 9 | from django.urls import include, re_path 10 | except ImportError: 11 | from django.conf.urls import include 12 | from django.conf.urls import url as re_path 13 | 14 | 15 | urlpatterns = [ 16 | re_path(r"^admin/", include(wagtailadmin_urls)), 17 | re_path(r"^documents/", include(wagtaildocs_urls)), 18 | re_path(r"", include(wagtailcore_urls)), 19 | ] 20 | 21 | 22 | if settings.DEBUG: 23 | from django.conf.urls.static import static 24 | from django.contrib.staticfiles.urls import staticfiles_urlpatterns 25 | 26 | urlpatterns += staticfiles_urlpatterns() 27 | urlpatterns += static( 28 | settings.MEDIA_URL, document_root=settings.MEDIA_ROOT 29 | ) 30 | -------------------------------------------------------------------------------- /wagtailinventory/views.py: -------------------------------------------------------------------------------- 1 | from django.forms.widgets import SelectMultiple 2 | 3 | from wagtail.admin.auth import permission_denied 4 | from wagtail.admin.filters import ContentTypeFilter, WagtailFilterSet 5 | from wagtail.admin.views.reports import PageReportView 6 | from wagtail.models import Page, get_page_content_types 7 | 8 | import django_filters 9 | 10 | from wagtailinventory.models import PageBlock 11 | 12 | 13 | def get_block_choices(): 14 | return [ 15 | (page_block, page_block) 16 | for page_block in PageBlock.objects.distinct() 17 | .order_by("block") 18 | .values_list("block", flat=True) 19 | ] 20 | 21 | 22 | class BlockInventoryFilterSet(WagtailFilterSet): 23 | include_page_blocks = django_filters.MultipleChoiceFilter( 24 | field_name="page_blocks__block", 25 | label="Include Blocks", 26 | distinct=True, 27 | choices=get_block_choices, 28 | widget=SelectMultiple(attrs={"style": "overflow: auto"}), 29 | ) 30 | exclude_page_blocks = django_filters.MultipleChoiceFilter( 31 | field_name="page_blocks__block", 32 | label="Exclude Blocks", 33 | distinct=True, 34 | exclude=True, 35 | choices=get_block_choices, 36 | widget=SelectMultiple(attrs={"style": "overflow: auto"}), 37 | ) 38 | content_type = ContentTypeFilter( 39 | label="Page Type", 40 | queryset=lambda request: get_page_content_types(), 41 | ) 42 | 43 | class Meta: 44 | model = Page 45 | fields = ["include_page_blocks", "exclude_page_blocks", "content_type"] 46 | 47 | 48 | class BlockInventoryReportView(PageReportView): 49 | page_title = "Block inventory" 50 | header_icon = "placeholder" 51 | filterset_class = BlockInventoryFilterSet 52 | index_url_name = "wagtailinventory:block_inventory_report" 53 | index_results_url_name = "wagtailinventory:block_inventory_report_results" 54 | 55 | @classmethod 56 | def check_permissions(cls, request): 57 | return request.user.is_superuser or request.user.has_perm( 58 | "wagtailinventory.view_pageblock" 59 | ) 60 | 61 | def dispatch(self, request, *args, **kwargs): 62 | if not self.check_permissions(request): 63 | return permission_denied(request) 64 | return super().dispatch(request, *args, **kwargs) 65 | 66 | def get_queryset(self): 67 | self.queryset = Page.objects.order_by("title") 68 | return super().get_queryset() 69 | -------------------------------------------------------------------------------- /wagtailinventory/wagtail_hooks.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import Permission 2 | from django.urls import include, path, reverse 3 | 4 | from wagtail import hooks 5 | from wagtail.admin.menu import MenuItem 6 | 7 | from wagtailinventory.helpers import ( 8 | create_page_inventory, 9 | delete_page_inventory, 10 | update_page_inventory, 11 | ) 12 | from wagtailinventory.views import BlockInventoryReportView 13 | 14 | 15 | @hooks.register("after_create_page") 16 | def do_after_page_create(request, page): 17 | create_page_inventory(page) 18 | 19 | 20 | @hooks.register("after_edit_page") 21 | def do_after_page_edit(request, page): 22 | update_page_inventory(page) 23 | 24 | 25 | @hooks.register("after_delete_page") 26 | def do_after_page_dete(request, page): 27 | delete_page_inventory(page) 28 | 29 | 30 | @hooks.register("register_permissions") 31 | def register_permissions(): 32 | return Permission.objects.filter( 33 | content_type__app_label="wagtailinventory", 34 | codename__in=["view_pageblock"], 35 | ) 36 | 37 | 38 | class CanViewBlockInventoryMenuItem(MenuItem): 39 | def is_shown(self, request): 40 | return BlockInventoryReportView.check_permissions(request) 41 | 42 | 43 | @hooks.register("register_reports_menu_item") 44 | def register_inventory_report_menu_item(): 45 | return CanViewBlockInventoryMenuItem( 46 | "Block inventory", 47 | reverse("wagtailinventory:block_inventory_report"), 48 | icon_name=BlockInventoryReportView.header_icon, 49 | ) 50 | 51 | 52 | @hooks.register("register_admin_urls") 53 | def register_inventory_report_url(): 54 | report_urls = [ 55 | path( 56 | "", 57 | BlockInventoryReportView.as_view(), 58 | name="block_inventory_report", 59 | ), 60 | path( 61 | "results/", 62 | BlockInventoryReportView.as_view(results_only=True), 63 | name="block_inventory_report_results", 64 | ), 65 | ] 66 | 67 | return [ 68 | path( 69 | "block-inventory/", 70 | include( 71 | (report_urls, "wagtailinventory"), 72 | namespace="wagtailinventory", 73 | ), 74 | ) 75 | ] 76 | --------------------------------------------------------------------------------