├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── pull_request_template.md └── workflows │ └── docker.yml ├── .gitignore ├── CONTRIBUTING.md ├── CONTRIBUTORS.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── artwork ├── favicon │ ├── 32.png │ ├── favicon.ico │ ├── favicon.svg │ └── logo_for_twitter.png ├── logo-w160px.png └── papermerge3-3.png ├── changelog.md ├── config ├── __init__.py ├── asgi.py ├── celery.py ├── settings │ ├── __init__.py │ ├── base.py │ ├── dev.py │ ├── development.example.py │ ├── devtmp.py │ └── test.py ├── urls.py └── wsgi.py ├── docker ├── app.dockerfile ├── app.startup.sh ├── config │ ├── app.production.py │ ├── papermerge.config.py │ └── worker.production.py ├── docker-compose.yml ├── scripts │ └── create_user.py ├── worker.dockerfile └── worker.startup.sh ├── example_data ├── deutsch │ └── page_management │ │ ├── usecase_1 │ │ ├── readme.txt │ │ └── rechnung.pdf │ │ ├── usecase_2 │ │ ├── dokument-A.pdf │ │ ├── dokument-B.pdf │ │ └── readme.txt │ │ └── usecase_3 │ │ ├── dokument-2-seiten.pdf │ │ └── readme.txt ├── english │ └── page_management │ │ └── usecase_1 │ │ ├── A.pdf │ │ ├── B.pdf │ │ ├── C.pdf │ │ ├── D.pdf │ │ └── readme.txt └── spanish │ └── andromeda.pdf ├── img └── papermerge3.png ├── manage.py ├── papermerge.conf.py.example ├── papermerge ├── test │ ├── __init__.py │ ├── auth_backends.py │ ├── contrib │ │ ├── __init__.py │ │ └── admin │ │ │ ├── __init__.py │ │ │ ├── test_anonymous_user.py │ │ │ ├── test_forms.py │ │ │ ├── test_sidebar_part.py │ │ │ ├── test_sidebar_part_field.py │ │ │ ├── test_views_automates.py │ │ │ ├── test_views_groups.py │ │ │ ├── test_views_index.py │ │ │ ├── test_views_logs.py │ │ │ ├── test_views_preferences.py │ │ │ └── test_views_tags.py │ ├── data │ │ ├── berlin.pdf │ │ ├── one-doc-in-root-testdata.tar │ │ ├── page-1.hocr │ │ ├── page-1.jpg │ │ └── testdata.tar │ ├── parts │ │ ├── __init__.py │ │ ├── app_0 │ │ │ ├── __init__.py │ │ │ ├── apps.py │ │ │ ├── migrations │ │ │ │ ├── 0001_initial.py │ │ │ │ ├── 0002_auto_20201111_0650.py │ │ │ │ └── __init__.py │ │ │ └── models.py │ │ ├── app_dr │ │ │ ├── __init__.py │ │ │ ├── apps.py │ │ │ ├── migrations │ │ │ │ ├── 0001_initial.py │ │ │ │ └── __init__.py │ │ │ └── models.py │ │ ├── app_max_p │ │ │ ├── __init__.py │ │ │ ├── apps.py │ │ │ ├── migrations │ │ │ │ ├── 0001_initial.py │ │ │ │ └── __init__.py │ │ │ └── models.py │ │ └── test_parts.py │ ├── test_access_model.py │ ├── test_automate.py │ ├── test_backup_restore.py │ ├── test_decorators.py │ ├── test_document.py │ ├── test_folder.py │ ├── test_hocr.py │ ├── test_imap_import.py │ ├── test_import_pipelines.py │ ├── test_kvstore.py │ ├── test_local_import.py │ ├── test_node.py │ ├── test_page.py │ ├── test_path.py │ ├── test_search.py │ ├── test_search_excerpt.py │ ├── test_tags.py │ ├── test_typed_key.py │ ├── test_utils.py │ ├── utils.py │ └── views │ │ ├── __init__.py │ │ ├── test_access_view.py │ │ ├── test_api_view.py │ │ ├── test_document_view.py │ │ ├── test_nodes_view.py │ │ ├── test_search_view.py │ │ ├── test_users_view.py │ │ ├── test_utils.py │ │ ├── test_view.py │ │ └── test_views_tags.py └── wsignals │ ├── __init__.py │ ├── apps.py │ ├── migrations │ └── __init__.py │ └── signals.py ├── poetry.lock ├── pyproject.toml ├── requirements ├── documentation.txt ├── extra │ ├── mysql.txt │ └── pg.txt └── production.txt ├── run_tests.sh ├── setup.cfg └── setup.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ciur 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: ciur 7 | 8 | --- 9 | 10 | **Description** 11 | 12 | 13 | **Info:** 14 | - Papermerge Version [e.g. 3.0.1] 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement, feature request 6 | assignees: ciur 7 | --- 8 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context, providing screenshots where practical. List any dependencies that are required for this change. 4 | 5 | Fixes # (issue) 6 | 7 | ## Type of change 8 | 9 | Please delete options that are not relevant. 10 | 11 | - [ ] Bug fix (non-breaking change which fixes an issue) 12 | - [ ] New feature (non-breaking change which adds functionality) 13 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 14 | - [ ] This change requires a documentation update 15 | 16 | # How Has This Been Tested? 17 | 18 | Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration 19 | 20 | - [ ] Test A 21 | - [ ] Test B 22 | 23 | **Test Configuration**: 24 | 25 | * Python version: 26 | * OS version 27 | 28 | # Checklist: 29 | 30 | - [ ] I have read the [Contributing file available here](https://github.com/ciur/papermerge/blob/master/CONTRIBUTING.md) 31 | - [ ] I have formatted this PR according to [PEP8 rules](https://www.python.org/dev/peps/pep-0008/) 32 | - [ ] I have commented my code, particularly in hard-to-understand areas 33 | - [ ] I have made corresponding changes to the documentation 34 | - [ ] My changes generate no new warnings 35 | - [ ] I have added tests that prove my fix is effective or that my feature works 36 | - [ ] New and existing unit tests pass locally with my changes 37 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker Images 2 | on: 3 | release: 4 | types: 5 | - published 6 | jobs: 7 | build-and-push-app-docker-image: 8 | runs-on: ubuntu-20.04 9 | steps: 10 | - name: Docker meta 11 | id: docker_meta 12 | uses: crazy-max/ghaction-docker-meta@v1 13 | with: 14 | images: eugenci/papermerge 15 | tag-semver: | 16 | {{version}} 17 | {{major}}.{{minor}} 18 | - name: Checkout source code 19 | uses: actions/checkout@v2 20 | - name: Set up Docker Buildx 21 | uses: docker/setup-buildx-action@v1 22 | - name: Set up QEMU 23 | uses: docker/setup-qemu-action@v1 24 | - name: Cache Docker layers 25 | uses: actions/cache@v2 26 | with: 27 | path: /tmp/.buildx-cache 28 | key: ${{ runner.os }}-buildx-${{ github.sha }} 29 | restore-keys: | 30 | ${{ runner.os }}-buildx- 31 | - name: Login to DockerHub 32 | uses: docker/login-action@v1 33 | with: 34 | username: ${{ secrets.DOCKER_USERNAME }} 35 | password: ${{ secrets.DOCKER_PASSWORD }} 36 | - name: Build and push image 37 | uses: docker/build-push-action@v2 38 | with: 39 | context: ./docker 40 | file: ./docker/app.dockerfile 41 | platforms: linux/amd64 42 | push: true 43 | tags: ${{ steps.docker_meta.outputs.tags }} 44 | cache-from: type=local,src=/tmp/.buildx-cache 45 | cache-to: type=local,dest=/tmp/.buildx-cache 46 | build-and-push-worker-docker-image: 47 | runs-on: ubuntu-20.04 48 | steps: 49 | - name: Docker meta 50 | id: docker_meta 51 | uses: crazy-max/ghaction-docker-meta@v1 52 | with: 53 | images: eugenci/papermerge-worker 54 | tag-semver: | 55 | {{version}} 56 | {{major}}.{{minor}} 57 | - name: Checkout source code 58 | uses: actions/checkout@v2 59 | - name: Set up Docker Buildx 60 | uses: docker/setup-buildx-action@v1 61 | - name: Set up QEMU 62 | uses: docker/setup-qemu-action@v1 63 | - name: Cache Docker layers 64 | uses: actions/cache@v2 65 | with: 66 | path: /tmp/.buildx-cache 67 | key: ${{ runner.os }}-buildx-${{ github.sha }} 68 | restore-keys: | 69 | ${{ runner.os }}-buildx- 70 | - name: Login to DockerHub 71 | uses: docker/login-action@v1 72 | with: 73 | username: ${{ secrets.DOCKER_USERNAME }} 74 | password: ${{ secrets.DOCKER_PASSWORD }} 75 | - name: Build and push image 76 | uses: docker/build-push-action@v2 77 | with: 78 | context: ./docker 79 | file: ./docker/worker.dockerfile 80 | platforms: linux/amd64 81 | push: true 82 | tags: ${{ steps.docker_meta.outputs.tags }} 83 | cache-from: type=local,src=/tmp/.buildx-cache 84 | cache-to: type=local,dest=/tmp/.buildx-cache 85 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | config/settings/local/* 3 | *.conf.py 4 | env.sh 5 | env_dev.sh 6 | env_test.sh 7 | run/ 8 | .venv 9 | *.pyc 10 | __pycache__ 11 | *.swp 12 | *.swn 13 | *.swo 14 | *.log 15 | .ropeproject 16 | *.sqlite3 17 | *.sublime-project 18 | *.map 19 | *.png~ 20 | *.kra~ 21 | papermerge.egg-info 22 | dist 23 | # Sphinx documentation 24 | docs/build/ 25 | _build 26 | _static 27 | _templates 28 | media/ 29 | ./static/ 30 | queue/ 31 | celerybeat-schedule 32 | launch.json 33 | .vscode 34 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # We :heart: Your Contributions 2 | 3 | Thank you for taking your time to contribute to Papermerge. This guide will 4 | help you spend your time well, and help us keep Papermerge focused and making 5 | valuable progress. 6 | 7 | In general, for very small changes like fixing documentation typo, remove 8 | unused variable or just remove a redundant white space - just create a pull 9 | request and very likely that your change (if it is reasonable) will be 10 | accepted immediately. 11 | 12 | For more **significant changes**, for example you plan to add a feature, or 13 | change/add a whole paragraph to the documentation - please **first discuss the 14 | change** you wish to make via GitHub issue, pull request or 15 | [email](mailto:eugen@papermerge.com). 16 | 17 | As general rule: the smaller your pull request is - the higher chance of it 18 | being merged. 19 | 20 | 21 | ## Fix a Typo 22 | 23 | Contribute to the project just by fixing documentation's typos. Like tis one. English is not [project maintainer's](https://github.com/ciur/) native lnguage so he makes lots of typoz. 24 | 25 | Fixing documentation typos is easiest and fastest way to value to the project. 26 | 27 | ## Open an Issue. 28 | 29 | Another way to contribute is open issues. Obviously this means you need to at 30 | least run once application and test it. 31 | 32 | 33 | ## Translate 34 | 35 | Currently Papermerge's user interface is available in English and German. 36 | 37 | If your master another language (e.g French, Spanish, Russian etc) 38 | you can add great value to the project by translating it to your own native 39 | language. Because Papermerge is based on absolutely awesome [Django Web 40 | Framework](https://www.djangoproject.com/) all Django's documentation applies 41 | here as well. So it is a good idea to first go through [Django's i18n 42 | documentation](https://docs.djangoproject.com/en/3.1/topics/i18n/) and then 43 | come back to translation Papermerge specific topics. 44 | 45 | For detailed information check [Translators Guide in 46 | documentation](https://papermerge.readthedocs.io/en/latest/translators_guide/index.html). 47 | 48 | 49 | ## Fixes and Pull Requests 50 | 51 | If you want to contribute (fix a bug, add a feature) - that is absolutely great! 52 | Any kind of improvement is welcome. 53 | 54 | There are 3 golden rules to follow. 55 | 56 | 57 | ### Rule 1 (R1) 58 | 59 | PEP8. 60 | 61 | All code must formated with [PEP8 62 | style](https://www.python.org/dev/peps/pep-0008/). If you code has couple of 63 | minor deviations from PEP8 - that's ok. We are flexible here, but if your PR code formatting 64 | has serious violations of PEP8 - it will simply be rejected with comment *not PEP8 formatted*. 65 | 66 | 67 | ### Rule 2 (R2) 68 | 69 | Communicate. Document. 70 | 71 | **Before creating a pull request for a new feature** - please first discuss the 72 | change you wish to make via GitHub issue, pull request or email. A 73 | silent creation of PR for any sort of feature without any communication will 74 | result in silent discard of your PR. If you are lazy to comment you your PR - 75 | why should we care to communicate you the reason of rejection? :)) 76 | 77 | 78 | ### Rule 3 (R3) 79 | 80 | Test. Test. Test. 81 | 82 | Code without tests is broken design. 83 | New features without basic tests will be rejected. 84 | 85 | 86 | ## For Developers. Regarding Code Style - Far Beyond PEP8 87 | 88 | Papermerge code has a style. You may not like it, because style is a matter of 89 | taste after all. However, for the sake of consistency - you will need to 90 | follow couple of extra rule. Following rules are not so strict as 3 golden 91 | rules above (1. PEP8, 2. Document 3. Test) however you are strongly encouraged 92 | to follow them. 93 | 94 | ### Use fStrings (S1) 95 | 96 | Use fStrings whenever possible. 97 | 98 | Bad: 99 | 100 | logger.debug("{} importer: not a file.".format(processor)) 101 | 102 | Good: 103 | 104 | logger.debug( 105 | f"{processor} importer: not a file." 106 | ) -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | Open source is awesome! Any person on the planet, with Internet connection and 2 | with reasonable knowledge can bring value for humanity and push forward 3 | technological progress. Open source encourages collaboration, openness and 4 | kindness. 5 | 6 | There are many people who contributed in Papermerge project. This file will 7 | list maintainers, contributors and sponsors of Papermerge project. 8 | 9 | ### Tech Lead & Maintainer 10 | 11 | * [Eugen Ciur](https://github.com/ciur) 12 | 13 | ### Contributors 14 | 15 | * [@frenos](https://github.com/frenos) - Backup and Restore Feature. 16 | * [@mtonnie](https://github.com/mtonnie) - Packaging for Synology NAS devices. 17 | * [@francescocarzaniga](https://github.com/francescocarzaniga) - Too many to list here. 18 | * [@defaultroute-eu](https://github.com/defaultroute-eu) - Reported a bug, provided a fix/PR. 19 | * [@hactar](https://github.com/hactar) - Documentation updates. 20 | * [Reto](https://github.com/tido-) - for very useful feedback and testing. 21 | * [Michael Nikitochkin](https://github.com/miry) - for fixing docker and docker compose files. 22 | * [@FutureCow](https://github.com/FutureCow) - for valuable documentation updates. 23 | * [Jesse Cureton](https://github.com/jessecureton) - Docker integration improved. 24 | * [@pvinis](https://github.com/pvinis) - fixed typo in README file. 25 | * [@amo13](https://github.com/amo13) - typo + systemd unit file fix. 26 | * [@dani](https://github.com/dani) - French i18n; github issues. 27 | * [Thomas Pedersen](https://github.com/twpedersen) - imap fixes. 28 | * [Georg Krause](https://github.com/georgkrause) - mglib improvements; replaced pdftk with stapler. 29 | * [Herr Knedel](https://github.com/ChristianKnedel) - German translations. 30 | 31 | ### Security Auditors 32 | 33 | * [@l4rm4nd](https://github.com/l4rm4nd) 34 | * [Ome Mishra](https://github.com/omemishra) 35 | 36 | ### Sponsors 37 | 38 | A special *Thank you* goes to GitHub Sponsors! 39 | 40 | * [Alexander](https://github.com/alex1702) 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | Copyright 2020-2021 Eugen Ciur 6 | 7 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 8 | 9 | 1. Definitions. 10 | 11 | "License" shall mean the terms and conditions for use, reproduction, 12 | and distribution as defined by Sections 1 through 9 of this document. 13 | 14 | "Licensor" shall mean the copyright owner or entity authorized by 15 | the copyright owner that is granting the License. 16 | 17 | "Legal Entity" shall mean the union of the acting entity and all 18 | other entities that control, are controlled by, or are under common 19 | control with that entity. For the purposes of this definition, 20 | "control" means (i) the power, direct or indirect, to cause the 21 | direction or management of such entity, whether by contract or 22 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 23 | outstanding shares, or (iii) beneficial ownership of such entity. 24 | 25 | "You" (or "Your") shall mean an individual or Legal Entity 26 | exercising permissions granted by this License. 27 | 28 | "Source" form shall mean the preferred form for making modifications, 29 | including but not limited to software source code, documentation 30 | source, and configuration files. 31 | 32 | "Object" form shall mean any form resulting from mechanical 33 | transformation or translation of a Source form, including but 34 | not limited to compiled object code, generated documentation, 35 | and conversions to other media types. 36 | 37 | "Work" shall mean the work of authorship, whether in Source or 38 | Object form, made available under the License, as indicated by a 39 | copyright notice that is included in or attached to the work 40 | (an example is provided in the Appendix below). 41 | 42 | "Derivative Works" shall mean any work, whether in Source or Object 43 | form, that is based on (or derived from) the Work and for which the 44 | editorial revisions, annotations, elaborations, or other modifications 45 | represent, as a whole, an original work of authorship. For the purposes 46 | of this License, Derivative Works shall not include works that remain 47 | separable from, or merely link (or bind by name) to the interfaces of, 48 | the Work and Derivative Works thereof. 49 | 50 | "Contribution" shall mean any work of authorship, including 51 | the original version of the Work and any modifications or additions 52 | to that Work or Derivative Works thereof, that is intentionally 53 | submitted to Licensor for inclusion in the Work by the copyright owner 54 | or by an individual or Legal Entity authorized to submit on behalf of 55 | the copyright owner. For the purposes of this definition, "submitted" 56 | means any form of electronic, verbal, or written communication sent 57 | to the Licensor or its representatives, including but not limited to 58 | communication on electronic mailing lists, source code control systems, 59 | and issue tracking systems that are managed by, or on behalf of, the 60 | Licensor for the purpose of discussing and improving the Work, but 61 | excluding communication that is conspicuously marked or otherwise 62 | designated in writing by the copyright owner as "Not a Contribution." 63 | 64 | "Contributor" shall mean Licensor and any individual or Legal Entity 65 | on behalf of whom a Contribution has been received by Licensor and 66 | subsequently incorporated within the Work. 67 | 68 | 2. Grant of Copyright License. Subject to the terms and conditions of 69 | this License, each Contributor hereby grants to You a perpetual, 70 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 71 | copyright license to reproduce, prepare Derivative Works of, 72 | publicly display, publicly perform, sublicense, and distribute the 73 | Work and such Derivative Works in Source or Object form. 74 | 75 | 3. Grant of Patent License. Subject to the terms and conditions of 76 | this License, each Contributor hereby grants to You a perpetual, 77 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 78 | (except as stated in this section) patent license to make, have made, 79 | use, offer to sell, sell, import, and otherwise transfer the Work, 80 | where such license applies only to those patent claims licensable 81 | by such Contributor that are necessarily infringed by their 82 | Contribution(s) alone or by combination of their Contribution(s) 83 | with the Work to which such Contribution(s) was submitted. If You 84 | institute patent litigation against any entity (including a 85 | cross-claim or counterclaim in a lawsuit) alleging that the Work 86 | or a Contribution incorporated within the Work constitutes direct 87 | or contributory patent infringement, then any patent licenses 88 | granted to You under this License for that Work shall terminate 89 | as of the date such litigation is filed. 90 | 91 | 4. Redistribution. You may reproduce and distribute copies of the 92 | Work or Derivative Works thereof in any medium, with or without 93 | modifications, and in Source or Object form, provided that You 94 | meet the following conditions: 95 | 96 | (a) You must give any other recipients of the Work or 97 | Derivative Works a copy of this License; and 98 | 99 | (b) You must cause any modified files to carry prominent notices 100 | stating that You changed the files; and 101 | 102 | (c) You must retain, in the Source form of any Derivative Works 103 | that You distribute, all copyright, patent, trademark, and 104 | attribution notices from the Source form of the Work, 105 | excluding those notices that do not pertain to any part of 106 | the Derivative Works; and 107 | 108 | (d) If the Work includes a "NOTICE" text file as part of its 109 | distribution, then any Derivative Works that You distribute must 110 | include a readable copy of the attribution notices contained 111 | within such NOTICE file, excluding those notices that do not 112 | pertain to any part of the Derivative Works, in at least one 113 | of the following places: within a NOTICE text file distributed 114 | as part of the Derivative Works; within the Source form or 115 | documentation, if provided along with the Derivative Works; or, 116 | within a display generated by the Derivative Works, if and 117 | wherever such third-party notices normally appear. The contents 118 | of the NOTICE file are for informational purposes only and 119 | do not modify the License. You may add Your own attribution 120 | notices within Derivative Works that You distribute, alongside 121 | or as an addendum to the NOTICE text from the Work, provided 122 | that such additional attribution notices cannot be construed 123 | as modifying the License. 124 | 125 | You may add Your own copyright statement to Your modifications and 126 | may provide additional or different license terms and conditions 127 | for use, reproduction, or distribution of Your modifications, or 128 | for any such Derivative Works as a whole, provided Your use, 129 | reproduction, and distribution of the Work otherwise complies with 130 | the conditions stated in this License. 131 | 132 | 5. Submission of Contributions. Unless You explicitly state otherwise, 133 | any Contribution intentionally submitted for inclusion in the Work 134 | by You to the Licensor shall be under the terms and conditions of 135 | this License, without any additional terms or conditions. 136 | Notwithstanding the above, nothing herein shall supersede or modify 137 | the terms of any separate license agreement you may have executed 138 | with Licensor regarding such Contributions. 139 | 140 | 6. Trademarks. This License does not grant permission to use the trade 141 | names, trademarks, service marks, or product names of the Licensor, 142 | except as required for reasonable and customary use in describing the 143 | origin of the Work and reproducing the content of the NOTICE file. 144 | 145 | 7. Disclaimer of Warranty. Unless required by applicable law or 146 | agreed to in writing, Licensor provides the Work (and each 147 | Contributor provides its Contributions) on an "AS IS" BASIS, 148 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 149 | implied, including, without limitation, any warranties or conditions 150 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 151 | PARTICULAR PURPOSE. You are solely responsible for determining the 152 | appropriateness of using or redistributing the Work and assume any 153 | risks associated with Your exercise of permissions under this License. 154 | 155 | 8. Limitation of Liability. In no event and under no legal theory, 156 | whether in tort (including negligence), contract, or otherwise, 157 | unless required by applicable law (such as deliberate and grossly 158 | negligent acts) or agreed to in writing, shall any Contributor be 159 | liable to You for damages, including any direct, indirect, special, 160 | incidental, or consequential damages of any character arising as a 161 | result of this License or out of the use or inability to use the 162 | Work (including but not limited to damages for loss of goodwill, 163 | work stoppage, computer failure or malfunction, or any and all 164 | other commercial damages or losses), even if such Contributor 165 | has been advised of the possibility of such damages. 166 | 167 | 9. Accepting Warranty or Additional Liability. While redistributing 168 | the Work or Derivative Works thereof, You may choose to offer, 169 | and charge a fee for, acceptance of support, warranty, indemnity, 170 | or other liability obligations and/or rights consistent with this 171 | License. However, in accepting such obligations, You may act only 172 | on Your own behalf and on Your sole responsibility, not on behalf 173 | of any other Contributor, and only if You agree to indemnify, 174 | defend, and hold each Contributor harmless for any liability 175 | incurred by, or claims asserted against, such Contributor by reason 176 | of your accepting any such warranty or additional liability. 177 | 178 | END OF TERMS AND CONDITIONS 179 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include MANIFEST.in 3 | include manage.py 4 | include LICENSE 5 | include changelog.md 6 | include papermerge/boss/locale/de/LC_MESSAGES/django.mo 7 | include papermerge/boss/locale/de/LC_MESSAGES/django.po 8 | include papermerge/core/locale/de/LC_MESSAGES/django.mo 9 | include papermerge/core/locale/de/LC_MESSAGES/django.po 10 | exclude config/settings/development.py 11 | exclude config/settings/dev.py 12 | 13 | recursive-include papermerge/contrib/admin/management/commands/thumbify/ * 14 | recursive-include papermerge/contrib/admin/templates/ * 15 | recursive-include papermerge/contrib/admin/static/admin/ * 16 | recursive-include papermerge/core/templates/ * 17 | 18 | recursive-exclude docs/ * 19 | recursive-exclude screenshots/ * 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Papermerge DMS

2 | 3 |

4 | 5 |

6 | 7 | Papermerge DMS or simply Papermerge is a open source document management 8 | system designed to work with scanned documents (also called digital 9 | archives). It extracts text from your scans using OCR, indexes them, and 10 | prepares them for full text search. Papermerge provides look and feel of 11 | modern desktop file browsers. It has features like dual panel document 12 | browser, drag and drop, tags, hierarchical folders and full text search so 13 | that you can efficiently store and organize your documents. 14 | 15 | It supports PDF, TIFF, JPEG and PNG document file formats. Papermerge is 16 | perfect tool for long term storage of your documents. 17 | 18 | Papermerge's main use case is **long term storage of digital archives**. 19 | 20 | This is web-based software. This means there is no executable file (aka no 21 | .exe files), and it must be run on a web server and accessed through a web 22 | browser. 23 | 24 | ![Papermerge](./artwork/papermerge3-3.png) 25 | 26 | 27 | ## Repositories 28 | 29 | **This is meta-repository** - which means that source code of the 30 | application is not here. **This repository is used to track project's existence, 31 | status and its issues.** 32 | 33 | As the application grew it was necessary to split it 34 | into multiple repositories and in same time move new repositories under 35 | [Papermerge Github Organization](https://github.com/papermerge). 36 | 37 | | Repository | Description | 38 | | :---------------|-------------| 39 | | [ciur/papermerge](https://github.com/ciur/papermerge)| Meta-repository which keeps track the project existence, status, and its issues.| 40 | | [papermerge/papermerge-core](https://github.com/papermerge/papermerge-core)| Source code for REST API Backend server. The heart of the project.| 41 | | [papermerge/documentation](https://github.com/papermerge/documentation)| Source code for the documentation.| 42 | | [papermerge/ansible](https://github.com/papermerge/ansible)| Ansible playbook for deployment on remote server/VM| 43 | 44 | ## Other Resources 45 | 46 | | Resource | Description | 47 | |-----------------|-------------| 48 | |[https://docs.papermerge.io](https://docs.papermerge.io/)| Documentation | 49 | |[https://papermerge.com](https://papermerge.com) | Homepage | 50 | |[https://demo.papermerge.com](https://demo.papermerge.com) | Online Demo (username: demo, password: demo) | 51 | |[https://papermerge.blog](https://papermerge.blog) | Blog | 52 | |[YouTube Channel](https://www.youtube.com/@papermerge) | YouTube channel | 53 | |[X/Former Twitter](https://twitter.com/papermerge) | X/Former Twitter | 54 | |[Reddit](https://www.reddit.com/r/Papermerge/) | Reddit | 55 | 56 | ## Features Highlight 57 | 58 | * Web UI with desktop like experience 59 | * OpenAPI compliant REST API 60 | * Works with PDF, JPEG, PNG and TIFF documents 61 | * OCR (Optical Character Recognition) of the documents 62 | * OCRed text overlay (you can download document with OCRed text overlay) 63 | * Full Text Search of the scanned documents 64 | * Document Versioning 65 | * Tags - assign colored tags to documents or folders 66 | * Documents and Folders - users can organize documents in folders 67 | * Document Types (i.e. Categories) 68 | * Custom Fields (metadata) per document type 69 | * Multi-User 70 | * Page Management - delete, reorder, cut, move, extract pages 71 | 72 | ## Donations, Fundraising, Your Support 73 | 74 | For donations, you can use PayPal and GitHub Sponsorship: 75 | 76 | * [Donate via Paypal](https://www.paypal.com/paypalme/eugenciur) 77 | * [Sponsor via Github](https://github.com/sponsors/ciur) 78 | 79 | ## Roadmap / Year 2025 80 | 81 | For project's roadmap, which describes features to be developed in year 2025, check: https://docs.papermerge.io/3.3/roadmap/ 82 | 83 | ## Contributing 84 | 85 | We welcome contributions! In general, if change is very small, like fixing a 86 | documentation typo, remove unused variable or minor adjustments of docker 87 | related files - you can create a pull request right away. If your change is 88 | small and reasonable it will be (very likely) almost immediately accepted. 89 | 90 | For bigger changes, like a new feature or even change/add/remove of 91 | whole paragraph in documentation - please **first discuss the 92 | change** you wish to make via GitHub issue, pull request or [email](mailto:eugen@papermerge.com). 93 | 94 | For more information, see the 95 | [contributing](https://github.com/ciur/papermerge/blob/master/CONTRIBUTING.md) 96 | file. 97 | -------------------------------------------------------------------------------- /artwork/favicon/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/artwork/favicon/32.png -------------------------------------------------------------------------------- /artwork/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/artwork/favicon/favicon.ico -------------------------------------------------------------------------------- /artwork/favicon/logo_for_twitter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/artwork/favicon/logo_for_twitter.png -------------------------------------------------------------------------------- /artwork/logo-w160px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/artwork/logo-w160px.png -------------------------------------------------------------------------------- /artwork/papermerge3-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/artwork/papermerge3-3.png -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 4 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 5 | 6 | ## [3.0.1] - 2024-01-13 7 | 8 | ### Fixed 9 | 10 | - Django ORM leaves DB connections open [Issue#575](https://github.com/ciur/papermerge/issues/575) 11 | - Add extra language codes [Issue#571](https://github.com/ciur/papermerge/issues/571) 12 | - Create user/home/folder in one DB transaction [Issue#572](https://github.com/ciur/papermerge/issues/572) 13 | 14 | ## [2.0.1] - 9 April 2021 15 | 16 | ### Changed 17 | 18 | - Django dependency version bumped from 3.1.7 to 3.2 19 | 20 | 21 | ## [2.0.0] - 31 March 2021 22 | 23 | ### Changed 24 | 25 | - Issue #354 fixed - scroll not working on search result page 26 | - Issue #349 fixed - pagination for pinned tags does not work 27 | - Issue #350 fixed - Umlauts don't work 28 | - Issue #339 fixed - IMAP import from gmail isn't working 29 | - Issue #338 fixed - Reflected Cross-Site Scripting (XSS) in Upload Error Messages 30 | 31 | 32 | ## [2.0.0rc48] - 12 March 2021 33 | 34 | ### Changed 35 | 36 | - Issue #281 fixed - IMPORT_MAIL_DELETE not working 37 | - Issue #233 fixed - IMAP Consumption - Mails imported multiple times and not marked as read 38 | - Automated docker images build & push 39 | 40 | 41 | ## [2.0.0rc45] - 5 March 2021 42 | 43 | - Fixes version issue (documents created by paste opreration should have version reset to 0) 44 | 45 | 46 | ## [2.0.0.rc38] - 28 February 2021 47 | 48 | ### Changed 49 | 50 | - Issue #311 - Fixes upgrade problems (migrations conflicts) 51 | - Issue #314 - Cross-Site Scripting (XSS) in Automation Tags 52 | - Issue #315 - Bug leads to multiple folder creation 53 | - Issue #316 - Cross-Site Scripting (XSS) in Permission Management 54 | 55 | 56 | ## [2.0.0.rc35] - 25 February 2021 57 | 58 | ### Added 59 | 60 | - Apps support (document parts/satellites architecture) 61 | - UI preferences - user can set timezone, date, time format from UI: user -> preferences 62 | - UI design improvements - widgets sidebar 63 | - UX improvement - action button was replaced with (desktop like) context menu 64 | - UX improvement - Desktop like selection 65 | - Import Pipeline - flexible import framework - thanks to [Francesco](https://github.com/francescocarzaniga) 66 | - Email inbox enhancement (IMPORT_MAIL_BY_USER) - thanks to [Francesco](https://github.com/francescocarzaniga) 67 | - no more pdftk dependency. For pdf operations will use [stapler](https://github.com/hellerbarde/stapler/) instead. 68 | - Roles - quicker way to assign set of permissions for a given user 69 | - Document versions (all changes on the document are non-distructively saved) 70 | - User can manually re-run automates 71 | - User can manually re-start OCR for selected documents 72 | - In document viewer user can see OCRed page text 73 | 74 | 75 | [1.5.5] - 27 December 2020 76 | 77 | ### Added 78 | 79 | - Fixes #271 - XSS on tag's description field. 80 | - No more failing test in ./run_tests.sh see #272 for details 81 | - Fixes #198 - docker compose configuration uses redis for message brocker 82 | 83 | 84 | ### [1.5.4] - 23 December 2020 85 | 86 | ### Added 87 | 88 | - Fix for #261 - docker-compose sample doesn't set-up fully 89 | - Fix for #241 - App Docker Tag 1.5.3 seems to be the worker? 90 | - Fix for #237 - Cannot rename folder 91 | - Fix for #239 - Adjusting size of sign-in button 92 | - Fix for #243 - Automates Screen max items 93 | - Fix for #257 - Using browser back/foward navigation causes json strings to be displayed instead of documents. 94 | 95 | 96 | ## [1.5.3] - 1 December 2020 97 | 98 | ### Added 99 | 100 | - Fix for #237 - Renaming files problem. 101 | 102 | ## [1.5.2] - 30 November 2020 103 | 104 | This is security release. 105 | 106 | ### Added 107 | 108 | - Extra fixes for #228 - Stored Cross-Site Scripting (XSS). Thanks to [@l4rm4nd](https://github.com/l4rm4nd) for security audit. 109 | 110 | ## [1.5.1] - 29 November 2020 111 | 112 | ### Added 113 | 114 | - Fixes #204 - Groups are not created 115 | - Fixes #206 - Anonymous user access to breadcrumb results in 500 116 | - Fixes #208 - Tags unique per user (different users can have tags with same name) 117 | - Fixes #202 - Automates created for all users 118 | - Fixes #185 - inbox folder is only selectable at automate creation 119 | - Fixes #196 - Allow Automates to only apply tags or move to folder 120 | - Fixes #228 - Stored Cross-Site Scripting (XSS) 121 | 122 | 123 | ## [1.5.0] - 14 October 2020 124 | 125 | ## [1.5.0.rc1] - 11 October 2020 126 | 127 | ### Added 128 | 129 | - Additional languages included in default configuration of official docker image. 130 | - MySQL Support. Fixes #76. 131 | - Fixes #150 (Directory Navigation) 132 | - Download multiple documents and folders. Fixes #84. 133 | - Auto-refresh on upload fixes #126 134 | - add "--include-user-password" switch to backup scripts 135 | - include tags into backups 136 | - Tag management (colored tags) 137 | - Pinnable tags 138 | - Better selection. Selection All/Folders/Documents/Invert Selection/Deselect menu. 139 | - Extra check (./manage.py check) for IMAP credentials. In case IMAP settings are not correct, ./manage.py check will issue a warning message. Also imap import will complain if IMAP credentials are incorrect. Extra detailed debugging messages for IMAP import. 140 | - Tags per Automate (matching docs will be assigned automate's tags) 141 | 142 | ### Changed 143 | 144 | - Upgrade to django 3.0.10 145 | - Dynamic preferences upgraded to latest 1.10.1 version 146 | - Fixes issue #120 REST API fails when uploading a document 147 | - Issue #114 Worker container use environment variables for DB 148 | - For backup/restore scripts --user argument is optional. Without --user argument backup command will backup all users' documents. 149 | - Fixes issue #118 - Email Import does not reach INBOX. 150 | 151 | ### Removed 152 | 153 | - Metadata plugins as Python modules. In future will be replaced with yml templates. 154 | 155 | 156 | ## [1.4.4] - 28 September 2020 157 | 158 | ### Changed 159 | 160 | - Fixes issue #118 - Email Import does not reach INBOX. 161 | - 3rd party apps/plugins can extend user menu 162 | 163 | 164 | ## [1.4.3] - 16 September 2020 165 | 166 | ### Changed 167 | 168 | - Fixes issue #120 REST API fails when uploading a document 169 | - Issue #114 Worker container use environment variables for DB 170 | - For backup/restore scripts --user argument is optional. Without --user argument backup command will backup all users' documents. 171 | - Extra check (./manage.py check) for IMAP credentials. In case IMAP settings are not correct, ./manage.py check will issue a warning message. Also imap import will complain if IMAP credentials are incorrect. Extra detailed debugging messages for IMAP import. 172 | 173 | ## [1.4.2] - 6 September 2020 174 | 175 | ### Added 176 | 177 | - UI logs. A mini-feature. It helps user to troubleshoot/get feedback from not 178 | directly visible processes like running automates or background OCRing of 179 | the docs 180 | - Automates match by txt not by hocr file 181 | - Clipboard bugfixes 182 | - Display current version at the lower/right bottom 183 | - Bug fixes 184 | 185 | 186 | ## [1.4.1] - 29 August 2020 187 | 188 | ### Removed 189 | 190 | - startetc command was removed. 191 | 192 | ### Added 193 | 194 | - Optimizations/performance issues - browsing folder with many files (> 200) 195 | was improved significantly (5x). Also, /browse/ request time will not linearly grow with increased number of files. 196 | 197 | ### Changed 198 | 199 | - Do not rise exception if preview image was not found. Return a generic image instead. 200 | - Fix: [issue #86](https://github.com/ciur/papermerge/issues/86) - UI uploader - confusing red color/warning during upload 201 | - Enhancement: in case of uploading unsupported format - a descriptive error message will be displayed in uploader 202 | - Documentation updates (especially [bare metal installation](https://papermerge.readthedocs.io/en/latest/setup/manual_way.html) + [server configuration](https://papermerge.readthedocs.io/en/latest/setup/server_configurations.html)) 203 | 204 | 205 | ## [1.4.0] - 19 August 2020 206 | 207 | ### Changed 208 | 209 | - Issue #72 - random changed order 210 | - Issue #63 - hardcoded OCR_BINARY settings 211 | - documentation updates 212 | 213 | ## [1.4.0.rc1] - 4 August 2020 214 | 215 | ### Added 216 | 217 | - Automation (of metadata extraction, moving doc to a specific folder, extract page) 218 | - AdminLTE3/Bootstrap based own UI 219 | - Backup/Restore feature. Feature implemented by [@frenos](https://github.com/frenos). 220 | - Added support for JPEG, PNG, TIFF images 221 | 222 | ### Changed 223 | 224 | - Metadata details are now displayed/edited on specialized right side panel (instead of modals) 225 | 226 | ### Removed 227 | 228 | - Customized Django Admin app named boss. Thus, UI is no longer Django Admin based. 229 | 230 | 231 | 232 | ## [1.3.0] - 25 June 2020 233 | 234 | ### Added 235 | 236 | - Metadata (per Folder/Document/Page) 237 | - Built-in worker (./manage.py worker - command) 238 | 239 | ### Changed 240 | 241 | 242 | - SQLite is default database (Postresql is now optional, available via Plugin) 243 | - Support for OCR on all languages 244 | - Refactoring: all static assets moved into boss/static directory. This change simplifies initial project setup (no need to clone yet another repo) 245 | - Read configurations from /etc/papermerge.conf.py or ./papermerge.conf.py 246 | - Refactoring: endpoint extracted from pmworker into mglib.path.DocumentPath and mglib.path.PagePath 247 | - Save last sorting mode in file browser - via save_last_sort cookie 248 | 249 | 250 | ## [1.2.0] - 10 Apr 2020 251 | 252 | ### Added 253 | 254 | - Delete pages 255 | - Reorder pages within the document 256 | - Cut/Paste document from one document into another 257 | - Paste pages into new document instance 258 | 259 | ### Changed 260 | 261 | - [Documentation](https://papermerge.readthedocs.io/en/v1.2.0/page_management.html) - updated to include Page Management feature description 262 | 263 | ## [1.1.0] - 14 Feb 2020 264 | 265 | ### Added 266 | 267 | - REST API support 268 | - Creation of multiple authentication tokens per user 269 | - Endpoint /api/document/upload for uploading documents 270 | - Rest API [feature demo ](https://www.youtube.com/watch?v=OePTvPcnoMw) 271 | 272 | ### Changed 273 | 274 | - [Documentation](https://papermerge.readthedocs.io/en/v1.1.0/rest_api.html) - updated to include REST API description 275 | 276 | ## [1.0.0] - 7 Feb 2020 277 | 278 | Open sourced version is more or less stable. 279 | 280 | ## [0.5.0] - 6 Jan 2020 281 | 282 | Project open sourced (also with lots of refactoring) 283 | 284 | ## [0.0.1] - 10 Sept 2017 285 | 286 | Initial commit. Project started as free time pet project / proof of concept. 287 | It was named vermilion, digilette and only later papermerge. 288 | -------------------------------------------------------------------------------- /config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/config/__init__.py -------------------------------------------------------------------------------- /config/asgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from channels.auth import AuthMiddlewareStack 4 | from channels.routing import ProtocolTypeRouter, URLRouter 5 | from django.core.asgi import get_asgi_application 6 | import papermerge.notifications.routing 7 | 8 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev') 9 | 10 | application = ProtocolTypeRouter({ 11 | "http": get_asgi_application(), 12 | "websocket": AuthMiddlewareStack( 13 | URLRouter( 14 | papermerge.notifications.routing.websocket_urlpatterns 15 | ) 16 | ), 17 | # Just HTTP for now. (We can add other protocols later.) 18 | }) 19 | -------------------------------------------------------------------------------- /config/celery.py: -------------------------------------------------------------------------------- 1 | import os 2 | from celery import Celery 3 | 4 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.development') 5 | 6 | app = Celery('pm_celery') 7 | 8 | # Using a string here means the worker doesn't have to serialize 9 | # the configuration object to child processes. 10 | # - namespace='CELERY' means all celery-related configuration keys 11 | # should have a `CELERY_` prefix. 12 | app.config_from_object('django.conf:settings', namespace='CELERY') 13 | 14 | # Load task modules from all registered Django app configs. 15 | app.autodiscover_tasks() 16 | 17 | # Workaround celery's bug: 18 | # https://github.com/celery/celery/issues/4296 19 | # Without this options, if broker is down, the celery 20 | # will loop forever in apply_async. 21 | app.conf.broker_transport_options = { 22 | 'max_retries': 3, 23 | 'interval_start': 0, 24 | 'interval_step': 0.2, 25 | 'interval_max': 0.2, 26 | } 27 | -------------------------------------------------------------------------------- /config/settings/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | # This will make sure the app is always imported when 4 | # Django starts so that shared_task will use this app. 5 | from ..celery import app as celery_app 6 | 7 | __all__ = ['celery_app'] 8 | -------------------------------------------------------------------------------- /config/settings/dev.py: -------------------------------------------------------------------------------- 1 | from .base import * # noqa 2 | 3 | DEBUG = True 4 | 5 | INSTALLED_APPS.extend( 6 | ['django_extensions'] 7 | ) 8 | 9 | INTERNAL_IPS = ['127.0.0.1', ] 10 | 11 | CORS_ALLOW_ALL_ORIGINS = True 12 | 13 | 14 | LOGGING = { 15 | 'version': 1, 16 | 'disable_existing_loggers': False, 17 | 'handlers': { 18 | 'papermerge': { 19 | 'class': 'logging.FileHandler', 20 | 'filename': 'papermerge.log', 21 | 'level': 'DEBUG' 22 | }, 23 | }, 24 | 'loggers': { 25 | 'papermerge': { 26 | 'handlers': ['papermerge'], 27 | 'level': 'DEBUG' 28 | }, 29 | }, 30 | } 31 | 32 | PAPERMERGE_DEFAULT_FILE_STORAGE = "papermerge.storage.S3Storage" 33 | PAPERMERGE_FILE_STORAGE_KWARGS = { 34 | 'bucketname': 'dev-papermerge', 35 | 'namespace': 'demo' 36 | } 37 | -------------------------------------------------------------------------------- /config/settings/development.example.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | DEBUG = True 4 | 5 | -------------------------------------------------------------------------------- /config/settings/devtmp.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import os 3 | from pathlib import Path 4 | from .base import * 5 | 6 | DEBUG = True 7 | UNIT_TESTS = False 8 | # debug variable in templates is available only if INTERNAL_IPS are set 9 | # to a not empty list 10 | INTERNAL_IPS = ['127.0.0.1', ] 11 | 12 | SITE_ID = 1 13 | 14 | SECRET_KEY = os.environ['SECRET_KEY'] 15 | MEDIA_ROOT = os.environ['MEDIA_ROOT'] 16 | STORAGE_ROOT = os.environ['STORAGE_ROOT'] 17 | 18 | CELERY_BROKER_URL = "filesystem://" 19 | CELERY_BROKER_TRANSPORT_OPTIONS = { 20 | 'data_folder_in': '', 21 | 'data_folder_out': '', 22 | 'data_folder_processed': '' 23 | } 24 | # write email messages in development mode to email spec by 25 | # EMAIL_FILE_PATH 26 | EMAIL_FILE_PATH = "" 27 | EMAIL_BACKEND = '' 28 | 29 | DATABASES = { 30 | 'default': { 31 | 'ENGINE': 'django.db.backends.postgresql', 32 | 'NAME': os.environ['DB_NAME'], 33 | 'USER': os.environ['DB_USER'], 34 | 'PASSWORD': os.environ['DB_PASS'], 35 | 'HOST': os.environ['DB_HOST'], 36 | 'PORT': os.environ['DB_PORT'], 37 | }, 38 | 'maildb': { 39 | } 40 | } 41 | 42 | #LOGGING = { 43 | # 'version': 1, 44 | # 'disable_existing_loggers': False, 45 | # 'handlers': { 46 | # 'console': { 47 | # 'class': 'logging.StreamHandler', 48 | # } 49 | # }, 50 | # 'loggers': { 51 | # 'django.db.backends': { 52 | # 'handlers': ['console'], 53 | # 'level': 'DEBUG' 54 | # }, 55 | # 56 | # }, 57 | #} 58 | 59 | STATICFILES_DIRS = [ 60 | '/home/eugen/github/papermerge-js/static' 61 | ] 62 | -------------------------------------------------------------------------------- /config/settings/test.py: -------------------------------------------------------------------------------- 1 | import os 2 | from os.path import expanduser 3 | 4 | from .base import * 5 | 6 | DEBUG = True 7 | 8 | SITE_ID = 1 9 | 10 | # Special folders are those starting with DOT character. 11 | # For example: .inbox, .trash 12 | PAPERMERGE_CREATE_SPECIAL_FOLDERS = False 13 | 14 | DATABASE_ROUTERS = [] 15 | 16 | INSTALLED_APPS = ( 17 | 'rest_framework', 18 | 'knox', 19 | 'django.contrib.contenttypes', 20 | 'django.contrib.auth', 21 | 'django.contrib.sites', 22 | 'django.contrib.sessions', 23 | 'django.contrib.messages', 24 | 'django.contrib.staticfiles', 25 | 'papermerge.core', 26 | 'papermerge.contrib.admin', 27 | 'papermerge.test', 28 | 'allauth', 29 | 'allauth.account', 30 | 'allauth.socialaccount', 31 | 'dynamic_preferences', 32 | # comment the following line if you don't want to use user preferences 33 | 'dynamic_preferences.users.apps.UserPreferencesConfig', 34 | 'polymorphic_tree', 35 | 'polymorphic', 36 | 'mptt', 37 | 'mgclipboard', 38 | 'bootstrap4', 39 | 'papermerge.test.parts.app_0', # absolute minimum app 40 | 'papermerge.test.parts.app_dr', # data retention app 41 | 'papermerge.test.parts.app_max_p', 42 | ) 43 | 44 | MIDDLEWARE = [ 45 | 'django.contrib.sessions.middleware.SessionMiddleware', 46 | 'django.middleware.common.CommonMiddleware', 47 | 'django.middleware.csrf.CsrfViewMiddleware', 48 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 49 | 'mgclipboard.middleware.ClipboardMiddleware', 50 | 'django.contrib.messages.middleware.MessageMiddleware', 51 | 'papermerge.contrib.admin.middleware.TimezoneMiddleware' 52 | ] 53 | 54 | AUTHENTICATION_BACKENDS = ( 55 | 'papermerge.test.auth_backends.TestcaseUserBackend', 56 | 'papermerge.core.auth.NodeAuthBackend', 57 | 'allauth.account.auth_backends.AuthenticationBackend' 58 | ) 59 | 60 | REST_FRAMEWORK = { 61 | 'DEFAULT_AUTHENTICATION_CLASSES': [ 62 | 'knox.auth.TokenAuthentication', 63 | 'rest_framework.authentication.SessionAuthentication', 64 | ] 65 | } 66 | 67 | REST_KNOX = { 68 | 'AUTH_TOKEN_CHARACTER_LENGTH': 32, 69 | 'SECURE_HASH_ALGORITHM': 'cryptography.hazmat.primitives.hashes.SHA512', 70 | } 71 | 72 | 73 | CELERY_BROKER_URL = "memory://" 74 | 75 | # The default password hasher is rather slow by design. 76 | # If you’re authenticating many users in your tests, 77 | # you may want to use a custom settings file and set 78 | # the PASSWORD_HASHERS setting to a faster hashing algorithm: 79 | PASSWORD_HASHERS = [ 80 | 'django.contrib.auth.hashers.MD5PasswordHasher', 81 | ] 82 | 83 | CACHES = { 84 | 'default': { 85 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 86 | } 87 | } 88 | LOGGING = { 89 | 'version': 1, 90 | 'disable_existing_loggers': True, 91 | 'handlers': { 92 | 'console': { 93 | 'class': 'logging.StreamHandler', 94 | }, 95 | }, 96 | 'root': { 97 | 'handlers': ['console'], 98 | 'level': 'CRITICAL' 99 | }, 100 | } 101 | 102 | MEDIA_ROOT = os.path.join( 103 | PROJ_ROOT, 104 | "papermerge", 105 | "test", 106 | "media" 107 | ) 108 | 109 | CACHES = { 110 | 'default': { 111 | 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', 112 | } 113 | } 114 | 115 | # guess where BINARY_STAPLER is located 116 | if not BINARY_STAPLER: # if BINARY_STAPLER was not set in papermerge.conf.py 117 | try: # maybe it is in virtual environment? 118 | BINARY_STAPLER = f"{os.environ['VIRTUAL_ENV']}/bin/stapler" 119 | except Exception: 120 | # crude guess 121 | home_dir = expanduser('~') 122 | BINARY_STAPLER = f"{home_dir}/.local/bin/stapler" 123 | -------------------------------------------------------------------------------- /config/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, re_path 2 | from django.views.i18n import JavaScriptCatalog 3 | 4 | from django.conf.urls import include 5 | 6 | from django.conf import settings 7 | 8 | js_info_dict = { 9 | 'domain': 'django', 10 | 'packages': None, 11 | } 12 | 13 | 14 | urlpatterns = [ 15 | path('api/', include('papermerge.core.urls')), 16 | ] 17 | 18 | for extra_urls in settings.EXTRA_URLCONF: 19 | urlpatterns.append( 20 | path('', include(extra_urls)), 21 | ) 22 | -------------------------------------------------------------------------------- /config/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.wsgi import get_wsgi_application 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.dev") 6 | 7 | application = get_wsgi_application() 8 | -------------------------------------------------------------------------------- /docker/app.dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:20.04 2 | 3 | LABEL maintainer="Eugen Ciur " 4 | 5 | # 6 | # Builds Papermerge APP docker image based on latest release. 7 | # Latest release is given by following URL: 8 | # https://api.github.com/repos/ciur/papermerge/releases/latest 9 | # 10 | 11 | ARG DEBIAN_FRONTEND=noninteractive 12 | 13 | RUN apt-get update \ 14 | && apt-get install -y \ 15 | build-essential \ 16 | vim \ 17 | python3 \ 18 | python3-pip \ 19 | python3-venv \ 20 | virtualenv \ 21 | poppler-utils \ 22 | git \ 23 | imagemagick \ 24 | apache2 \ 25 | apache2-dev \ 26 | locales \ 27 | && rm -rf /var/lib/apt/lists/* \ 28 | && pip3 install --upgrade pip 29 | 30 | RUN groupadd -g 1002 www 31 | RUN useradd -g www -s /bin/bash --uid 1001 -d /opt/app www 32 | 33 | # ensures our console output looks familiar and is not buffered by Docker 34 | ENV PYTHONUNBUFFERED 1 35 | 36 | ENV DJANGO_SETTINGS_MODULE config.settings.production 37 | ENV PATH=/opt/app:/opt/app/.local/bin:$PATH 38 | RUN echo 'en_US.UTF-8 UTF-8' >> /etc/locale.gen && locale-gen 39 | ENV LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 40 | 41 | RUN mkdir -p /opt/app && \ 42 | if [ -z ${PAPERMERGE_RELEASE+x} ]; then \ 43 | PAPERMERGE_RELEASE=$(curl -sX GET "https://api.github.com/repos/ciur/papermerge/releases/latest" \ 44 | | awk '/tag_name/{print $4;exit}' FS='[""]'); \ 45 | fi && \ 46 | curl -o \ 47 | /tmp/papermerge.tar.gz -L \ 48 | "https://github.com/ciur/papermerge/archive/${PAPERMERGE_RELEASE}.tar.gz" && \ 49 | tar xf \ 50 | /tmp/papermerge.tar.gz -C \ 51 | /opt/app/ --strip-components=1 52 | 53 | RUN mkdir -p /opt/media && mkdir -p /opt/etc && mkdir -p /opt/defaults 54 | 55 | COPY config/app.production.py /opt/defaults/production.py 56 | COPY config/papermerge.config.py /opt/defaults/papermerge.conf.py 57 | COPY app.startup.sh /opt/app/startup.sh 58 | 59 | RUN chmod +x /opt/app/startup.sh 60 | COPY scripts/create_user.py /opt/app/create_user.py 61 | 62 | RUN chown -R www:www /opt/ 63 | 64 | WORKDIR /opt/app 65 | USER www 66 | 67 | ENV VIRTUAL_ENV=/opt/app/.venv 68 | RUN cd /opt/app 69 | RUN virtualenv $VIRTUAL_ENV -p /usr/bin/python3.8 70 | 71 | ENV PATH="$VIRTUAL_ENV/bin:$PATH" 72 | ENV DJANGO_SETTINGS_MODULE=config.settings.production 73 | 74 | RUN pip3 install django==3.1.7 75 | RUN pip3 install -r requirements/base.txt --no-cache-dir 76 | RUN pip3 install -r requirements/production.txt --no-cache-dir 77 | RUN pip3 install -r requirements/extra/pg.txt --no-cache-dir 78 | 79 | CMD ["/opt/app/startup.sh"] -------------------------------------------------------------------------------- /docker/app.startup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ ! -f "/opt/etc/production.py" ]; then 4 | cp /opt/defaults/production.py /opt/etc/production.py 5 | fi 6 | 7 | if [ ! -f "/opt/etc/papermerge.conf.py" ]; then 8 | cp /opt/defaults/papermerge.conf.py /opt/etc/papermerge.conf.py 9 | fi 10 | 11 | ln -sf /opt/etc/production.py /opt/app/config/settings/production.py 12 | ln -sf /opt/etc/papermerge.conf.py /opt/app/papermerge.conf.py 13 | 14 | ./manage.py makemigrations 15 | ./manage.py migrate 16 | cat create_user.py | python3 manage.py shell 17 | 18 | ./manage.py collectstatic --no-input 19 | ./manage.py check 20 | 21 | mod_wsgi-express start-server \ 22 | --server-root /opt/app/\ 23 | --url-alias /static /opt/static \ 24 | --url-alias /media /opt/media \ 25 | --port 8000 --user www --group www \ 26 | --log-to-terminal \ 27 | --limit-request-body 20971520 \ 28 | config/wsgi.py 29 | 30 | # --limit-request-body 20971520 options is to limit 31 | # max upload size to 20 MB 32 | -------------------------------------------------------------------------------- /docker/config/app.production.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from .base import * # noqa 4 | 5 | 6 | DEBUG = False 7 | # debug variable in templates is available only if INTERNAL_IPS are set 8 | # to a not empty list 9 | INTERNAL_IPS = [ 10 | '127.0.0.1', 11 | ] 12 | 13 | INSTALLED_APPS.extend( 14 | ['mod_wsgi.server', ] 15 | ) 16 | 17 | ALLOWED_HOSTS = ['*'] 18 | 19 | CELERY_BROKER_URL = "redis://redis/0" 20 | CELERY_BROKER_TRANSPORT_OPTIONS = {} 21 | CELERY_RESULT_BACKEND = "redis://redis/0" 22 | 23 | DATABASES = { 24 | 'default': { 25 | 'ENGINE': 'django.db.backends.postgresql', 26 | 'NAME': os.environ.get('POSTGRES_DB', 'dbname'), 27 | 'USER': os.environ.get('POSTGRES_USER', 'dbuser'), 28 | 'PASSWORD': os.environ.get('POSTGRES_PASSWORD', 'dbpass'), 29 | 'HOST': os.environ.get('POSTGRES_HOST', 'db'), 30 | 'PORT': os.environ.get('POSTGRES_PORT', 5432), 31 | }, 32 | } 33 | 34 | LOGGING = { 35 | 'version': 1, 36 | 'disable_existing_loggers': False, 37 | 'handlers': { 38 | 'file_worker': { 39 | 'class': 'logging.FileHandler', 40 | 'filename': 'worker.log', 41 | }, 42 | 'file_app': { 43 | 'class': 'logging.FileHandler', 44 | 'filename': 'app.log', 45 | }, 46 | }, 47 | 'loggers': { 48 | 'mglib': { 49 | 'handlers': ['file_app'], 50 | 'level': 'DEBUG' 51 | }, 52 | 'papermerge': { 53 | 'handlers': ['file_app'], 54 | 'level': 'DEBUG' 55 | }, 56 | 'celery': { 57 | 'handlers': ['file_worker'], 58 | 'level': 'INFO' 59 | }, 60 | }, 61 | } 62 | -------------------------------------------------------------------------------- /docker/config/papermerge.config.py: -------------------------------------------------------------------------------- 1 | BINARY_STAPLER = "/opt/app/.venv/bin/stapler" 2 | 3 | DBUSER = "dbuser" 4 | DBPASS = "dbpass" 5 | DBHOST = "db" 6 | DBNAME = "dbname" 7 | 8 | MEDIA_DIR = "/opt/media" 9 | STATIC_DIR = "/opt/static" 10 | MEDIA_URL = "/media/" 11 | STATIC_URL = "/static/" 12 | 13 | OCR_DEFAULT_LANGUAGE = "deu" 14 | 15 | OCR_LANGUAGES = { 16 | "deu": "Deutsch", 17 | "spa": "Español", 18 | "eng": "English", 19 | "fra": "Français", 20 | "rus": "Русский", 21 | "ron": "Română" 22 | } 23 | -------------------------------------------------------------------------------- /docker/config/worker.production.py: -------------------------------------------------------------------------------- 1 | import os 2 | from .base import * # noqa 3 | 4 | DEBUG = False 5 | # debug variable in templates is available only if INTERNAL_IPS are set 6 | # to a not empty list 7 | INTERNAL_IPS = [ 8 | '127.0.0.1', 9 | ] 10 | 11 | ALLOWED_HOSTS = ['*'] 12 | 13 | 14 | CELERY_BROKER_URL = "redis://redis/0" 15 | CELERY_BROKER_TRANSPORT_OPTIONS = {} 16 | CELERY_RESULT_BACKEND = "redis://redis/0" 17 | 18 | DATABASES = { 19 | 'default': { 20 | 'ENGINE': 'django.db.backends.postgresql', 21 | 'NAME': os.environ.get('POSTGRES_DB', 'dbname'), 22 | 'USER': os.environ.get('POSTGRES_USER', 'dbuser'), 23 | 'PASSWORD': os.environ.get('POSTGRES_PASSWORD', 'dbpass'), 24 | 'HOST': os.environ.get('POSTGRES_HOST', 'db'), 25 | 'PORT': os.environ.get('POSTGRES_PORT', 5432), 26 | }, 27 | } 28 | 29 | LOGGING = { 30 | 'version': 1, 31 | 'disable_existing_loggers': False, 32 | 'handlers': { 33 | 'file_worker': { 34 | 'class': 'logging.FileHandler', 35 | 'filename': 'worker.log', 36 | }, 37 | 'file_app': { 38 | 'class': 'logging.FileHandler', 39 | 'filename': 'app.log', 40 | }, 41 | }, 42 | 'loggers': { 43 | 'mglib': { 44 | 'handlers': ['file_app'], 45 | 'level': 'DEBUG' 46 | }, 47 | 'papermerge': { 48 | 'handlers': ['file_app'], 49 | 'level': 'DEBUG' 50 | }, 51 | 'celery': { 52 | 'handlers': ['file_worker'], 53 | 'level': 'INFO' 54 | }, 55 | }, 56 | } 57 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | app: 4 | image: eugenci/papermerge:2.0.0 5 | container_name: papermerge_app 6 | ports: 7 | - "8000:8000" 8 | depends_on: 9 | - db 10 | - redis 11 | volumes: 12 | - media_root:/opt/media 13 | environment: 14 | - DJANGO_SETTINGS_MODULE=config.settings.production 15 | - POSTGRES_USER=dbuser 16 | - POSTGRES_PASSWORD=dbpass 17 | - POSTGRES_DB=dbname 18 | - POSTGRES_HOST=db 19 | - POSTGRES_PORT=5432 20 | db: 21 | image: postgres:12.3 22 | container_name: postgres_db 23 | volumes: 24 | - postgres_data7:/var/lib/postgresql/data/ 25 | environment: 26 | - POSTGRES_USER=dbuser 27 | - POSTGRES_PASSWORD=dbpass 28 | - POSTGRES_DB=dbname 29 | redis: 30 | container_name: 'redis' 31 | image: 'redis:6' 32 | ports: 33 | - '127.0.0.1:6379:6379' 34 | volumes: 35 | - 'redisdata:/data' 36 | worker: 37 | image: eugenci/papermerge-worker:v2.0.0 38 | container_name: papermerge_worker 39 | volumes: 40 | - media_root:/opt/media 41 | environment: 42 | - DJANGO_SETTINGS_MODULE=config.settings.production 43 | - POSTGRES_USER=dbuser 44 | - POSTGRES_PASSWORD=dbpass 45 | - POSTGRES_DB=dbname 46 | - POSTGRES_HOST=db 47 | - POSTGRES_PORT=5432 48 | volumes: 49 | postgres_data7: 50 | media_root: 51 | redisdata: 52 | -------------------------------------------------------------------------------- /docker/scripts/create_user.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | 3 | # see ref. below 4 | UserModel = get_user_model() 5 | 6 | if not UserModel.objects.filter(username='admin').exists(): 7 | user = UserModel.objects.create_user('admin', password='admin') 8 | user.is_superuser = True 9 | user.is_staff = True 10 | user.save() 11 | -------------------------------------------------------------------------------- /docker/worker.dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:20.04 2 | 3 | LABEL maintainer="Eugen Ciur " 4 | 5 | # 6 | # Builds Papermerge WORKER docker image based on latest release. 7 | # Latest release is given by following URL: 8 | # https://api.github.com/repos/ciur/papermerge/releases/latest 9 | # 10 | 11 | ARG DEBIAN_FRONTEND=noninteractive 12 | 13 | RUN apt-get update \ 14 | && apt-get install -y \ 15 | build-essential \ 16 | vim \ 17 | curl \ 18 | python3 \ 19 | python3-pip \ 20 | python3-venv \ 21 | virtualenv \ 22 | poppler-utils \ 23 | git \ 24 | imagemagick \ 25 | locales \ 26 | tesseract-ocr \ 27 | tesseract-ocr-deu \ 28 | tesseract-ocr-eng \ 29 | tesseract-ocr-fra \ 30 | tesseract-ocr-rus \ 31 | tesseract-ocr-ron \ 32 | tesseract-ocr-spa \ 33 | && rm -rf /var/lib/apt/lists/* \ 34 | && pip3 install --upgrade pip 35 | 36 | RUN groupadd -g 1002 www 37 | RUN useradd -g www -s /bin/bash --uid 1001 -d /opt/app www 38 | 39 | 40 | # ensures our console output looks familiar and is not buffered by Docker 41 | ENV PYTHONUNBUFFERED 1 42 | 43 | ENV DJANGO_SETTINGS_MODULE config.settings.production 44 | ENV PATH=/opt/app/:/opt/app/.local/bin:$PATH 45 | RUN echo 'en_US.UTF-8 UTF-8' >> /etc/locale.gen && locale-gen 46 | ENV LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 47 | 48 | RUN mkdir -p /opt/app && \ 49 | if [ -z ${PAPERMERGE_RELEASE+x} ]; then \ 50 | PAPERMERGE_RELEASE=$(curl -sX GET "https://api.github.com/repos/ciur/papermerge/releases/latest" \ 51 | | awk '/tag_name/{print $4;exit}' FS='[""]'); \ 52 | fi && \ 53 | curl -o \ 54 | /tmp/papermerge.tar.gz -L \ 55 | "https://github.com/ciur/papermerge/archive/${PAPERMERGE_RELEASE}.tar.gz" && \ 56 | tar xf \ 57 | /tmp/papermerge.tar.gz -C \ 58 | /opt/app/ --strip-components=1 59 | 60 | RUN mkdir -p /opt/media && mkdir -p /opt/etc && mkdir -p /opt/defaults 61 | 62 | RUN mkdir -p /opt/media 63 | 64 | COPY config/worker.production.py /opt/defaults/production.py 65 | COPY config/papermerge.config.py /opt/defaults/papermerge.conf.py 66 | COPY worker.startup.sh /opt/app/startup.sh 67 | RUN chmod +x /opt/app/startup.sh 68 | 69 | RUN chown -R www:www /opt/ 70 | 71 | WORKDIR /opt/app 72 | USER www 73 | 74 | ENV VIRTUAL_ENV=/opt/app/.venv 75 | RUN virtualenv $VIRTUAL_ENV -p /usr/bin/python3.8 76 | 77 | ENV PATH="$VIRTUAL_ENV/bin:$PATH" 78 | ENV DJANGO_SETTINGS_MODULE=config.settings.production 79 | 80 | RUN pip3 install django==3.1.7 81 | RUN pip3 install -r requirements/base.txt --no-cache-dir 82 | RUN pip3 install -r requirements/extra/pg.txt --no-cache-dir 83 | 84 | CMD ["/opt/app/startup.sh"] -------------------------------------------------------------------------------- /docker/worker.startup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ ! -f "/opt/etc/production.py" ]; then 4 | cp /opt/defaults/production.py /opt/etc/production.py 5 | fi 6 | 7 | if [ ! -f "/opt/etc/papermerge.conf.py" ]; then 8 | cp /opt/defaults/papermerge.conf.py /opt/etc/papermerge.conf.py 9 | fi 10 | 11 | ln -sf /opt/etc/production.py /opt/app/config/settings/production.py 12 | ln -sf /opt/etc/papermerge.conf.py /opt/app/papermerge.conf.py 13 | 14 | ./manage.py makemigrations 15 | ./manage.py migrate 16 | ./manage.py check 17 | python manage.py worker 18 | -------------------------------------------------------------------------------- /example_data/deutsch/page_management/usecase_1/readme.txt: -------------------------------------------------------------------------------- 1 | Remove blank page and reorder other two. 2 | Result document must have 2 pages (page where is written Rechnung must be first). -------------------------------------------------------------------------------- /example_data/deutsch/page_management/usecase_1/rechnung.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/example_data/deutsch/page_management/usecase_1/rechnung.pdf -------------------------------------------------------------------------------- /example_data/deutsch/page_management/usecase_2/dokument-A.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/example_data/deutsch/page_management/usecase_2/dokument-A.pdf -------------------------------------------------------------------------------- /example_data/deutsch/page_management/usecase_2/dokument-B.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/example_data/deutsch/page_management/usecase_2/dokument-B.pdf -------------------------------------------------------------------------------- /example_data/deutsch/page_management/usecase_2/readme.txt: -------------------------------------------------------------------------------- 1 | Cut/Paste one page from one document to another. -------------------------------------------------------------------------------- /example_data/deutsch/page_management/usecase_3/dokument-2-seiten.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/example_data/deutsch/page_management/usecase_3/dokument-2-seiten.pdf -------------------------------------------------------------------------------- /example_data/deutsch/page_management/usecase_3/readme.txt: -------------------------------------------------------------------------------- 1 | Cut pages and paste them as new document. -------------------------------------------------------------------------------- /example_data/english/page_management/usecase_1/A.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/example_data/english/page_management/usecase_1/A.pdf -------------------------------------------------------------------------------- /example_data/english/page_management/usecase_1/B.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/example_data/english/page_management/usecase_1/B.pdf -------------------------------------------------------------------------------- /example_data/english/page_management/usecase_1/C.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/example_data/english/page_management/usecase_1/C.pdf -------------------------------------------------------------------------------- /example_data/english/page_management/usecase_1/D.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/example_data/english/page_management/usecase_1/D.pdf -------------------------------------------------------------------------------- /example_data/english/page_management/usecase_1/readme.txt: -------------------------------------------------------------------------------- 1 | ## Reorder Pages 2 | 3 | Upload documents A, B, C, D with mixed pages. 4 | User must be able to reorder pages (copy/paste pages) so that: 5 | 6 | * A will contain pages A1, A2, A3 (in this order) 7 | * B will contain pages B1, B2, B3, B4, B6, B7, B8 (page B5 is missing) 8 | * C will contain pages C1, C2, ..., C8 9 | * D will contain pages D1, D2, D3 -------------------------------------------------------------------------------- /example_data/spanish/andromeda.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/example_data/spanish/andromeda.pdf -------------------------------------------------------------------------------- /img/papermerge3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/img/papermerge3.png -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault( 7 | "DJANGO_SETTINGS_MODULE", "config.settings.dev" 8 | ) 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError: 12 | # The above import may fail for some other reason. Ensure that the 13 | # issue is really that Django is missing to avoid masking other 14 | # exceptions on Python 2. 15 | try: 16 | import django 17 | except ImportError: 18 | raise ImportError( 19 | "Couldn't import Django. Are you sure it's installed and " 20 | "available on your PYTHONPATH environment variable? Did you " 21 | "forget to activate a virtual environment?" 22 | ) 23 | raise 24 | execute_from_command_line(sys.argv) 25 | -------------------------------------------------------------------------------- /papermerge.conf.py.example: -------------------------------------------------------------------------------- 1 | # Example of papermerge.conf.py 2 | # 3 | # papermerge.conf.py - is a configuration files with python syntax 4 | # 5 | # Copy this file to /etc/papermerge.conf.py or to your project's root directory 6 | # and modify it to suit your needs. 7 | # As this file contains passwords it should only be readable by the user 8 | # running Papermerge. 9 | 10 | 11 | # Paths & Folders 12 | ####################### 13 | 14 | 15 | # You can specify where you want the SQLite database to be stored instead of 16 | # the default location of /data/ within the install directory. 17 | # DBDIR = "/path/to/papermerge/db" 18 | 19 | # Override the default MEDIA_ROOT. This is where all files are stored. 20 | # The default location is /media/documents/ within the install directory. 21 | # MEDIA_DIR = "/path/to/media/dir" 22 | 23 | # Override the default STATIC_ROOT. All static files created with 24 | # "collectstatic" manager-command, their default location is /.... ? 25 | # STATIC_DIR = "/path/to/static/dir" 26 | 27 | # Override the default MEDIA_URL here. Unless you're hosting Papermerge off a subdomain 28 | # like /papermerge/, you probably don't need to change this. 29 | # MEDIA_URL = "/media/" 30 | 31 | # Override the STATIC_URL here. Unless you're hosting Papermerge off a 32 | # subdomain like /papermerge/, you probably don't need to change this. 33 | # STATIC_URL = "/static/" 34 | 35 | 36 | # Document Importer 37 | ######################### 38 | 39 | # Configuration for ./manage.py importer command 40 | 41 | # This where ./manage importer will import your documents from. 42 | # IMPORTER_DIR = "/path/to/import/dir" 43 | 44 | # Files are considered ready for import if they have been unmodified 45 | # for this duration (in seconds) 46 | # FILES_MIN_UNMODIFIED_DURATION = 1 47 | 48 | # This setting is ignored on Linux where inotify is used instead of a 49 | # polling loop. 50 | # The number of seconds that Papermerge will wait between checking 51 | # IMPORTER_DIR. If you tend to write documents to this directory 52 | # rarely, you may want to use a higher value than the default (5). 53 | # IMPORTER_LOOP_TIME = 5 54 | 55 | 56 | # These values are required if you want papermerge to import email attachments 57 | # from specific email account. 58 | # If you don't define a HOST, mail checking will just be disabled. 59 | # IMPORT_MAIL_HOST = "" 60 | # IMPORT_MAIL_USER = "" 61 | # IMPORT_MAIL_PASS = "" 62 | 63 | # These values specify how the mail importer should sort incoming messages 64 | # IMPORT_MAIL_BY_USER = False 65 | # IMPORT_MAIL_BY_SECRET = False 66 | # IMPORT_MAIL_DELETE = False 67 | 68 | # Override the default IMAP inbox here. If not set Papermerge defaults to 69 | # "INBOX". 70 | # IMPORT_MAIL_INBOX = "INBOX" 71 | 72 | # Worker 73 | ######################## 74 | 75 | # These settings are used by built-in asyncronious task (celery). 76 | # By default, celery will use local directory as message broker. 77 | # This directory will be created automatically when you start papermerge. 78 | # TASK_QUEUE_DIR = "/var/tmp/papermerge/queue" 79 | 80 | 81 | # Storage 82 | ############### 83 | 84 | # DEFAULT_FILE_STORAGE = "mglib.storage.FileSystemStorage" 85 | 86 | 87 | # Search Backend 88 | ################### 89 | 90 | # SEARCH_BACKEND = "papermerge.search.backends.db.SearchBackend" 91 | 92 | # Metadata 93 | #################### 94 | 95 | # METADATA_DATE_FORMATS = [ 96 | # 'dd.mm.yy', 97 | # 'dd.mm.yyyy', 98 | # 'dd.M.yyyy', 99 | # 'month', Month as locale’s full name, January, February, …, December (en_US); 100 | #Januar, Februar, …, Dezember (de_DE) 101 | # ] 102 | # 103 | # METADATA_CURRENCY_FORMATS = [ 104 | # 'dd.cc', 105 | # 'dd,cc' 106 | # ] 107 | # 108 | # METADATA_NUMERIC_FORMATS = [ 109 | # 'dddd', 110 | # 'd,ddd', 111 | # 'd.ddd' 112 | # ] 113 | 114 | 115 | # OCR 116 | ####### 117 | 118 | # Customize the default language that tesseract will attempt to use when 119 | # parsing documents. It should be a 3-letter language code consistent with ISO 120 | # 639: https://www.loc.gov/standards/iso639-2/php/code_list.php 121 | # Examples: 122 | # eng = for English 123 | # deu = for German 124 | # spa = for Spanish 125 | 126 | # OCR_DEFAULT_LANGUAGE = "deu" 127 | 128 | # OCR_LANGUAGES = { 129 | # "deu": "Deutsch", 130 | # "eng": "English", 131 | # "ron": "Romanian", 132 | # "rus": "Russian" 133 | # } 134 | 135 | 136 | # Internationalization & Localization 137 | ###################################### 138 | 139 | # Internationalization = i18n 140 | # 141 | # In what language is user interface. 142 | # At this point there two options: 143 | # 144 | # en, en-US, en-UK, ... = for user interface in English language 145 | # de, de-DE, de-AT, ... = for user interface in German language 146 | # 147 | # English is default fallback i.e. if you don't specify anything, 148 | # of specify unsupported language - English will be used. 149 | 150 | # LANGUAGE_CODE = 'en' 151 | 152 | # If is set to True, will use same language code as your Web Browser (agent) does. 153 | # Browsers send 'Accept-Language' header with their locale. 154 | # For more, read: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language 155 | # If set to True - will override LANGUAGE_CODE option 156 | # If set to False - language code specified in LANGUAGE_CODE option will be used. 157 | 158 | # LANGUAGE_FROM_AGENT = False 159 | 160 | 161 | # Binary Dependencies 162 | ########################### 163 | # 164 | # Papermerge uses a number of open source 3rd parties for various purposes. 165 | # One of the most obvious example is tesseract - used to OCR documents (extract 166 | # text from binary image file). Another, less obvious example is pdfinfo 167 | # utility provided by poppler-utils package: pdfinfo is used to count number of 168 | # pages in pdf document. 169 | # 170 | # Settings prefixed with BINARY_ are used to configure paths to those dependencies. 171 | # Here is the full list of all binary dependencies and their default values. 172 | 173 | # file utility used to find out mime type of a file 174 | 175 | # BINARY_FILE = "/usr/bin/file" 176 | 177 | # Provided by ImageMagick package. 178 | # convert is used for resizing images. 179 | 180 | # BINARY_CONVERT = "/usr/bin/convert" 181 | 182 | # Provided by Poppler Utils. 183 | # pdftoppm used to extract images from PDF file. 184 | 185 | # BINARY_PDFTOPPM = "/usr/bin/pdftoppm" 186 | 187 | # Provided by Poppler Utils. 188 | # pdfinfo is used to get page count in PDF file 189 | 190 | # BINARY_PDFINFO = "/usr/bin/pdfinfo" 191 | 192 | # Provided by ImageMagick package. 193 | # identity is used to get number of pages in TIFF file. 194 | 195 | # BINARY_IDENTIFY = "/usr/bin/identify" 196 | 197 | # Provided by tesseract package. 198 | # tesseract is used to extract text from images/PDF files. 199 | 200 | # BINARY_OCR = "/usr/bin/tesseract" 201 | 202 | # Provided by pdftk package 203 | # pdftok is used to reorder, cut/paste, delete pages withing PDF document 204 | 205 | # BINARY_PDFTK = "/usr/bin/pdftk" 206 | -------------------------------------------------------------------------------- /papermerge/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/papermerge/test/__init__.py -------------------------------------------------------------------------------- /papermerge/test/auth_backends.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | 3 | 4 | class TestcaseUserBackend(object): 5 | """ 6 | Custom authentication backend which... well, skips 7 | authentication. Very usefull for testing views functionality 8 | where authentication is not on in primary focus. 9 | """ 10 | 11 | def authenticate(self, request, testcase_user=None): 12 | return testcase_user 13 | 14 | def get_user(self, user_id): 15 | User = get_user_model() 16 | user = User.objects.get(pk=user_id) 17 | return user 18 | -------------------------------------------------------------------------------- /papermerge/test/contrib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/papermerge/test/contrib/__init__.py -------------------------------------------------------------------------------- /papermerge/test/contrib/admin/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/papermerge/test/contrib/admin/__init__.py -------------------------------------------------------------------------------- /papermerge/test/contrib/admin/test_anonymous_user.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.test import Client 3 | from django.urls import reverse 4 | 5 | 6 | class TestAnonymousUserView(TestCase): 7 | 8 | def setUp(self): 9 | self.client = Client() 10 | # superuser exists, but is not authenticated 11 | 12 | def test_basic_login_view(self): 13 | """ 14 | Login view renders OK in case of aunonymous user 15 | """ 16 | ret = self.client.get(reverse('account_login')) 17 | 18 | self.assertEqual( 19 | ret.status_code, 20 | 200 21 | ) 22 | -------------------------------------------------------------------------------- /papermerge/test/contrib/admin/test_forms.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from papermerge.core.models import Automate, Folder 4 | from papermerge.contrib.admin.forms import AutomateForm 5 | from papermerge.test.utils import create_root_user 6 | 7 | 8 | class TestForms(TestCase): 9 | 10 | def setUp(self): 11 | self.user = create_root_user() 12 | 13 | def test_basic_automate_form(self): 14 | # automate form can be called with or without 15 | # user as argument 16 | form = AutomateForm(user=self.user) 17 | self.assertTrue(form) 18 | 19 | form = AutomateForm() 20 | self.assertTrue(form) 21 | 22 | folder = Folder.objects.create( 23 | title="FolderX", 24 | user=self.user 25 | ) 26 | automate = Automate( 27 | user=self.user, 28 | name="XYZ", 29 | match="X", 30 | matching_algorithm=Automate.MATCH_ANY, 31 | dst_folder=folder 32 | ) 33 | automate.save() 34 | 35 | form = AutomateForm(instance=automate) 36 | self.assertTrue(form) 37 | -------------------------------------------------------------------------------- /papermerge/test/contrib/admin/test_sidebar_part.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from papermerge.core.models import Document 4 | from papermerge.contrib import admin 5 | 6 | from papermerge.test.utils import create_root_user 7 | 8 | from papermerge.test.parts.app_0.models import Document as DocumentPart 9 | from papermerge.test.parts.app_0.models import Color 10 | 11 | 12 | class AdminSidebarDocumentPart(admin.SidebarPart): 13 | model = DocumentPart 14 | verbose_name = 'App Zero' 15 | 16 | fields = ( 17 | 'extra_special_id', 18 | 'color' 19 | ) 20 | 21 | field_options = { 22 | 'color': { 23 | 'choice_fields': ['id', 'name'] 24 | } 25 | } 26 | 27 | 28 | class TestSidebarPart(TestCase): 29 | 30 | def setUp(self): 31 | self.user = create_root_user() 32 | 33 | def test_basic(self): 34 | """ 35 | Asserts that SidebarPart is created 36 | """ 37 | green = Color.objects.create(name="green") 38 | 39 | doc = Document.objects.create_document( 40 | file_name="test.pdf", 41 | title="Test #1", 42 | page_count=3, 43 | size="3", 44 | lang="DEU", 45 | user=self.user, 46 | parts={ 47 | "extra_special_id": "DOC_XYZ_1" 48 | } 49 | ) 50 | doc.parts.color = green 51 | 52 | sidebar_part = AdminSidebarDocumentPart(doc) 53 | self.assertTrue(sidebar_part) 54 | 55 | self.assertEqual( 56 | sidebar_part.get_label(), 57 | "app_0", # django app label 58 | ) 59 | 60 | self.assertEqual( 61 | sidebar_part.get_verbose_name(), 62 | "App Zero", # django app verbose name 63 | ) 64 | -------------------------------------------------------------------------------- /papermerge/test/contrib/admin/test_sidebar_part_field.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from papermerge.core.models import Document 4 | from papermerge.contrib import admin 5 | 6 | from papermerge.test.utils import create_root_user 7 | 8 | from papermerge.test.parts.app_0.models import Color 9 | from papermerge.test.parts.app_0.models import Document as DocumentPart 10 | 11 | 12 | class TestSidebarFieldPart(TestCase): 13 | 14 | def setUp(self): 15 | self.user = create_root_user() 16 | 17 | def test_get_internal_type(self): 18 | green = Color.objects.create(name="green") 19 | # other colors are available as well 20 | red = Color.objects.create(name="red") 21 | yellow = Color.objects.create(name="yellow") 22 | 23 | doc = Document.objects.create_document( 24 | file_name="test.pdf", 25 | title="Test #1", 26 | page_count=3, 27 | size="3", 28 | lang="DEU", 29 | user=self.user, 30 | parts={ 31 | "extra_special_id": "DOC_XYZ_1" 32 | } 33 | ) 34 | doc.parts.color = green 35 | 36 | sidebar_field_1 = admin.SidebarPartField( 37 | document=doc, # core document instance 38 | field_name="extra_special_id", 39 | model=DocumentPart, 40 | ) 41 | 42 | sidebar_field_2 = admin.SidebarPartField( 43 | document=doc, # core document instance 44 | field_name="color", 45 | model=DocumentPart, 46 | options={ 47 | 'color': { 48 | 'choice_fields': [ 49 | 'id', 'name' 50 | ] 51 | } 52 | } 53 | ) 54 | 55 | self.assertEqual( 56 | sidebar_field_1.get_internal_type(), 57 | "CharField", # sticks with django model field names conversions 58 | ) 59 | 60 | self.assertEqual( 61 | sidebar_field_2.get_internal_type(), 62 | "ForeignKey", # sticks with django model field names conversions 63 | ) 64 | 65 | self.assertEqual( 66 | sidebar_field_1.get_value(), 67 | "DOC_XYZ_1" 68 | ) 69 | 70 | self.assertDictEqual( 71 | sidebar_field_1.to_json(), 72 | { 73 | "class": "CharField", 74 | "value": "DOC_XYZ_1", 75 | "field_name": "extra_special_id" 76 | } 77 | ) 78 | 79 | self.assertEqual( 80 | sidebar_field_2.get_value(), 81 | green 82 | ) 83 | 84 | self.assertDictEqual( 85 | sidebar_field_2.to_json(), 86 | { 87 | "class": "ForeignKey", 88 | "value": (green.id, green.name), 89 | "choices": [ 90 | (green.id, green.name), 91 | (red.id, red.name), 92 | (yellow.id, yellow.name), 93 | ], 94 | "field_name": "color" 95 | } 96 | ) 97 | -------------------------------------------------------------------------------- /papermerge/test/contrib/admin/test_views_automates.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.test import Client 3 | from django.contrib.auth import get_user_model 4 | from django.urls import reverse 5 | from django.http.response import HttpResponseRedirect 6 | 7 | from papermerge.core.models import ( 8 | Automate, 9 | Folder, 10 | Tag 11 | ) 12 | 13 | User = get_user_model() 14 | 15 | 16 | class TestAutomateViewsAuthReq(TestCase): 17 | def setUp(self): 18 | self.user = _create_user( 19 | username="john", 20 | password="test" 21 | ) 22 | self.dst_folder = Folder.objects.create( 23 | title="destination Folder", 24 | user=self.user 25 | ) 26 | 27 | def test_automate_view(self): 28 | """ 29 | If user is not authenticated reponse must 30 | be HttpReponseRedirect (302) 31 | """ 32 | automate = Automate.objects.create( 33 | user=self.user, 34 | name="test", 35 | dst_folder=self.dst_folder, 36 | match="XYZ", 37 | matching_algorithm=Automate.MATCH_ANY, 38 | ) 39 | ret = self.client.get( 40 | reverse('admin:automate-update', args=(automate.id,)), 41 | ) 42 | self.assertEqual( 43 | ret.status_code, 44 | HttpResponseRedirect.status_code 45 | ) 46 | 47 | def test_automates_view(self): 48 | """ 49 | Not accessible to users which are not authenticated 50 | """ 51 | ret = self.client.post( 52 | reverse('admin:automates'), 53 | { 54 | 'action': 'delete_selected', 55 | '_selected_action': [1, 2], 56 | } 57 | ) 58 | self.assertEqual( 59 | ret.status_code, 60 | HttpResponseRedirect.status_code 61 | ) 62 | # same story for get method 63 | ret = self.client.get( 64 | reverse('admin:automates'), 65 | ) 66 | self.assertEqual( 67 | ret.status_code, 68 | HttpResponseRedirect.status_code 69 | ) 70 | 71 | 72 | class TestAutomateViews(TestCase): 73 | 74 | def setUp(self): 75 | self.user = _create_user( 76 | username="john", 77 | password="test" 78 | ) 79 | self.dst_folder = Folder.objects.create( 80 | title="destination Folder", 81 | user=self.user 82 | ) 83 | self.client = Client() 84 | self.client.login( 85 | username='john', 86 | password='test' 87 | ) 88 | 89 | def test_automate_change_view(self): 90 | auto = Automate.objects.create( 91 | user=self.user, 92 | name="test", 93 | dst_folder=self.dst_folder, 94 | match="XYZ", 95 | matching_algorithm=Automate.MATCH_ANY, 96 | ) 97 | ret = self.client.get( 98 | reverse( 99 | 'admin:automate-update', 100 | args=(auto.id,) 101 | ), 102 | ) 103 | self.assertEqual(ret.status_code, 200) 104 | 105 | ret = self.client.get( 106 | reverse('admin:automate-update', args=(auto.id + 1,)), 107 | ) 108 | self.assertEqual(ret.status_code, 404) 109 | 110 | def test_automates_view(self): 111 | ret = self.client.get( 112 | reverse('admin:automates') 113 | ) 114 | self.assertEqual( 115 | ret.status_code, 200 116 | ) 117 | 118 | def test_create_new_automate_view(self): 119 | 120 | self.assertEqual( 121 | Automate.objects.count(), 122 | 0 123 | ) 124 | 125 | ret = self.client.post( 126 | reverse('admin:automate-add'), 127 | { 128 | 'name': "XYZ", 129 | 'matching_algorithm': Automate.MATCH_ANY, 130 | 'match': 'XYZ', 131 | 'is_case_sensitive': True, 132 | 'dst_folder': self.dst_folder.id 133 | } 134 | ) 135 | self.assertEqual( 136 | ret.status_code, 302 137 | ) 138 | self.assertEqual( 139 | Automate.objects.count(), 140 | 1 141 | ) 142 | self.assertEqual( 143 | Automate.objects.first().user, 144 | self.user 145 | ) 146 | 147 | def test_create_new_automate_with_new_tag_assigned_view(self): 148 | """ 149 | Creates a new automate with a new tag. 150 | 151 | In this scenario it is very important that tag with same name won't 152 | exist before (i.e. tag is new). It is expected that automate 153 | will create a new tag instance. Tag will be assigned to same user as 154 | corresponding automate. 155 | """ 156 | self.assertEqual( 157 | Automate.objects.count(), 158 | 0 159 | ) 160 | self.assertEqual( 161 | Tag.objects.count(), 162 | 0 163 | ) 164 | 165 | ret = self.client.post( 166 | reverse('admin:automate-add'), 167 | { 168 | "name": "XYZ", 169 | "matching_algorithm": Automate.MATCH_ANY, 170 | "match": "XYZ", 171 | "is_case_sensitive": True, 172 | "dst_folder": self.dst_folder.id, 173 | "tags": "groceries," 174 | } 175 | ) 176 | self.assertEqual( 177 | ret.status_code, 302 178 | ) 179 | self.assertEqual( 180 | Automate.objects.count(), 181 | 1 182 | ) 183 | self.assertEqual( 184 | Automate.objects.first().user, 185 | self.user 186 | ) 187 | self.assertEqual( 188 | Tag.objects.count(), 189 | 1 190 | ) 191 | # create tag belongs to same user as automate 192 | self.assertEqual( 193 | Tag.objects.first().user, 194 | self.user 195 | ) 196 | 197 | def test_delete_automates(self): 198 | a1 = Automate.objects.create( 199 | user=self.user, 200 | name="test1", 201 | dst_folder=self.dst_folder, 202 | match="XYZ", 203 | matching_algorithm=Automate.MATCH_ANY, 204 | ) 205 | a2 = Automate.objects.create( 206 | user=self.user, 207 | name="test2", 208 | dst_folder=self.dst_folder, 209 | match="XYZ", 210 | matching_algorithm=Automate.MATCH_ANY, 211 | ) 212 | Automate.objects.create( 213 | user=self.user, 214 | name="test3", 215 | dst_folder=self.dst_folder, 216 | match="XYZ", 217 | matching_algorithm=Automate.MATCH_ANY, 218 | ) 219 | ret = self.client.post( 220 | reverse('admin:automates'), 221 | { 222 | 'action': 'delete_selected', 223 | '_selected_action': [a1.id, a2.id], 224 | } 225 | ) 226 | self.assertEqual( 227 | ret.status_code, 302 228 | ) 229 | # two log entries were deleted 230 | # only one should remain 231 | self.assertEqual( 232 | Automate.objects.filter( 233 | user=self.user 234 | ).count(), 235 | 1 236 | ) 237 | 238 | 239 | def _create_user(username, password): 240 | user = User.objects.create_user( 241 | username=username, 242 | is_active=True, 243 | ) 244 | user.set_password(password) 245 | user.save() 246 | 247 | return user 248 | -------------------------------------------------------------------------------- /papermerge/test/contrib/admin/test_views_groups.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.test import Client 3 | from django.urls import reverse 4 | from django.http import HttpResponseRedirect 5 | from django.http import HttpResponseForbidden 6 | 7 | from django.contrib.auth.models import Group 8 | 9 | from papermerge.test.utils import ( 10 | create_root_user, 11 | create_margaret_user, 12 | create_user 13 | ) 14 | 15 | 16 | class TestGroupViewUserNotAuth(TestCase): 17 | """ 18 | Basic test to make sure that unauthorized users do not 19 | have access to group list 20 | """ 21 | 22 | def test_basic_group_list(self): 23 | """ 24 | No user is authenticated/signed in 25 | """ 26 | Group.objects.create(name="test1") 27 | Group.objects.create(name="test2") 28 | 29 | ret = self.client.get( 30 | reverse('admin:groups'), 31 | ) 32 | self.assertEqual( 33 | ret.status_code, 34 | HttpResponseRedirect.status_code 35 | ) 36 | 37 | 38 | class TestGroupView(TestCase): 39 | 40 | def setUp(self): 41 | 42 | self.testcase_user = create_root_user() 43 | self.margaret_user = create_margaret_user() 44 | self.client = Client() 45 | 46 | def test_basic_group_list(self): 47 | self.client.login(testcase_user=self.testcase_user) 48 | 49 | Group.objects.create(name="test1") 50 | Group.objects.create(name="test2") 51 | 52 | ret = self.client.get( 53 | reverse('admin:groups'), 54 | ) 55 | self.assertEqual( 56 | ret.status_code, 57 | 200 58 | ) 59 | self.assertEqual( 60 | ret.context['object_list'].count(), 61 | 2 62 | ) 63 | 64 | def test_basic_groups_delete(self): 65 | self.client.login(testcase_user=self.testcase_user) 66 | 67 | gr1 = Group.objects.create(name="test1") 68 | gr2 = Group.objects.create(name="test2") 69 | 70 | self.assertEqual( 71 | Group.objects.count(), 72 | 2 73 | ) 74 | 75 | ret = self.client.post( 76 | reverse('admin:groups'), 77 | { 78 | 'action': 'delete_selected', 79 | '_selected_action': [gr1.id, gr2.id] 80 | } 81 | ) 82 | self.assertEqual( 83 | ret.status_code, 84 | 302 85 | ) 86 | self.assertEqual( 87 | Group.objects.count(), 88 | 0 89 | ) 90 | 91 | def test_basic_group(self): 92 | self.client.login(testcase_user=self.testcase_user) 93 | 94 | gr = Group.objects.create(name="test") 95 | 96 | ret = self.client.get( 97 | reverse('admin:group-update', args=(gr.pk,)), 98 | ) 99 | self.assertEqual( 100 | ret.status_code, 101 | 200 102 | ) 103 | 104 | def test_basic_group_new(self): 105 | self.client.login(testcase_user=self.testcase_user) 106 | 107 | ret = self.client.get( 108 | reverse('admin:group-add'), 109 | ) 110 | self.assertEqual( 111 | ret.status_code, 112 | 200 113 | ) 114 | 115 | def test_create_new_group_via_post(self): 116 | """ 117 | Asserts correct functionality of new group creation. 118 | """ 119 | self.client.login(testcase_user=self.testcase_user) 120 | 121 | self.client.post( 122 | reverse('admin:group-add'), 123 | {'name': "new_group"} 124 | ) 125 | 126 | self.assertEqual( 127 | Group.objects.count(), 128 | 1 129 | ) 130 | 131 | def test_change_group(self): 132 | """ 133 | When updating a group, should not create a new 134 | entry, but update existing one! 135 | """ 136 | self.client.login(testcase_user=self.testcase_user) 137 | 138 | gr = Group.objects.create(name="XXX") 139 | self.assertEqual( 140 | Group.objects.count(), 141 | 1 142 | ) 143 | 144 | self.client.post( 145 | reverse('admin:group-update', args=(gr.pk,)), 146 | { 147 | 'name': "XXX2" 148 | } 149 | ) 150 | self.assertEqual( 151 | Group.objects.count(), 152 | 1 153 | ) 154 | gr.refresh_from_db() 155 | self.assertEqual( 156 | gr.name, 157 | "XXX2" 158 | ) 159 | 160 | def test_group_list_denied_for_margaret(self): 161 | """ 162 | Margaret is a non-superuser (non-root) without any 163 | additional permissions assigned -> she will be denied 164 | access to list groups. 165 | """ 166 | self.client.login( 167 | testcase_user=self.margaret_user 168 | ) 169 | ret = self.client.get( 170 | reverse('admin:groups'), 171 | ) 172 | self.assertEqual( 173 | ret.status_code, 174 | HttpResponseForbidden.status_code 175 | ) 176 | 177 | def test_group_new_denied_for_margaret(self): 178 | """ 179 | Margaret is a non-superuser (non-root) without any 180 | additional permissions assigned -> she won't be allowed 181 | adding a new group. 182 | """ 183 | self.client.login( 184 | testcase_user=self.margaret_user 185 | ) 186 | ret = self.client.get( 187 | reverse('admin:group-add'), 188 | ) 189 | self.assertEqual( 190 | ret.status_code, 191 | HttpResponseForbidden.status_code 192 | ) 193 | 194 | def test_group_change_denied_for_margaret(self): 195 | """ 196 | Margaret is a non-superuser (non-root) without any 197 | additional permissions assigned -> she won't be allowed 198 | to perform changes on the group model. 199 | """ 200 | gr = Group.objects.create(name="XXX") 201 | self.client.login( 202 | testcase_user=self.margaret_user 203 | ) 204 | ret = self.client.post( 205 | reverse('admin:group-update', args=(gr.pk,)), 206 | { 207 | 'name': "XXX2" 208 | } 209 | ) 210 | self.assertEqual( 211 | ret.status_code, 212 | HttpResponseForbidden.status_code 213 | ) 214 | gr.refresh_from_db() 215 | self.assertEqual( 216 | gr.name, 217 | "XXX" 218 | ) 219 | 220 | def test_group_list_granted_given_correct_perm(self): 221 | """ 222 | In order to get access to group list ``auth.view_group`` 223 | permission is required. 224 | """ 225 | user = create_user( 226 | username="non_priv", 227 | perms=['auth.view_group'] 228 | ) 229 | negative_case_user = create_user( 230 | username="other_perm_user", 231 | perms=['core.view_user'] 232 | ) 233 | Group.objects.create(name="XXX") 234 | 235 | self.client.login( 236 | testcase_user=user 237 | ) 238 | ret = self.client.get(reverse('admin:groups')) 239 | 240 | self.assertEqual( 241 | ret.status_code, 242 | 200 243 | ) 244 | self.client.logout() 245 | # check negative case 246 | self.client.login( 247 | testcase_user=negative_case_user 248 | ) 249 | ret = self.client.get(reverse('admin:groups')) 250 | 251 | self.assertEqual( 252 | ret.status_code, 253 | HttpResponseForbidden.status_code 254 | ) 255 | 256 | def test_group_add_granted_given_correct_perm(self): 257 | """ 258 | In order to add a group ``auth.add_group`` 259 | permission is required. 260 | """ 261 | user = create_user( 262 | username="non_priv", 263 | perms=['auth.add_group'] 264 | ) 265 | negative_case_user = create_user( 266 | username="other_perm_user", 267 | perms=['core.view_user'] 268 | ) 269 | Group.objects.create(name="XXX") 270 | 271 | self.client.login( 272 | testcase_user=user 273 | ) 274 | ret = self.client.get(reverse('admin:group-add')) 275 | 276 | self.assertEqual( 277 | ret.status_code, 278 | 200 279 | ) 280 | # check negative case 281 | self.client.logout() 282 | self.client.login( 283 | testcase_user=negative_case_user 284 | ) 285 | ret = self.client.get(reverse('admin:group-add')) 286 | 287 | self.assertEqual( 288 | ret.status_code, 289 | HttpResponseForbidden.status_code 290 | ) 291 | 292 | def test_group_change_granted_given_correct_perm(self): 293 | """ 294 | In order to change a group ``auth.change_group`` 295 | permission is required. 296 | """ 297 | user = create_user( 298 | username="non_priv", 299 | perms=['auth.change_group'] 300 | ) 301 | negative_case_user = create_user( 302 | username="other_perm_user", 303 | perms=['core.view_user'] 304 | ) 305 | gr = Group.objects.create(name="XXX") 306 | 307 | self.client.login( 308 | testcase_user=user 309 | ) 310 | ret = self.client.get( 311 | reverse('admin:group-update', args=(gr.id, )) 312 | ) 313 | 314 | self.assertEqual( 315 | ret.status_code, 316 | 200 317 | ) 318 | # check negative case 319 | self.client.logout() 320 | self.client.login( 321 | testcase_user=negative_case_user 322 | ) 323 | ret = self.client.get( 324 | reverse('admin:group-update', args=(gr.id, )) 325 | ) 326 | 327 | self.assertEqual( 328 | ret.status_code, 329 | HttpResponseForbidden.status_code 330 | ) 331 | -------------------------------------------------------------------------------- /papermerge/test/contrib/admin/test_views_index.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.test import Client 3 | from django.urls import reverse 4 | from django.http import HttpResponseRedirect 5 | 6 | from papermerge.core.models import Document 7 | 8 | from papermerge.test.utils import ( 9 | create_root_user, 10 | ) 11 | 12 | 13 | class AnonymouseUserIndexAccessView(TestCase): 14 | def setUp(self): 15 | # user exists, but not signed in 16 | self.testcase_user = create_root_user() 17 | self.client = Client() 18 | 19 | def test_index(self): 20 | ret = self.client.get(reverse('admin:index')) 21 | 22 | self.assertEqual( 23 | ret.status_code, 24 | HttpResponseRedirect.status_code 25 | ) 26 | 27 | 28 | class TestAdvancedSearchView(TestCase): 29 | """ 30 | AV = advanced search 31 | """ 32 | 33 | def setUp(self): 34 | 35 | self.testcase_user = create_root_user() 36 | self.client = Client() 37 | self.client.login(testcase_user=self.testcase_user) 38 | 39 | def test_basic_av_by_tag(self): 40 | """ 41 | In advaced search user can search by tag(s) 42 | """ 43 | doc1 = Document.objects.create_document( 44 | title="doc1", 45 | user=self.testcase_user, 46 | page_count=2, 47 | file_name="koko.pdf", 48 | size='1111', 49 | lang='ENG', 50 | ) 51 | doc2 = Document.objects.create_document( 52 | title="doc2", 53 | user=self.testcase_user, 54 | page_count=2, 55 | file_name="kuku.pdf", 56 | size='1111', 57 | lang='ENG', 58 | ) 59 | doc1.tags.add( 60 | "green", 61 | "blue", 62 | tag_kwargs={'user': self.testcase_user} 63 | ) 64 | doc2.tags.add( 65 | "blue", 66 | tag_kwargs={'user': self.testcase_user} 67 | ) 68 | 69 | ret = self.client.get( 70 | reverse('admin:search'), {'tag': 'green'} 71 | ) 72 | self.assertEqual( 73 | ret.status_code, 74 | 200 75 | ) 76 | self.assertEqual( 77 | len(ret.context['results_docs']), 78 | 1 79 | ) 80 | doc_ = ret.context['results_docs'][0] 81 | 82 | self.assertEqual( 83 | doc_.id, 84 | doc1.id 85 | ) 86 | 87 | def test_basic_av_by_tags_op_all(self): 88 | """ 89 | In advaced search user can search by tag(s) 90 | tags_op can be 'all' or 'any'. 91 | tags_op=all: find all documents which contain all tags 92 | """ 93 | doc1 = Document.objects.create_document( 94 | title="doc1", 95 | user=self.testcase_user, 96 | page_count=2, 97 | file_name="koko.pdf", 98 | size='1111', 99 | lang='ENG', 100 | ) 101 | doc2 = Document.objects.create_document( 102 | title="doc2", 103 | user=self.testcase_user, 104 | page_count=2, 105 | file_name="kuku.pdf", 106 | size='1111', 107 | lang='ENG', 108 | ) 109 | doc3 = Document.objects.create_document( 110 | title="doc3", 111 | user=self.testcase_user, 112 | page_count=2, 113 | file_name="momo.pdf", 114 | size='1111', 115 | lang='ENG', 116 | ) 117 | doc1.tags.add( 118 | "green", 119 | "blue", 120 | tag_kwargs={'user': self.testcase_user} 121 | ) 122 | doc2.tags.add( 123 | "blue", 124 | tag_kwargs={'user': self.testcase_user} 125 | ) 126 | doc3.tags.add( 127 | "green", 128 | "blue", 129 | "red", 130 | tag_kwargs={'user': self.testcase_user} 131 | ) 132 | 133 | base_url = reverse('admin:search') 134 | args = "tag=green&tag=blue&tags_op=all" 135 | url = f"{base_url}?{args}" 136 | 137 | ret = self.client.get(url) 138 | 139 | self.assertEqual( 140 | ret.status_code, 141 | 200 142 | ) 143 | self.assertEqual( 144 | len(ret.context['results_docs']), 145 | 2 146 | ) 147 | result_ids = set( 148 | [doc_.id for doc_ in ret.context['results_docs']] 149 | ) 150 | self.assertEqual( 151 | result_ids, 152 | set([doc1.id, doc3.id]) 153 | ) 154 | 155 | def test_basic_av_by_tags_op_any(self): 156 | """ 157 | In advaced search user can search by tag(s) 158 | tags_op can be 'all' or 'any'. 159 | tags_op=any: find all documents which contain any tags 160 | of the mentioned tags 161 | """ 162 | doc1 = Document.objects.create_document( 163 | title="doc1", 164 | user=self.testcase_user, 165 | page_count=2, 166 | file_name="koko.pdf", 167 | size='1111', 168 | lang='ENG', 169 | ) 170 | doc2 = Document.objects.create_document( 171 | title="doc2", 172 | user=self.testcase_user, 173 | page_count=2, 174 | file_name="kuku.pdf", 175 | size='1111', 176 | lang='ENG', 177 | ) 178 | doc3 = Document.objects.create_document( 179 | title="doc3", 180 | user=self.testcase_user, 181 | page_count=2, 182 | file_name="momo.pdf", 183 | size='1111', 184 | lang='ENG', 185 | ) 186 | doc1.tags.add( 187 | "red", 188 | tag_kwargs={'user': self.testcase_user} 189 | ) 190 | doc2.tags.add( 191 | "green", 192 | tag_kwargs={'user': self.testcase_user} 193 | ) 194 | doc3.tags.add( 195 | "blue", 196 | tag_kwargs={'user': self.testcase_user} 197 | ) 198 | 199 | base_url = reverse('admin:search') 200 | args = "tag=red&tag=green&tags_op=any" 201 | url = f"{base_url}?{args}" 202 | 203 | ret = self.client.get(url) 204 | 205 | self.assertEqual( 206 | ret.status_code, 207 | 200 208 | ) 209 | self.assertEqual( 210 | len(ret.context['results_docs']), 211 | 2 212 | ) 213 | result_ids = set( 214 | [doc_.id for doc_ in ret.context['results_docs']] 215 | ) 216 | self.assertEqual( 217 | result_ids, 218 | set([doc1.id, doc2.id]) 219 | ) 220 | -------------------------------------------------------------------------------- /papermerge/test/contrib/admin/test_views_logs.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.test import Client 3 | from django.contrib.auth import get_user_model 4 | from django.urls import reverse 5 | from django.http.response import HttpResponseRedirect 6 | 7 | from papermerge.contrib.admin.models import LogEntry 8 | 9 | User = get_user_model() 10 | 11 | 12 | class TestLogViewsAuthReq(TestCase): 13 | def setUp(self): 14 | self.user = _create_user( 15 | username="john", 16 | password="test" 17 | ) 18 | 19 | def test_log_view(self): 20 | """ 21 | If user is not authenticated reponse must 22 | be HttpReponseRedirect (302) 23 | """ 24 | log = LogEntry.objects.create( 25 | user=self.user, message="test" 26 | ) 27 | ret = self.client.post( 28 | reverse('admin:log-update', args=(log.id,)), 29 | ) 30 | self.assertEqual( 31 | ret.status_code, 32 | HttpResponseRedirect.status_code 33 | ) 34 | 35 | def test_logs_view(self): 36 | """ 37 | Not accessible to users which are not authenticated 38 | """ 39 | ret = self.client.post( 40 | reverse('admin:logs'), 41 | { 42 | 'action': 'delete_selected', 43 | '_selected_action': [1, 2], 44 | } 45 | ) 46 | self.assertEqual( 47 | ret.status_code, 48 | HttpResponseRedirect.status_code 49 | ) 50 | # same story for get method 51 | ret = self.client.get( 52 | reverse('admin:logs'), 53 | ) 54 | self.assertEqual( 55 | ret.status_code, 56 | HttpResponseRedirect.status_code 57 | ) 58 | 59 | 60 | class TestLogViews(TestCase): 61 | 62 | def setUp(self): 63 | self.user = _create_user( 64 | username="john", 65 | password="test" 66 | ) 67 | self.client = Client() 68 | self.client.login( 69 | username='john', 70 | password='test' 71 | ) 72 | 73 | def test_log_view(self): 74 | log = LogEntry.objects.create( 75 | user=self.user, message="test" 76 | ) 77 | ret = self.client.get( 78 | reverse('admin:log-update', args=(log.id,)), 79 | ) 80 | self.assertEqual(ret.status_code, 200) 81 | 82 | # try to see a non existing log entry 83 | # must return 404 status code 84 | ret = self.client.get( 85 | reverse('admin:log-update', args=(log.id + 1,)), 86 | ) 87 | self.assertEqual(ret.status_code, 404) 88 | 89 | def test_logs_view(self): 90 | ret = self.client.get( 91 | reverse('admin:logs') 92 | ) 93 | self.assertEquals( 94 | ret.status_code, 200 95 | ) 96 | 97 | def test_delete_logs(self): 98 | log1 = LogEntry.objects.create( 99 | user=self.user, message="test" 100 | ) 101 | log2 = LogEntry.objects.create( 102 | user=self.user, message="test" 103 | ) 104 | LogEntry.objects.create( 105 | user=self.user, message="test" 106 | ) 107 | ret = self.client.post( 108 | reverse('admin:logs'), 109 | { 110 | 'action': 'delete_selected', 111 | '_selected_action': [log1.id, log2.id], 112 | } 113 | ) 114 | self.assertEquals( 115 | ret.status_code, 302 116 | ) 117 | # two log entries were deleted 118 | # only one should remain 119 | self.assertEqual( 120 | LogEntry.objects.filter( 121 | user=self.user 122 | ).count(), 123 | 1 124 | ) 125 | 126 | 127 | def _create_user(username, password): 128 | user = User.objects.create_user( 129 | username=username, 130 | is_active=True, 131 | ) 132 | user.set_password(password) 133 | user.save() 134 | 135 | return user 136 | -------------------------------------------------------------------------------- /papermerge/test/contrib/admin/test_views_preferences.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.test import Client 3 | from django.urls import reverse 4 | from django.http.response import ( 5 | HttpResponseRedirect 6 | ) 7 | 8 | 9 | from papermerge.test.utils import ( 10 | create_root_user, 11 | ) 12 | 13 | 14 | class TestPreferencesView(TestCase): 15 | 16 | def setUp(self): 17 | 18 | self.testcase_user = create_root_user() 19 | self.client = Client() 20 | self.client.login(testcase_user=self.testcase_user) 21 | 22 | def test_preferences_get(self): 23 | ret = self.client.get( 24 | reverse('admin:preferences'), 25 | ) 26 | self.assertEquals( 27 | ret.status_code, 28 | 200 29 | ) 30 | 31 | def test_preferences_post(self): 32 | ret = self.client.post( 33 | reverse('admin:preferences'), 34 | { 35 | 'views__documents_view': 'list', 36 | 'ocr__OCR_Language': 'eng' 37 | } 38 | ) 39 | self.assertEquals( 40 | ret.status_code, 41 | 200 42 | ) 43 | -------------------------------------------------------------------------------- /papermerge/test/contrib/admin/test_views_tags.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.test import Client 3 | from django.contrib.auth import get_user_model 4 | from django.urls import reverse 5 | from django.http.response import ( 6 | HttpResponseRedirect, 7 | ) 8 | 9 | from papermerge.core.models import Tag 10 | 11 | User = get_user_model() 12 | 13 | 14 | class TestTagsViewsAuthReq(TestCase): 15 | def setUp(self): 16 | self.user = _create_user( 17 | username="john", 18 | password="test" 19 | ) 20 | 21 | def test_tag_view(self): 22 | """ 23 | If user is not authenticated reponse must 24 | be HttpReponseRedirect (302) 25 | """ 26 | tag = Tag.objects.create( 27 | user=self.user, name="test" 28 | ) 29 | ret = self.client.get( 30 | reverse('admin:tag-update', args=(tag.pk,)), 31 | ) 32 | self.assertEqual( 33 | ret.status_code, 34 | HttpResponseRedirect.status_code 35 | ) 36 | 37 | def test_tags_view(self): 38 | """ 39 | Not accessible to users which are not authenticated 40 | """ 41 | ret = self.client.post( 42 | reverse('admin:tags'), 43 | { 44 | 'action': 'delete_selected', 45 | '_selected_action': [1, 2], 46 | } 47 | ) 48 | self.assertEqual( 49 | ret.status_code, 50 | HttpResponseRedirect.status_code 51 | ) 52 | # same story for get method 53 | ret = self.client.get( 54 | reverse('admin:tags'), 55 | ) 56 | self.assertEqual( 57 | ret.status_code, 58 | HttpResponseRedirect.status_code 59 | ) 60 | 61 | 62 | class TestTagViews(TestCase): 63 | 64 | def setUp(self): 65 | self.user = _create_user( 66 | username="john", 67 | password="test" 68 | ) 69 | self.client = Client() 70 | self.client.login( 71 | username='john', 72 | password='test' 73 | ) 74 | 75 | def test_tag_change_view(self): 76 | tag = Tag.objects.create( 77 | user=self.user, name="test" 78 | ) 79 | ret = self.client.get( 80 | reverse('admin:tag-update', args=(tag.pk,)), 81 | ) 82 | self.assertEqual(ret.status_code, 200) 83 | 84 | # try to see a non existing log entry 85 | # must return 404 status code 86 | ret = self.client.get( 87 | reverse('admin:tag-update', args=(tag.pk + 1,)), 88 | ) 89 | self.assertEqual(ret.status_code, 404) 90 | 91 | def test_tags_view(self): 92 | ret = self.client.get( 93 | reverse('admin:tags') 94 | ) 95 | self.assertEqual( 96 | ret.status_code, 200 97 | ) 98 | 99 | def test_delete_tags(self): 100 | tag1 = Tag.objects.create( 101 | user=self.user, name="test1" 102 | ) 103 | tag2 = Tag.objects.create( 104 | user=self.user, name="test2" 105 | ) 106 | Tag.objects.create( 107 | user=self.user, name="test3" 108 | ) 109 | ret = self.client.post( 110 | reverse('admin:tags'), 111 | { 112 | 'action': 'delete_selected', 113 | '_selected_action': [tag1.id, tag2.id], 114 | } 115 | ) 116 | self.assertEqual( 117 | ret.status_code, 302 118 | ) 119 | # two tags entries were deleted 120 | # only one should remain 121 | self.assertEqual( 122 | Tag.objects.filter( 123 | user=self.user 124 | ).count(), 125 | 1 126 | ) 127 | 128 | def test_tags_view_user_adds_duplicate_tag(self): 129 | """ 130 | User will try to 131 | add a duplicate. In case of duplicate - a user friendly 132 | error will be displayed 133 | """ 134 | Tag.objects.create( 135 | user=self.user, name="tag-10" 136 | ) 137 | # do it again 138 | ret = self.client.post( 139 | reverse('admin:tag-add'), 140 | { 141 | 'name': 'tag-10', 142 | 'pinned': False, 143 | 'fg_color': '#000000', 144 | 'bg_color': '#FF0000' 145 | } 146 | ) 147 | # no dramatic exception here, like DB duplicate key 148 | # violations 149 | self.assertEqual( 150 | ret.status_code, 151 | 200 152 | ) 153 | # no new tags were added 154 | self.assertEqual( 155 | Tag.objects.count(), 156 | 1 157 | ) 158 | 159 | 160 | def _create_user(username, password): 161 | user = User.objects.create_user( 162 | username=username, 163 | is_active=True, 164 | ) 165 | user.set_password(password) 166 | user.save() 167 | 168 | return user 169 | -------------------------------------------------------------------------------- /papermerge/test/data/berlin.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/papermerge/test/data/berlin.pdf -------------------------------------------------------------------------------- /papermerge/test/data/one-doc-in-root-testdata.tar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/papermerge/test/data/one-doc-in-root-testdata.tar -------------------------------------------------------------------------------- /papermerge/test/data/page-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/papermerge/test/data/page-1.jpg -------------------------------------------------------------------------------- /papermerge/test/data/testdata.tar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/papermerge/test/data/testdata.tar -------------------------------------------------------------------------------- /papermerge/test/parts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/papermerge/test/parts/__init__.py -------------------------------------------------------------------------------- /papermerge/test/parts/app_0/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/papermerge/test/parts/app_0/__init__.py -------------------------------------------------------------------------------- /papermerge/test/parts/app_0/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class App0Config(AppConfig): 5 | name = 'papermerge.test.parts.app_0' 6 | verbose_name = 'App Zero' 7 | -------------------------------------------------------------------------------- /papermerge/test/parts/app_0/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1 on 2020-11-05 14:17 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ('core', '0027_auto_20201118_1538'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Document', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('extra_special_id', models.CharField(max_length=50, null=True, unique=True)), 21 | ('base_ptr', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='app_0_document_related', related_query_name='app_0_documents', to='core.document')), 22 | ], 23 | options={ 24 | 'abstract': False, 25 | }, 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /papermerge/test/parts/app_0/migrations/0002_auto_20201111_0650.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1 on 2020-11-11 06:50 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('app_0', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Color', 16 | fields=[ 17 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('name', models.CharField(max_length=50)), 19 | ], 20 | ), 21 | migrations.AddField( 22 | model_name='document', 23 | name='color', 24 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='app_0.color'), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /papermerge/test/parts/app_0/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/papermerge/test/parts/app_0/migrations/__init__.py -------------------------------------------------------------------------------- /papermerge/test/parts/app_0/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from papermerge.core.models import AbstractDocument 4 | 5 | 6 | class Color(models.Model): 7 | 8 | name = models.CharField( 9 | max_length=50 10 | ) 11 | 12 | def __repr__(self): 13 | return f"Color(name={self.name})" 14 | 15 | 16 | class Document(AbstractDocument): 17 | """ 18 | This document part adds an extra attribute 19 | """ 20 | 21 | extra_special_id = models.CharField( 22 | max_length=50, 23 | unique=True, 24 | null=True 25 | ) 26 | 27 | color = models.ForeignKey( 28 | Color, 29 | on_delete=models.CASCADE, 30 | blank=True, 31 | null=True 32 | ) 33 | 34 | def __repr__(self): 35 | 36 | attrs = { 37 | 'id': self.id, 38 | 'extra_special_id': self.extra_special_id, 39 | 'base_ptr': self.base_ptr, 40 | 'color': self.color 41 | } 42 | 43 | attributes = "" 44 | for k, v in attrs.items(): 45 | attributes = f"{attributes},{k}={v}" 46 | 47 | return f"DocumentPart({attributes})" 48 | -------------------------------------------------------------------------------- /papermerge/test/parts/app_dr/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/papermerge/test/parts/app_dr/__init__.py -------------------------------------------------------------------------------- /papermerge/test/parts/app_dr/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AppDrConfig(AppConfig): 5 | name = "papermerge.test.parts.app_dr" 6 | -------------------------------------------------------------------------------- /papermerge/test/parts/app_dr/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1 on 2020-11-05 14:17 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ('core', '0027_auto_20201118_1538'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Policy', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('name', models.CharField(max_length=50, unique=True)), 21 | ('allow_delete', models.BooleanField(default=False)), 22 | ], 23 | ), 24 | migrations.CreateModel( 25 | name='NodeX', 26 | fields=[ 27 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 28 | ('base_ptr', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='app_dr_nodex_related', related_query_name='app_dr_nodexs', to='core.basetreenode')), 29 | ('policy_x', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='statesx', to='app_dr.policy')), 30 | ], 31 | options={ 32 | 'abstract': False, 33 | }, 34 | ), 35 | migrations.CreateModel( 36 | name='Node', 37 | fields=[ 38 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 39 | ('base_ptr', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='app_dr_node_related', related_query_name='app_dr_nodes', to='core.basetreenode')), 40 | ('policy', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='states', to='app_dr.policy')), 41 | ], 42 | options={ 43 | 'abstract': False, 44 | }, 45 | ), 46 | ] 47 | -------------------------------------------------------------------------------- /papermerge/test/parts/app_dr/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/papermerge/test/parts/app_dr/migrations/__init__.py -------------------------------------------------------------------------------- /papermerge/test/parts/app_dr/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.core.exceptions import PermissionDenied 3 | 4 | from papermerge.core.models import AbstractNode 5 | 6 | 7 | class Policy(models.Model): 8 | name = models.CharField( 9 | max_length=50, 10 | unique=True 11 | ) 12 | 13 | # policy dictates if specific document 14 | # may be deleted. 15 | allow_delete = models.BooleanField( 16 | default=False 17 | ) 18 | 19 | def __str__(self): 20 | 21 | n = self.name 22 | d = self.allow_delete 23 | 24 | return f"Policy({n}, allow_delete={d})" 25 | 26 | 27 | class Node(AbstractNode): 28 | """ 29 | All nodes may have one retention 30 | policy associated 31 | """ 32 | policy = models.ForeignKey( 33 | Policy, 34 | on_delete=models.CASCADE, 35 | related_name='states', 36 | blank=True, 37 | null=True 38 | ) 39 | 40 | def delete(self): 41 | """ 42 | raise Permission denied if policy 43 | does not allow delection. 44 | """ 45 | if not self.policy: 46 | super().delete() 47 | return (1, {type(self): 1}) 48 | 49 | # if there is a policy associated 50 | # which denies permission 51 | if not self.policy.allow_delete: 52 | raise PermissionDenied() 53 | 54 | super().delete() 55 | return (1, {type(self): 1}) 56 | 57 | def __repr__(self): 58 | _i = self.id 59 | _p = self.policy 60 | _b = self.base_ptr 61 | 62 | return f"NodePart(id={_i}, policy={_p}, base_ptr={_b})" 63 | 64 | 65 | class NodeX(AbstractNode): 66 | policy_x = models.ForeignKey( 67 | Policy, 68 | on_delete=models.CASCADE, 69 | related_name='statesx', # Note: 'x' 70 | blank=True, 71 | null=True 72 | ) 73 | 74 | def delete(self): 75 | if not self.policy_x: 76 | super().delete() 77 | return (1, {type(self): 1}) 78 | 79 | # silently object deletion 80 | if not self.policy_x.allow_delete: 81 | # returning 0 as first item in tuple 82 | # will silently prevent document deletion 83 | return (0, {type(self): 0}) 84 | 85 | super().delete() 86 | return (1, {type(self): 1}) 87 | 88 | def __repr__(self): 89 | _i = self.id 90 | _p = self.policy 91 | _b = self.base_ptr 92 | 93 | return f"NodeXPart(id={_i}, policy={_p}, base_ptr={_b})" 94 | -------------------------------------------------------------------------------- /papermerge/test/parts/app_max_p/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/papermerge/test/parts/app_max_p/__init__.py -------------------------------------------------------------------------------- /papermerge/test/parts/app_max_p/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AppMaxPConfig(AppConfig): 5 | name = 'papermerge.test.parts.app_max_p' 6 | -------------------------------------------------------------------------------- /papermerge/test/parts/app_max_p/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1 on 2020-11-05 14:17 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ('core', '0027_auto_20201118_1538'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Document', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('base_ptr', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='app_max_p_document_related', related_query_name='app_max_p_documents', to='core.document')), 21 | ], 22 | options={ 23 | 'abstract': False, 24 | }, 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /papermerge/test/parts/app_max_p/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/papermerge/test/parts/app_max_p/migrations/__init__.py -------------------------------------------------------------------------------- /papermerge/test/parts/app_max_p/models.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | 3 | from papermerge.core.models import AbstractDocument 4 | 5 | 6 | class Document(AbstractDocument): 7 | """ 8 | This document part a validation of maximum number of pages per document. 9 | """ 10 | 11 | MAX_PAGES = 100 12 | 13 | def clean(self): 14 | if self.get_pagecount() > Document.MAX_PAGES: 15 | raise ValidationError({ 16 | "page_count": f"Max pages {Document.MAX_PAGES} allowed" 17 | }) 18 | -------------------------------------------------------------------------------- /papermerge/test/parts/test_parts.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.core.exceptions import ( 3 | ValidationError, 4 | PermissionDenied 5 | ) 6 | 7 | from papermerge.core.models import ( 8 | Document, 9 | Page, 10 | BaseTreeNode 11 | ) 12 | from papermerge.test.utils import create_root_user 13 | 14 | from papermerge.test.parts.app_dr.models import Policy 15 | 16 | 17 | class PartsTests(TestCase): 18 | 19 | def setUp(self): 20 | self.user = create_root_user() 21 | 22 | def test_basic(self): 23 | doc = Document.objects.create_document( 24 | file_name="test.pdf", 25 | title="Test #1", 26 | page_count=3, 27 | size="3", 28 | lang="DEU", 29 | user=self.user, 30 | parts={ 31 | "extra_special_id": "DOC_XYZ_1" 32 | } 33 | ) 34 | 35 | self.assertTrue(doc) 36 | self.assertEqual( 37 | doc.parts.extra_special_id, 38 | "DOC_XYZ_1" 39 | ) 40 | 41 | def test_create_a_simple_document(self): 42 | policy = Policy.objects.create(name="Default Policy") 43 | 44 | doc = Document.objects.create_document( 45 | file_name="test.pdf", 46 | title="Test #1", 47 | page_count=3, 48 | size="3", 49 | lang="DEU", 50 | user=self.user, 51 | parts={ 52 | 'policy': policy 53 | } 54 | ) 55 | 56 | self.assertEqual( 57 | doc.title, 58 | "Test #1" 59 | ) 60 | self.assertEqual( 61 | doc.parts.policy.name, 62 | "Default Policy" 63 | ) 64 | 65 | def test_assign_policy_after_document_creation(self): 66 | 67 | doc = Document.objects.create_document( 68 | file_name="test.pdf", 69 | size="3", 70 | lang="DEU", 71 | user=self.user, 72 | title="Test #1", 73 | page_count=3, 74 | ) 75 | 76 | self.assertEqual( 77 | doc.title, 78 | "Test #1" 79 | ) 80 | policy = Policy.objects.create( 81 | name="Default Policy" 82 | ) 83 | self.assertFalse( 84 | doc.parts.policy 85 | ) 86 | 87 | doc.parts.policy = policy 88 | doc.save() 89 | doc.refresh_from_db() 90 | 91 | dox = Document.objects.get(id=doc.id) 92 | 93 | self.assertEqual( 94 | dox.parts.policy.name, 95 | "Default Policy" 96 | ) 97 | 98 | def test_permission_denied_on_restrictive_policy(self): 99 | """ 100 | Document should not be allowed to be deleted if one 101 | document part (papermerge app) restricts this operation. 102 | 103 | Data retention policy is a good example of this behaviour. 104 | Data retention app imposes a policy that will restrict document 105 | deletion. 106 | 107 | Test single Document object deletion. 108 | """ 109 | doc = Document.objects.create_document( 110 | file_name="test.pdf", 111 | size="3", 112 | lang="DEU", 113 | user=self.user, 114 | title="Test #1", 115 | page_count=3, 116 | ) 117 | 118 | # this policy will raise Permission Denied on 119 | # deletion 120 | policy = Policy.objects.create( 121 | name="Default Policy", 122 | allow_delete=False 123 | ) 124 | 125 | doc.parts.policy = policy 126 | doc.save() 127 | 128 | dox = Document.objects.get(id=doc.id) 129 | 130 | with self.assertRaises(PermissionDenied): 131 | dox.delete() 132 | 133 | # document is still there 134 | self.assertTrue(Document.objects.get(id=doc.id)) 135 | 136 | def test_silently_object_deletion_with_policy_x(self): 137 | """ 138 | Document part may silently object the deletion by 139 | returning a tuple with first item set to 0. 140 | """ 141 | doc = Document.objects.create_document( 142 | file_name="test.pdf", 143 | size="3", 144 | lang="DEU", 145 | user=self.user, 146 | title="Test #1", 147 | page_count=3, 148 | ) 149 | 150 | # this policy will SILENTLY prevent the deletion. 151 | policy = Policy.objects.create( 152 | name="Default Policy", 153 | allow_delete=False 154 | ) 155 | 156 | doc.parts.policy_x = policy 157 | doc.save() 158 | 159 | dox = Document.objects.get(id=doc.id) 160 | 161 | # no exception raised 162 | dox.delete() 163 | 164 | # document is still there 165 | self.assertTrue(Document.objects.get(id=doc.id)) 166 | 167 | def test_silently_object_deletion_with_policy_x_BULK_delete(self): 168 | """ 169 | Create 7 documents where one has assigned policy_x (which siliently 170 | forbids deletion). 171 | Deleting in bulk is expected delete 6 documents (which do not have 172 | policy_x assigned). 173 | """ 174 | for number in range(0, 6): 175 | doc = Document.objects.create_document( 176 | file_name=f"test_{number}.pdf", 177 | size="3", 178 | lang="DEU", 179 | user=self.user, 180 | title="Test #1", 181 | page_count=3, 182 | ) 183 | doc.save() 184 | 185 | doc = Document.objects.create_document( 186 | file_name="test.pdf", 187 | size="3", 188 | lang="DEU", 189 | user=self.user, 190 | title="Test #1", 191 | page_count=3, 192 | ) 193 | 194 | # this policy will SILENTLY prevent the deletion. 195 | policy = Policy.objects.create( 196 | name="Default Policy", 197 | allow_delete=False 198 | ) 199 | doc.parts.policy_x = policy 200 | doc.save() 201 | self.assertEqual( 202 | BaseTreeNode.objects.count(), 203 | 7 204 | ) # there are 7 nodes/documents 205 | 206 | BaseTreeNode.objects.all().delete() 207 | 208 | # Because one delete silently failed 209 | self.assertEqual( 210 | BaseTreeNode.objects.count(), 211 | 1 212 | ) # there is one node ramaining (one with policy x assigned) 213 | 214 | def test_create_document_with_101_pages(self): 215 | """ 216 | test.parts.app_max_p contains a Document Part which 217 | invalidates any document with > 100 pages. 218 | 219 | Create a document with 101 pages and check that 220 | ValidationError is raised. 221 | """ 222 | self.assertFalse( 223 | Document.objects.count() 224 | ) 225 | 226 | self.assertFalse( 227 | Page.objects.count() 228 | ) 229 | 230 | with self.assertRaises(ValidationError): 231 | Document.objects.create_document( 232 | title="Invoice BRT-0001", 233 | file_name="invoice.pdf", 234 | size="3", 235 | lang="DEU", 236 | user=self.user, 237 | # test.parts.app_0.models.Document allow MAX_PAGES=100 238 | page_count=101, 239 | ) 240 | 241 | # No partially created docs were left. 242 | # If document creation failed - and its satellite models 243 | # - all transaction is rolled back 244 | self.assertFalse( 245 | Document.objects.count() 246 | ) 247 | # No pages (from not yet created document) left. 248 | # If document creation failed - all transaction is rolled back 249 | self.assertFalse( 250 | Page.objects.count() 251 | ) 252 | -------------------------------------------------------------------------------- /papermerge/test/test_automate.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.test import TestCase 3 | from papermerge.core.models import ( 4 | Automate, 5 | Folder, 6 | Document 7 | ) 8 | from papermerge.core.models.page import get_pages 9 | from papermerge.core.models.folder import get_inbox_children 10 | 11 | User = get_user_model() 12 | 13 | 14 | TEXT = """ 15 | The majority of mortals, Paulinus, complain bitterly of the spitefulness of 16 | Nature, because we are born for a brief span of life, because even this space 17 | that has been granted to us rushes by so speedily and so swiftly that all save 18 | a very few find life at an end just when they are getting ready to live. 19 | 20 | Seneca - On the shortness of life 21 | """ 22 | 23 | 24 | class TestAutomateModel(TestCase): 25 | 26 | def setUp(self): 27 | self.user = User.objects.create_user('admin') 28 | 29 | def test_automate_match_literal(self): 30 | am_1 = _create_am_literal( 31 | "1", 32 | "Paulinus", 33 | self.user 34 | ) 35 | am_2 = _create_am_literal( 36 | "2", 37 | "Cesar", 38 | self.user 39 | ) 40 | self.assertTrue( 41 | am_1.is_a_match(TEXT) 42 | ) 43 | self.assertFalse( 44 | am_2.is_a_match(TEXT) 45 | ) 46 | 47 | def test_automate_match_all(self): 48 | # should match because all words occur in 49 | # text 50 | am_1 = _create_am_all( 51 | "1", 52 | "granted life rushes", 53 | self.user 54 | ) 55 | # should not mach, because word quality 56 | # is not in TEXT 57 | am_2 = _create_am_all( 58 | "2", 59 | "granted life quality rushes", 60 | self.user 61 | ) 62 | self.assertTrue( 63 | am_1.is_a_match(TEXT) 64 | ) 65 | self.assertFalse( 66 | am_2.is_a_match(TEXT) 67 | ) 68 | 69 | def test_automate_match_any(self): 70 | # should match by word 'granted' 71 | 72 | am_1 = _create_am_any( 73 | "1", 74 | "what if granted usecase test", 75 | self.user 76 | ) 77 | # should not mach, of none of the words 78 | # is found in TEXT 79 | am_2 = _create_am_any( 80 | "2", 81 | "what if usecase test", 82 | self.user 83 | ) 84 | self.assertTrue( 85 | am_1.is_a_match(TEXT) 86 | ) 87 | self.assertFalse( 88 | am_2.is_a_match(TEXT) 89 | ) 90 | 91 | def test_automate_match_any2(self): 92 | 93 | am_1 = _create_am_any( 94 | "schnell", 95 | "schnell", 96 | self.user, 97 | is_case_sensitive=False 98 | ) 99 | result = am_1.is_a_match( 100 | """ 101 | SCHNELL 102 | 103 | IHRE FAMILIENBÄCKEREI 104 | """ 105 | ) 106 | self.assertTrue(result) 107 | 108 | def test_automate_match_any3(self): 109 | 110 | am_1 = _create_am_any( 111 | "schnell", 112 | "schnell", 113 | self.user, 114 | is_case_sensitive=True 115 | ) 116 | result = am_1.is_a_match( 117 | """ 118 | SCHNELL 119 | 120 | IHRE FAMILIENBÄCKEREI 121 | """ 122 | ) 123 | # will not match because text contains 124 | # uppercase version, while text to match is expected 125 | # to be lowercase as is_case_sensitive=True 126 | self.assertFalse(result) 127 | 128 | def test_automate_match_regex(self): 129 | # should match by word life. 130 | am_1 = _create_am_any( 131 | "1", 132 | r"l..e", 133 | self.user 134 | ) 135 | # should not mach, there no double digits 136 | # in the TEXT 137 | am_2 = _create_am_any( 138 | "2", 139 | r"\d\d", 140 | self.user 141 | ) 142 | self.assertTrue( 143 | am_1.is_a_match(TEXT) 144 | ) 145 | self.assertFalse( 146 | am_2.is_a_match(TEXT) 147 | ) 148 | 149 | def test_automate_apply(self): 150 | """ 151 | test automate.apply method 152 | """ 153 | 154 | # automates are applicable only for documents 155 | # in inbox folder 156 | folder, _ = Folder.objects.get_or_create( 157 | title=Folder.INBOX_NAME, 158 | user=self.user 159 | ) 160 | document = Document.objects.create_document( 161 | title="document_c", 162 | file_name="document_c.pdf", 163 | size='1212', 164 | lang='DEU', 165 | user=self.user, 166 | parent_id=folder.id, 167 | page_count=5, 168 | ) 169 | document2 = Document.objects.create_document( 170 | title="document_c", 171 | file_name="document_c.pdf", 172 | size='1212', 173 | lang='DEU', 174 | user=self.user, 175 | parent_id=folder.id, 176 | page_count=5, 177 | ) 178 | # automate with tags 179 | automate = _create_am_any("test", "test", self.user) 180 | automate.tags.set( 181 | "test", "one", tag_kwargs={'user': self.user} 182 | ) 183 | # make sure no exception is rised 184 | automate.apply( 185 | document=document, 186 | page_num=1, 187 | text="test", 188 | ) 189 | # without tags 190 | automate2 = _create_am_any("test2", "test", self.user) 191 | 192 | # make sure no exception is rised 193 | automate2.apply( 194 | document=document2, 195 | page_num=1, 196 | text="test", 197 | ) 198 | 199 | def test_automate_run_from_queryset(self): 200 | folder, _ = Folder.objects.get_or_create( 201 | title=Folder.INBOX_NAME, 202 | user=self.user 203 | ) 204 | Document.objects.create_document( 205 | title="document_c", 206 | file_name="document_c.pdf", 207 | size='1212', 208 | lang='DEU', 209 | user=self.user, 210 | parent_id=folder.id, 211 | page_count=2, 212 | ) 213 | doc = Document.objects.create_document( 214 | title="document_c", 215 | file_name="document_c.pdf", 216 | size='1212', 217 | lang='DEU', 218 | user=self.user, 219 | parent_id=folder.id, 220 | page_count=1, 221 | ) 222 | page = doc.pages.first() 223 | page.text = TEXT 224 | page.save() 225 | # automate with tags 226 | _create_am_any( 227 | name="test", 228 | match="Paulinus", 229 | user=self.user 230 | ) 231 | matched_automates = Automate.objects.all().run( 232 | get_pages( 233 | get_inbox_children(self.user), 234 | include_pages_with_empty_text=False 235 | ) 236 | ) 237 | 238 | self.assertEquals( 239 | matched_automates.count(), 240 | 1 241 | ) 242 | 243 | 244 | def _create_am(name, match, alg, user, **kwargs): 245 | dst_folder = Folder.objects.create( 246 | title="destination Folder", 247 | user=user 248 | ) 249 | return Automate.objects.create( 250 | name=name, 251 | match=match, 252 | matching_algorithm=alg, 253 | user=user, 254 | dst_folder=dst_folder, 255 | **kwargs 256 | ) 257 | 258 | 259 | def _create_am_any(name, match, user, **kwargs): 260 | return _create_am( 261 | name=name, 262 | match=match, 263 | alg=Automate.MATCH_ANY, 264 | user=user, 265 | **kwargs 266 | ) 267 | 268 | 269 | def _create_am_all(name, match, user): 270 | return _create_am( 271 | name=name, 272 | match=match, 273 | alg=Automate.MATCH_ALL, 274 | user=user, 275 | is_case_sensitive=False 276 | ) 277 | 278 | 279 | def _create_am_literal(name, match, user): 280 | return _create_am( 281 | name=name, 282 | match=match, 283 | alg=Automate.MATCH_LITERAL, 284 | user=user, 285 | is_case_sensitive=False 286 | ) 287 | 288 | 289 | def _create_am_regex(name, match, user): 290 | return _create_am( 291 | name=name, 292 | match=match, 293 | alg=Automate.MATCH_REGEX, 294 | user=user, 295 | is_case_sensitive=False 296 | ) 297 | -------------------------------------------------------------------------------- /papermerge/test/test_decorators.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.test import TestCase, RequestFactory 4 | 5 | from papermerge.core.views.decorators import ( 6 | smart_dump, 7 | json_response 8 | ) 9 | 10 | 11 | class TestDecorators(TestCase): 12 | def setUp(self): 13 | self.factory = RequestFactory() 14 | 15 | def test_smart_dump(self): 16 | ret = json.dumps({'msg': "OK"}) 17 | 18 | self.assertEqual( 19 | smart_dump("OK"), 20 | ret 21 | ) 22 | 23 | self.assertEqual( 24 | smart_dump({'msg': "OK"}), 25 | ret 26 | ) 27 | 28 | def test_json_response_1(self): 29 | ret = json.dumps({'msg': "OK"}) 30 | 31 | def view_ok(request): 32 | return "OK" 33 | 34 | view = json_response(view_ok) 35 | request = self.factory.get('/blah') 36 | ret = view(request) 37 | 38 | self.assertEqual( 39 | ret.status_code, 40 | 200 41 | ) 42 | 43 | self.assertEqual( 44 | json.loads(ret.content), 45 | {'msg': "OK"} 46 | ) 47 | 48 | def test_json_response_2(self): 49 | def view_ok(request): 50 | return {"key1": "value1", "key2": "value2"} 51 | 52 | view = json_response(view_ok) 53 | request = self.factory.get('/blah') 54 | ret = view(request) 55 | 56 | self.assertEqual( 57 | ret.status_code, 58 | 200 59 | ) 60 | 61 | self.assertEqual( 62 | json.loads(ret.content), 63 | {"key1": "value1", "key2": "value2"} 64 | ) 65 | 66 | def test_json_response_3(self): 67 | def view_bad_request(request): 68 | return "There was an error", 400 69 | 70 | view = json_response(view_bad_request) 71 | request = self.factory.get('/blah') 72 | ret = view(request) 73 | 74 | self.assertEqual( 75 | ret.status_code, 76 | 400 77 | ) 78 | 79 | self.assertEqual( 80 | json.loads(ret.content), 81 | {'msg': "There was an error"} 82 | ) 83 | 84 | def test_json_response_4(self): 85 | def view_bad_request(request): 86 | return {"key1": "value1"}, 400 87 | 88 | view = json_response(view_bad_request) 89 | request = self.factory.get('/blah') 90 | ret = view(request) 91 | 92 | self.assertEqual( 93 | ret.status_code, 94 | 400 95 | ) 96 | 97 | self.assertEqual( 98 | json.loads(ret.content), 99 | {"key1": "value1"} 100 | ) 101 | -------------------------------------------------------------------------------- /papermerge/test/test_document.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | from django.test import TestCase 5 | from django.core.exceptions import ValidationError 6 | 7 | from papermerge.core.import_pipeline import go_through_pipelines 8 | from papermerge.core.models import Document, Folder, Automate 9 | from papermerge.test.utils import create_root_user 10 | 11 | # points to papermerge.testing folder 12 | BASE_DIR = Path(__file__).parent 13 | 14 | 15 | class TestDocument(TestCase): 16 | 17 | def setUp(self): 18 | self.user = create_root_user() 19 | 20 | def test_tree_path(self): 21 | """ 22 | Create following structure: 23 | Folder A > Folder B > Document C 24 | and check ancestors of Document C 25 | """ 26 | 27 | folder_a = Folder.objects.create( 28 | title="folder_a", 29 | user=self.user, 30 | parent_id=None 31 | ) 32 | folder_a.save() 33 | 34 | folder_b = Folder.objects.create( 35 | title="folder_b", 36 | user=self.user, 37 | parent_id=folder_a.id 38 | ) 39 | folder_b.save() 40 | 41 | doc = Document.objects.create_document( 42 | title="document_c", 43 | file_name="document_c.pdf", 44 | size='1212', 45 | lang='DEU', 46 | user=self.user, 47 | parent_id=folder_b.id, 48 | page_count=5, 49 | ) 50 | ancestors = [ 51 | [node.title, node.id] 52 | for node in doc.get_ancestors(include_self=True) 53 | ] 54 | self.assertListEqual( 55 | ancestors, 56 | [ 57 | ['folder_a', folder_a.id], 58 | ['folder_b', folder_b.id], 59 | ['document_c', doc.id] 60 | ] 61 | ) 62 | self.assertEqual( 63 | doc.pages.count(), 64 | 5 65 | ) 66 | 67 | def test_delete_document_with_parent(self): 68 | """ 69 | Document D is child of folder F. 70 | Deleting document D, will result in... well... 71 | no more D around, but F still present. 72 | """ 73 | folder = Folder.objects.create( 74 | user=self.user, 75 | title="F" 76 | ) 77 | 78 | doc = Document.objects.create_document( 79 | title="andromeda.pdf", 80 | user=self.user, 81 | lang="ENG", 82 | file_name="andromeda.pdf", 83 | size=1222, 84 | page_count=3 85 | ) 86 | doc.parent = folder 87 | doc.save() 88 | folder.save() 89 | count = folder.get_children().count() 90 | self.assertEqual( 91 | count, 92 | 1, 93 | f"Folder {folder.title} has {count} children" 94 | ) 95 | self.assertEqual( 96 | doc.pages.count(), 97 | 3 98 | ) 99 | 100 | doc.delete() 101 | 102 | self.assertEqual( 103 | folder.get_children().count(), 104 | 0, 105 | ) 106 | 107 | with self.assertRaises(Document.DoesNotExist): 108 | Document.objects.get(title="D") 109 | 110 | def test_import_file(self): 111 | src_file_path = os.path.join( 112 | BASE_DIR, "data", "berlin.pdf" 113 | ) 114 | 115 | with open(src_file_path, 'rb') as fp: 116 | src_file = fp.read() 117 | 118 | init_kwargs = {'payload': src_file, 'processor': 'TEST'} 119 | apply_kwargs = {'skip_ocr': True, 'name': "berlin.pdf"} 120 | 121 | self.assertIsNotNone(go_through_pipelines( 122 | init_kwargs=init_kwargs, apply_kwargs=apply_kwargs)) 123 | 124 | self.assertEqual( 125 | Document.objects.filter(title="berlin.pdf").count(), 126 | 1, 127 | "Document berlin.pdf was not created." 128 | ) 129 | 130 | def test_update_text_field(self): 131 | """ 132 | basic test for doc.update_text_field() 133 | """ 134 | doc = Document.objects.create_document( 135 | title="document_c", 136 | file_name="document_c.pdf", 137 | size='1212', 138 | lang='DEU', 139 | user=self.user, 140 | parent_id=None, 141 | page_count=5, 142 | ) 143 | doc.update_text_field() 144 | 145 | def test_delete_pages(self): 146 | # Create a document with two pages 147 | src_file_path = os.path.join( 148 | BASE_DIR, "data", "berlin.pdf" 149 | ) 150 | 151 | with open(src_file_path, 'rb') as fp: 152 | src_file = fp.read() 153 | 154 | init_kwargs = {'payload': src_file, 'processor': 'TEST'} 155 | apply_kwargs = {'skip_ocr': True, 'name': "berlin.pdf"} 156 | self.assertIsNotNone(go_through_pipelines( 157 | init_kwargs=init_kwargs, apply_kwargs=apply_kwargs)) 158 | 159 | doc = Document.objects.get(title="berlin.pdf") 160 | self.assertEqual( 161 | doc.page_count, 162 | 2 163 | ) 164 | # initial version of any document is 0 165 | self.assertEqual( 166 | doc.version, 167 | 0 168 | ) 169 | 170 | doc.delete_pages( 171 | page_numbers=[1], 172 | skip_migration=True 173 | ) 174 | 175 | self.assertEqual( 176 | doc.page_count, 177 | 1 178 | ) 179 | 180 | self.assertEqual( 181 | doc.pages.count(), 182 | 1 183 | ) 184 | 185 | # version should have been incremented 186 | self.assertEqual( 187 | doc.version, 188 | 1 189 | ) 190 | 191 | def test_document_inherits_kv_from_parent_folder(self): 192 | """ 193 | Newly added focuments into the folder will inherit folder's 194 | kv metadata. 195 | """ 196 | top = Folder.objects.create( 197 | title="top", 198 | user=self.user, 199 | ) 200 | top.save() 201 | top.kv.update( 202 | [ 203 | { 204 | 'key': 'shop', 205 | 'kv_type': 'text', 206 | 'kv_format': '' 207 | }, 208 | { 209 | 'key': 'total', 210 | 'kv_type': 'money', 211 | 'kv_format': 'dd.cc' 212 | } 213 | ] 214 | ) 215 | doc = Document.objects.create_document( 216 | title="document_c", 217 | file_name="document_c.pdf", 218 | size='1212', 219 | lang='DEU', 220 | user=self.user, 221 | parent_id=top.id, 222 | page_count=5, 223 | ) 224 | doc.save() 225 | self.assertEqual(2, doc.kv.count()) 226 | self.assertEqual( 227 | set( 228 | doc.kv.typed_keys() 229 | ), 230 | set( 231 | top.kv.typed_keys() 232 | ) 233 | ) 234 | 235 | def test_document_moved_into_other_folder_inherits_kv(self): 236 | """ 237 | When a Document (e.g. named doc) is moved from one folder F1 238 | into another F2, then all metadata keys (and metadata values) 239 | of document doc may be are deleted and inherited from parent. 240 | 'May be deleted' because this replacement of metadata will 241 | not happen if new parent folder (F2) has same metadata keys 242 | as document doc. 243 | """ 244 | f1 = Folder.objects.create( 245 | title="F1", 246 | user=self.user, 247 | ) 248 | f1.save() 249 | f2 = Folder.objects.create( 250 | title="F2", 251 | user=self.user, 252 | ) 253 | f2.save() 254 | f2.kv.update( 255 | [{'key': 'shop'}, {'key': 'total'}] 256 | ) 257 | doc = Document.objects.create_document( 258 | title="document_c", 259 | file_name="document_c.pdf", 260 | size='1212', 261 | lang='DEU', 262 | user=self.user, 263 | parent_id=f1.id, 264 | page_count=5, 265 | ) 266 | doc.save() 267 | self.assertEqual(0, doc.kv.count()) 268 | 269 | # move document into the new parent 270 | Document.objects.move_node(doc, f2) 271 | 272 | # assert that metakeys were updated 273 | self.assertEqual(2, doc.kv.count()) 274 | self.assertEqual( 275 | set( 276 | doc.kv.keys() 277 | ), 278 | set( 279 | f2.kv.keys() 280 | ) 281 | ) 282 | 283 | # similarly, pages will inherit kv from their parent 284 | # document 285 | page = doc.pages.first() 286 | # assert that metakeys were updated 287 | self.assertEqual(2, page.kv.count()) 288 | self.assertEqual( 289 | set( 290 | doc.kv.keys() 291 | ), 292 | set( 293 | page.kv.keys() 294 | ) 295 | ) 296 | 297 | def test_assign_tags_from_automate_instance(self): 298 | doc = Document.objects.create_document( 299 | title="document_c", 300 | file_name="document_c.pdf", 301 | size='1212', 302 | lang='DEU', 303 | user=self.user, 304 | page_count=5, 305 | ) 306 | 307 | dst_folder = Folder.objects.create( 308 | title="destination Folder", 309 | user=self.user 310 | ) 311 | 312 | auto = Automate.objects.create( 313 | name="whatever", 314 | match="XYZ", 315 | matching_algorithm=Automate.MATCH_ALL, 316 | is_case_sensitive=False, # i.e. ignore case 317 | user=self.user, 318 | dst_folder=dst_folder 319 | ) 320 | 321 | auto.tags.add( 322 | 'invoice', 323 | 'tags', 324 | tag_kwargs={'user': self.user} 325 | ) 326 | 327 | doc.add_tags(auto.tags.all()) 328 | 329 | self.assertEquals( 330 | doc.tags.count(), 331 | 2 332 | ) 333 | 334 | self.assertEquals( 335 | set([tag.name for tag in doc.tags.all()]), 336 | set([tag.name for tag in auto.tags.all()]) 337 | ) 338 | 339 | def test_folder_validation_against_xss_titles(self): 340 | 341 | folder_a = Folder( 342 | title="XSS Folder", 343 | user=self.user, 344 | parent_id=None 345 | ) 346 | 347 | with self.assertRaises(ValidationError): 348 | folder_a.full_clean() 349 | 350 | folder_b = Folder( 351 | title="XSS", 352 | user=self.user, 353 | parent_id=None 354 | ) 355 | 356 | with self.assertRaises(ValidationError): 357 | folder_b.full_clean() 358 | -------------------------------------------------------------------------------- /papermerge/test/test_hocr.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | import json 3 | import os 4 | from pathlib import Path 5 | 6 | from django.test import TestCase 7 | from papermerge.core.lib.hocr import Hocr, OcrxWord, extract_size 8 | 9 | BASE_DIR = Path(__file__).parent 10 | 11 | 12 | class TestHocr(TestCase): 13 | def test_extract_words_from(self): 14 | hocr_file = os.path.join( 15 | BASE_DIR, 16 | "data", 17 | "page-1.hocr" 18 | ) 19 | hocr = Hocr(hocr_file_path=hocr_file) 20 | 21 | try: 22 | json.dumps({ 23 | 'hocr': hocr.good_json_words(), 24 | 'hocr_meta': hocr.get_meta() 25 | }) 26 | except TypeError: 27 | self.assertTrue( 28 | False, 29 | "Unserializable result" 30 | ) 31 | 32 | def test_extract_size_func(self): 33 | title = "image blah/blah; bbox 0 0 300 600; ppageno 0" 34 | width, height = extract_size(title) 35 | 36 | self.assertEqual( 37 | width, 38 | 300 39 | ) 40 | self.assertEqual( 41 | height, 42 | 600 43 | ) 44 | 45 | def test_extract_img_size(self): 46 | hocr_file = os.path.join( 47 | BASE_DIR, 48 | "data", 49 | "page-1.hocr" 50 | ) 51 | hocr = Hocr(hocr_file_path=hocr_file) 52 | 53 | self.assertEqual( 54 | hocr.width, 55 | 1240 56 | ) 57 | 58 | self.assertEqual( 59 | hocr.height, 60 | 1754 61 | ) 62 | 63 | def test_ocrx_bbox(self): 64 | word = OcrxWord( 65 | el_class="ocrx_word", 66 | el_id="word_1_218", 67 | title="bbox 102 448 120 457; x_wconf 38", 68 | text="Dder" 69 | ) 70 | self.assertEqual( 71 | word.x1, 102 72 | ) 73 | self.assertEqual( 74 | word.y1, 448 75 | ) 76 | self.assertEqual( 77 | word.x2, 120 78 | ) 79 | self.assertEqual( 80 | word.y2, 457 81 | ) 82 | self.assertEqual( 83 | word.wconf, 84 | 38 85 | ) 86 | 87 | def test_empty_file_hocr(self): 88 | """ 89 | If empty or invalid file is provided then 90 | json_good_words() and get_meta() 91 | will return empty list. 92 | """ 93 | file = tempfile.NamedTemporaryFile(mode="r+t") 94 | hocr = Hocr(hocr_file_path=file.name) 95 | 96 | self.assertEqual( 97 | hocr.good_json_words(), 98 | [] 99 | ) 100 | meta = hocr.get_meta() 101 | self.assertEqual( 102 | meta, 103 | { 104 | 'count_all': 0, 105 | 'bad_words': [], 106 | 'count_good': 0, 107 | 'count_bad': 0, 108 | 'count_non_empty': 0, 109 | 'count_low_wconf': 0, 110 | 'width': 0, 111 | 'height': 0, 112 | 'min_wconf': 30 113 | } 114 | ) 115 | file.close() 116 | -------------------------------------------------------------------------------- /papermerge/test/test_import_pipelines.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | import os 4 | 5 | from django.test import TestCase, override_settings 6 | 7 | from .utils import create_root_user 8 | from papermerge.core.import_pipeline import ( 9 | go_through_pipelines, 10 | IMAP, 11 | LOCAL, 12 | WEB, 13 | DefaultPipeline 14 | ) 15 | 16 | BASE_DIR = os.path.dirname(__file__) 17 | 18 | 19 | PAPERMERGE_DEFAULT_PIPELINE = [ 20 | 'papermerge.core.import_pipeline.DefaultPipeline' 21 | ] 22 | PAPERMERGE_SIMPLE_PIPELINE = [ 23 | 'papermerge.test.test_import_pipelines.PipelineOne', 24 | 'papermerge.core.import_pipeline.DefaultPipeline', 25 | ] 26 | PROCESSORS = [WEB, LOCAL, IMAP, 'TEST'] 27 | MAGIC_BYTES = [ 28 | ('pdf', b'\x25\x50\x44\x46\x2d'), 29 | ('txt', b''), 30 | ('jpg', b'\xFF\xD8\xFF\xDB') 31 | ] 32 | 33 | 34 | class TestSimplePipelineBytes(TestCase): 35 | def setUp(self): 36 | self.user = create_root_user() 37 | 38 | def make_init_kwargs(self, payload, processor): 39 | return {'payload': payload, 'processor': processor} 40 | 41 | def make_apply_kwargs(self, apply_async=False): 42 | return {'skip_ocr': True, 'apply_async': apply_async} 43 | 44 | @override_settings(PAPERMERGE_PIPELINES=PAPERMERGE_SIMPLE_PIPELINE) 45 | def test_simple_pipeline_pdf(self): 46 | file_path = os.path.join( 47 | BASE_DIR, 48 | "data", 49 | "berlin.pdf" 50 | ) 51 | with open(file_path, 'rb') as f: 52 | payload = f.read() 53 | for processor in PROCESSORS: 54 | init_kwargs = self.make_init_kwargs( 55 | payload=payload, processor=processor) 56 | apply_kwargs = self.make_apply_kwargs() 57 | doc = go_through_pipelines(init_kwargs, apply_kwargs) 58 | self.assertIsNotNone(doc) 59 | self.assertEqual(doc.name, 'test_change_name') 60 | 61 | @override_settings(PAPERMERGE_PIPELINES=PAPERMERGE_SIMPLE_PIPELINE) 62 | def test_simple_pipeline_txt(self): 63 | payload_data = ''.join(random.choices( 64 | string.ascii_uppercase + string.digits, k=100)).encode() 65 | payload = b''.join([MAGIC_BYTES[1][1], payload_data]) 66 | for processor in PROCESSORS: 67 | init_kwargs = self.make_init_kwargs( 68 | payload=payload, processor=processor) 69 | apply_kwargs = self.make_apply_kwargs() 70 | doc = go_through_pipelines(init_kwargs, apply_kwargs) 71 | self.assertIsNone(doc) 72 | 73 | @override_settings(PAPERMERGE_PIPELINES=PAPERMERGE_SIMPLE_PIPELINE) 74 | def test_simple_pipeline_jpg(self): 75 | file_path = os.path.join( 76 | BASE_DIR, 77 | "data", 78 | "page-1.jpg" 79 | ) 80 | with open(file_path, 'rb') as f: 81 | payload = f.read() 82 | for processor in PROCESSORS: 83 | init_kwargs = self.make_init_kwargs( 84 | payload=payload, processor=processor) 85 | apply_kwargs = self.make_apply_kwargs() 86 | doc = go_through_pipelines(init_kwargs, apply_kwargs) 87 | self.assertIsNotNone(doc) 88 | self.assertEqual(doc.name, 'test_change_name') 89 | 90 | 91 | class TestDefaultPipelineBytes(TestCase): 92 | def setUp(self): 93 | self.user = create_root_user() 94 | 95 | def make_init_kwargs(self, payload, processor): 96 | return {'payload': payload, 'processor': processor} 97 | 98 | def make_apply_kwargs(self, apply_async=False): 99 | return {'skip_ocr': True, 'apply_async': apply_async} 100 | 101 | @override_settings(PAPERMERGE_PIPELINES=PAPERMERGE_DEFAULT_PIPELINE) 102 | def test_default_pipeline_pdf(self): 103 | file_path = os.path.join( 104 | BASE_DIR, 105 | "data", 106 | "berlin.pdf" 107 | ) 108 | with open(file_path, 'rb') as f: 109 | payload = f.read() 110 | for processor in PROCESSORS: 111 | init_kwargs = self.make_init_kwargs( 112 | payload=payload, processor=processor) 113 | apply_kwargs = self.make_apply_kwargs() 114 | doc = go_through_pipelines(init_kwargs, apply_kwargs) 115 | self.assertIsNotNone(doc) 116 | 117 | @override_settings(PAPERMERGE_PIPELINES=PAPERMERGE_DEFAULT_PIPELINE) 118 | def test_default_pipeline_txt(self): 119 | payload_data = ''.join(random.choices( 120 | string.ascii_uppercase + string.digits, k=100)).encode() 121 | payload = b''.join([MAGIC_BYTES[1][1], payload_data]) 122 | for processor in PROCESSORS: 123 | init_kwargs = self.make_init_kwargs( 124 | payload=payload, processor=processor) 125 | apply_kwargs = self.make_apply_kwargs() 126 | doc = go_through_pipelines(init_kwargs, apply_kwargs) 127 | self.assertIsNone(doc) 128 | 129 | @override_settings(PAPERMERGE_PIPELINES=PAPERMERGE_DEFAULT_PIPELINE) 130 | def test_default_pipeline_jpg(self): 131 | file_path = os.path.join( 132 | BASE_DIR, 133 | "data", 134 | "page-1.jpg" 135 | ) 136 | with open(file_path, 'rb') as f: 137 | payload = f.read() 138 | for processor in PROCESSORS: 139 | init_kwargs = self.make_init_kwargs( 140 | payload=payload, processor=processor) 141 | apply_kwargs = self.make_apply_kwargs() 142 | doc = go_through_pipelines(init_kwargs, apply_kwargs) 143 | self.assertIsNotNone(doc) 144 | 145 | 146 | class PipelineOne(DefaultPipeline): 147 | def get_init_kwargs(self): 148 | return None 149 | 150 | def get_apply_kwargs(self): 151 | name = 'test_change_name' 152 | return {'name': name} 153 | 154 | def apply(self, **kwargs): 155 | pass 156 | -------------------------------------------------------------------------------- /papermerge/test/test_local_import.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import os 3 | import tempfile 4 | 5 | from django.test import TestCase 6 | 7 | from .utils import create_root_user 8 | from papermerge.core.importers.local import import_documents 9 | from papermerge.core.models import Document 10 | 11 | BASE_DIR = os.path.dirname(__file__) 12 | 13 | 14 | class TestLocalImporter(TestCase): 15 | def setUp(self): 16 | self.user = create_root_user() 17 | 18 | def test_pdf_local_importer(self): 19 | file_path = os.path.join( 20 | BASE_DIR, 21 | "data", 22 | "berlin.pdf" 23 | ) 24 | with tempfile.TemporaryDirectory() as tempdirname: 25 | shutil.copy(file_path, tempdirname) 26 | import_documents(tempdirname, skip_ocr=True) 27 | self.assertEqual( 28 | Document.objects.count(), 29 | 1 30 | ) 31 | 32 | def test_jpg_local_importer(self): 33 | file_path = os.path.join( 34 | BASE_DIR, 35 | "data", 36 | "page-1.jpg" 37 | ) 38 | with tempfile.TemporaryDirectory() as tempdirname: 39 | shutil.copy(file_path, tempdirname) 40 | import_documents(tempdirname, skip_ocr=True) 41 | self.assertEqual( 42 | Document.objects.count(), 43 | 1 44 | ) 45 | 46 | def test_tar_local_importer(self): 47 | file_path = os.path.join( 48 | BASE_DIR, 49 | "data", 50 | "testdata.tar" 51 | ) 52 | with tempfile.TemporaryDirectory() as tempdirname: 53 | shutil.copy(file_path, tempdirname) 54 | import_documents(tempdirname, skip_ocr=True) 55 | self.assertEqual( 56 | Document.objects.count(), 57 | 0 58 | ) 59 | 60 | def test_multiple_files_local_importer(self): 61 | file_path_tar = os.path.join( 62 | BASE_DIR, 63 | "data", 64 | "testdata.tar" 65 | ) 66 | file_path_jpg = os.path.join( 67 | BASE_DIR, 68 | "data", 69 | "page-1.jpg" 70 | ) 71 | file_path_pdf = os.path.join( 72 | BASE_DIR, 73 | "data", 74 | "berlin.pdf" 75 | ) 76 | with tempfile.TemporaryDirectory() as tempdirname: 77 | shutil.copy(file_path_pdf, tempdirname) 78 | shutil.copy(file_path_jpg, tempdirname) 79 | shutil.copy(file_path_tar, tempdirname) 80 | import_documents(tempdirname, skip_ocr=True) 81 | self.assertEqual( 82 | Document.objects.count(), 83 | 2 84 | ) 85 | -------------------------------------------------------------------------------- /papermerge/test/test_node.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from papermerge.core.models import Document, Folder, BaseTreeNode 3 | from papermerge.test.utils import create_root_user 4 | 5 | 6 | class TestNode(TestCase): 7 | 8 | """ 9 | Both methods - is_folder and is_document 10 | are defined on the node i.e. on BaseTreeNode model 11 | """ 12 | 13 | def setUp(self): 14 | self.user = create_root_user() 15 | 16 | def test_node_is_folder(self): 17 | node = Folder.objects.create( 18 | title="folder_a", 19 | user=self.user, 20 | parent_id=None 21 | ) 22 | node.save() 23 | 24 | self.assertTrue( 25 | node.is_folder() 26 | ) 27 | self.assertFalse( 28 | node.is_document() 29 | ) 30 | 31 | def test_node_is_document(self): 32 | node = Document.objects.create_document( 33 | title="document_node", 34 | file_name="document_node.pdf", 35 | size='1212', 36 | lang='DEU', 37 | user=self.user, 38 | page_count=5, 39 | ) 40 | node.save() 41 | 42 | self.assertTrue( 43 | node.is_document() 44 | ) 45 | self.assertFalse( 46 | node.is_folder() 47 | ) 48 | 49 | 50 | class TestRecursiveDelete(TestCase): 51 | """ 52 | Dedicated TestCase for very suble bug. 53 | 54 | There 2 empty folders: A and B: 55 | Home 56 | A B 57 | 58 | User uploads document to folder A: 59 | Home 60 | A B 61 | | 62 | document.pdf 63 | 64 | User moves empty folder B (Cut -> Paste) into A: 65 | Home 66 | | 67 | A 68 | / \ 69 | / \ 70 | document.pdf B 71 | 72 | Expected: User can delete folder A ( 73 | recursively this must delete document.pdf and folder B) 74 | Actual: When deleting folder A - there is a foreign key constrain error 75 | """ 76 | 77 | def setUp(self): 78 | self.user = create_root_user() 79 | 80 | def test_delete_folder_with_document(self): 81 | 82 | folder_A = Folder.objects.create( 83 | title="A", 84 | user=self.user 85 | ) 86 | 87 | folder_B = Folder.objects.create( 88 | title="B", 89 | user=self.user, 90 | ) 91 | 92 | doc = Document.objects.create_document( 93 | title="document.pdf", 94 | file_name="document.pdf", 95 | size='1212', 96 | lang='DEU', 97 | user=self.user, 98 | parent_id=folder_A.id, 99 | page_count=5, 100 | ) 101 | doc.save() 102 | 103 | BaseTreeNode.objects.move_node(folder_B, folder_A) 104 | 105 | folder_A.refresh_from_db() 106 | # at this point, folder_A will have 2 descendants: 107 | # * folder B 108 | # * document.pdf 109 | descendants_count = folder_A.get_descendants( 110 | include_self=False 111 | ).count() 112 | 113 | self.assertEqual( 114 | 2, 115 | descendants_count 116 | ) 117 | folder_A.delete() 118 | 119 | # by now everything should be deleted 120 | self.assertEqual( 121 | 0, 122 | BaseTreeNode.objects.count() 123 | ) 124 | 125 | def test_delete_folder_with_recentely_moved_in_descendant(self): 126 | """ 127 | Related to test_delete_folder_with_document. 128 | This test assert that user can delete folders with recently 129 | moved in folders. 130 | There 2 empty folders: A and B: 131 | Home 132 | A B 133 | 134 | User moves empty folder B (Cut -> Paste) into A: 135 | Home 136 | | 137 | A 138 | | 139 | | 140 | B 141 | 142 | Expected: User can delete folder A ( 143 | recursively this must delete folder B) 144 | 145 | """ 146 | folder_A = Folder.objects.create( 147 | title="A", 148 | user=self.user 149 | ) 150 | 151 | folder_B = Folder.objects.create( 152 | title="B", 153 | user=self.user, 154 | ) 155 | 156 | Folder.objects.move_node(folder_B, folder_A) 157 | 158 | folder_A.refresh_from_db() 159 | # at this point, folder_A will have 2 descendants: 160 | # * folder B 161 | # * document.pdf 162 | descendants_count = folder_A.get_descendants( 163 | include_self=False 164 | ).count() 165 | 166 | self.assertEqual( 167 | 1, 168 | descendants_count 169 | ) 170 | 171 | # no exception should be raised here. 172 | folder_A.delete() 173 | # no folders left 174 | self.assertEqual( 175 | 0, 176 | Folder.objects.count() 177 | ) 178 | 179 | def test_delete_folders_and_documents_recursively(self): 180 | """ 181 | User should be able to delete folder A in following structure: 182 | Home 183 | | 184 | | 185 | A 186 | / \ 187 | / \ 188 | doc1.pdf subfolder 189 | / \ 190 | / \ 191 | B doc2.pdf 192 | 193 | basically this test asserts correct functionality of 194 | node/folder queryset delete function 195 | """ 196 | folder_A = Folder.objects.create( 197 | title="A", 198 | user=self.user 199 | ) 200 | 201 | subfolder = Folder.objects.create( 202 | title="subfolder", 203 | user=self.user, 204 | parent_id=folder_A.id 205 | ) 206 | 207 | doc = Document.objects.create_document( 208 | title="document.pdf", 209 | file_name="document.pdf", 210 | size='1212', 211 | lang='DEU', 212 | user=self.user, 213 | parent_id=folder_A.id, 214 | page_count=5, 215 | ) 216 | doc.save() 217 | 218 | Folder.objects.create( 219 | title="B", 220 | user=self.user, 221 | parent_id=subfolder.id 222 | ) 223 | 224 | doc = Document.objects.create_document( 225 | title="document.pdf", 226 | file_name="document.pdf", 227 | size='1212', 228 | lang='DEU', 229 | user=self.user, 230 | parent_id=subfolder.id, 231 | page_count=5, 232 | ) 233 | doc.save() 234 | 235 | self.assertEqual( 236 | 5, 237 | BaseTreeNode.objects.count() 238 | ) 239 | 240 | # no exceptions here 241 | folder_A.delete() 242 | 243 | self.assertEqual( 244 | 0, 245 | BaseTreeNode.objects.count() 246 | ) 247 | -------------------------------------------------------------------------------- /papermerge/test/test_path.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from papermerge.core.lib.path import filter_by_extention 3 | 4 | 5 | class TestFolder(TestCase): 6 | def test_filter_all_good(self): 7 | all_good = [ 8 | 'document_365-page-2.jpg', 9 | 'document_365-page-3.jpg', 10 | 'document_365-page-1.jpg' 11 | ] 12 | result = filter_by_extention(all_good) 13 | # nothing should be filtered, as by dafault all 14 | # extentions are supported 15 | self.assertEqual( 16 | result, all_good 17 | ) 18 | 19 | def test_filter_out_invalid_bmp(self): 20 | files_list = [ 21 | 'document_365-page-2.jpg', 22 | 'document_365-page-3.bmp', 23 | 'document_365-page-1.bmp' 24 | ] 25 | result = filter_by_extention(files_list) 26 | self.assertEqual( 27 | result, ['document_365-page-2.jpg'] 28 | ) 29 | 30 | def test_filter_out_without_ext(self): 31 | files_list = [ 32 | 'document_365-page', 33 | 'document_365-page', 34 | 'document_365-page-1.bmp' 35 | ] 36 | result = filter_by_extention(files_list) 37 | # files without extentions will be fileted out 38 | self.assertEqual( 39 | result, [] 40 | ) 41 | -------------------------------------------------------------------------------- /papermerge/test/test_search.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.contrib.auth import get_user_model 3 | from papermerge.core.models import ( 4 | Document, Page 5 | ) 6 | 7 | from papermerge.search.backends import get_search_backend 8 | 9 | User = get_user_model() 10 | 11 | 12 | class TestPage(TestCase): 13 | 14 | def setUp(self): 15 | self.user = User.objects.create_user(username='test') 16 | 17 | def test_search_backend(self): 18 | backend = get_search_backend() 19 | backend.search("Great doc!", Document) 20 | 21 | def test_search_is_not_case_sensitive(self): 22 | """ 23 | UT to double check that search by default is NOT case sensitive 24 | """ 25 | backend = get_search_backend() 26 | 27 | doc = Document.objects.create_document( 28 | title="document_c", 29 | file_name="document_c.pdf", 30 | size='1212', 31 | lang='DEU', 32 | user=self.user, 33 | page_count=5, 34 | ) 35 | 36 | p = doc.pages.first() 37 | p.text = "search for TESTX text" 38 | p.save() 39 | 40 | result = backend.search("TESTX", Page) 41 | # it matches exact case 42 | self.assertEqual( 43 | result.count(), 1 44 | ) 45 | 46 | result_case_insensitive_match = backend.search("testX", Page) 47 | # it matches lower and upper case mix 48 | self.assertEqual( 49 | result_case_insensitive_match.count(), 1 50 | ) 51 | 52 | # no match for tst 53 | no_match = backend.search("tst", Page) 54 | self.assertEqual( 55 | no_match.count(), 0 56 | ) 57 | -------------------------------------------------------------------------------- /papermerge/test/test_search_excerpt.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from papermerge.core.templatetags.search_tags import highlight, search_excerpt 3 | 4 | # Discourses of Epictetus 5 | TEXT = """ 6 | Of things some are in our power, and others are not. In our power 7 | are opinion, movement towards a thing, desire, aversion, turning 8 | from a thing; and in a word, whatever are our acts. 9 | Not in our power are the body, property, reputation, offices 10 | (magisterial power), and in a word, whatever are not our own acts. 11 | And the things in our power are by nature free, not subject to 12 | restraint or hindrance; but the things not in our power are weak, 13 | slavish, subject to restraint, in the power of others. 14 | """ 15 | 16 | 17 | class TestSearchExcerpt(TestCase): 18 | """ This is not testing of search feature! 19 | 20 | This TestCase tests core functions of search_except_tag templatetag: 21 | 22 | * search_excerpt 23 | * highlight 24 | """ 25 | 26 | def test_search_excerpt_basic(self): 27 | 28 | result = search_excerpt( 29 | text=TEXT, 30 | phrases="weak", 31 | context_words_count=2 32 | ) 33 | 34 | self.assertEqual( 35 | result['excerpt'], 36 | "... power are weak, slavish, subject ..." 37 | ) 38 | 39 | def test_search_excerpt_two_phrases(self): 40 | result = search_excerpt( 41 | text=TEXT, 42 | phrases=["weak", "free"], 43 | context_words_count=2 44 | ) 45 | 46 | self.assertEqual( 47 | result['excerpt'], 48 | "... by nature free, not subject ..." 49 | " power are weak, slavish, subject ..." 50 | ) 51 | 52 | def test_search_excerpt_phrase_occurs_twice(self): 53 | result = search_excerpt( 54 | text=TEXT, 55 | phrases=["others"], 56 | context_words_count=2 57 | ) 58 | 59 | self.assertEqual( 60 | result['excerpt'], 61 | "... power, and others are not. ..." 62 | ) 63 | 64 | def test_highlight(self): 65 | result = highlight( 66 | text="this is a weak match", 67 | phrases=["weak"], 68 | class_name="highlighted" 69 | ) 70 | 71 | self.assertEqual( 72 | result['highlighted'], 73 | 'this is a weak match' 74 | ) 75 | -------------------------------------------------------------------------------- /papermerge/test/test_tags.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from django.test import TestCase 4 | from papermerge.core.models import ( 5 | Document, 6 | Folder, 7 | Tag 8 | ) 9 | from papermerge.test.utils import create_root_user 10 | 11 | # points to papermerge.testing folder 12 | BASE_DIR = Path(__file__).parent 13 | 14 | 15 | class TestDocument(TestCase): 16 | 17 | def setUp(self): 18 | self.user = create_root_user() 19 | 20 | def test_basic_document_tagging(self): 21 | doc = Document.objects.create_document( 22 | title="document_c", 23 | file_name="document_c.pdf", 24 | size='1212', 25 | lang='DEU', 26 | user=self.user, 27 | page_count=5, 28 | ) 29 | doc.save() 30 | 31 | # associate "invoice" and "paid" tags 32 | # boths tags belong to self.user 33 | doc.tags.add( 34 | "invoice", 35 | "paid", 36 | tag_kwargs={"user": self.user} 37 | ) 38 | 39 | # If you’re filtering on multiple tags, it’s very common to get 40 | # duplicate results, 41 | # because of the way relational databases work. Often 42 | # you’ll want to make use of the distinct() method on QuerySets. 43 | found_docs = Document.objects.filter( 44 | tags__name__in=["paid", "invoice"] 45 | ).distinct() 46 | 47 | self.assertEqual( 48 | found_docs.count(), 49 | 1 50 | ) 51 | 52 | self.assertEqual( 53 | found_docs.first().title, 54 | "document_c" 55 | ) 56 | 57 | def test_restore_multiple_tags(self): 58 | """ 59 | Given a list of dictionaries with tag 60 | attributes - add those tags to the document 61 | (eventually create core.models.Tag instances). 62 | 63 | Keep in mind that tag instances need to belong to same user as the 64 | document owner. 65 | 66 | This scenario is used in restore script (restore from backup). 67 | """ 68 | tag_attributes = [ 69 | { 70 | "bg_color": "#ff1f1f", 71 | "fg_color": "#ffffff", 72 | "name": "important", 73 | "description": "", 74 | "pinned": True 75 | }, 76 | { 77 | "bg_color": "#c41fff", 78 | "fg_color": "#FFFFFF", 79 | "name": "receipts", 80 | "description": None, 81 | "pinned": False 82 | } 83 | ] 84 | doc = Document.objects.create_document( 85 | title="document_c", 86 | file_name="document_c.pdf", 87 | size='1212', 88 | lang='DEU', 89 | user=self.user, 90 | page_count=5, 91 | ) 92 | doc.save() 93 | 94 | for attrs in tag_attributes: 95 | attrs['user'] = self.user 96 | tag = Tag.objects.create(**attrs) 97 | doc.tags.add(tag) 98 | 99 | doc.refresh_from_db() 100 | 101 | self.assertEqual( 102 | set([tag.name for tag in doc.tags.all()]), 103 | {"receipts", "important"} 104 | ) 105 | 106 | def test_basic_folder_tagging(self): 107 | folder = Folder.objects.create( 108 | title="Markus", 109 | user=self.user 110 | ) 111 | folder.tags.add( 112 | "invoices", 113 | tag_kwargs={"user": self.user} 114 | ) 115 | found_folders = Folder.objects.filter( 116 | tags__name__in=["invoices"] 117 | ) 118 | 119 | self.assertEqual( 120 | found_folders.count(), 121 | 1 122 | ) 123 | 124 | self.assertEqual( 125 | found_folders.first().title, 126 | "Markus" 127 | ) 128 | -------------------------------------------------------------------------------- /papermerge/test/test_typed_key.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from papermerge.core.models.kvstore import TypedKey 3 | 4 | 5 | class TestTypedKey(TestCase): 6 | 7 | def test_typed_key_equality_operator(self): 8 | 9 | tkey1 = TypedKey( 10 | "Steuernummer", 11 | "numeric", 12 | "dd/ddd/ddd" 13 | ) 14 | tkey2 = TypedKey( 15 | "Steuernummer", 16 | "numeric", 17 | "dd/ddd/ddd" 18 | ) 19 | self.assertEqual( 20 | tkey1, 21 | tkey2 22 | ) 23 | 24 | def test_type_key_equality_set(self): 25 | 26 | tkey1 = TypedKey( 27 | "Steuernummer", 28 | "numeric", 29 | "dd/ddd/ddd" 30 | ) 31 | tkey2 = TypedKey( 32 | "date", 33 | "date", 34 | "dd/mm/YYYY" 35 | ) 36 | tkey3 = TypedKey( 37 | "Steuernummer", 38 | "numeric", 39 | "dd/ddd/ddd" 40 | ) 41 | tkey4 = TypedKey( 42 | "date", 43 | "date", 44 | "dd/mm/YYYY" 45 | ) 46 | self.assertEqual( 47 | set([tkey1, tkey2]), 48 | set([tkey3, tkey4]) 49 | ) 50 | self.assertNotEqual( 51 | set([tkey1]), 52 | set([tkey4]) 53 | ) 54 | -------------------------------------------------------------------------------- /papermerge/test/test_utils.py: -------------------------------------------------------------------------------- 1 | import time 2 | from django.test import TestCase 3 | 4 | from papermerge.core.utils import ( 5 | Timer, 6 | filter_node_id, 7 | remove_backup_filename_id, 8 | ) 9 | 10 | from papermerge.core.models.utils import group_per_model 11 | 12 | 13 | class TestTimer(TestCase): 14 | 15 | def test_basic_timer_usage(self): 16 | """ 17 | This UT just checks that calling Timer() with a context 18 | manager does not throw any exception/error 19 | """ 20 | with Timer() as timer: 21 | time.sleep(0.15) 22 | 23 | msg = f"It took {timer} seconds to complete" 24 | 25 | self.assertTrue(msg) 26 | 27 | def test_filter_node_id(self): 28 | # invalid values of node id will be 29 | # filtered out (return None) 30 | self.assertFalse( 31 | filter_node_id("sdf") 32 | ) 33 | 34 | self.assertFalse( 35 | filter_node_id("sdf12") 36 | ) 37 | 38 | self.assertFalse( 39 | filter_node_id(-1) 40 | ) 41 | 42 | self.assertFalse( 43 | filter_node_id("-1") 44 | ) 45 | 46 | # valid values for node id will pass 47 | # and will be returned as integers 48 | self.assertEqual( 49 | filter_node_id("12"), 50 | 12 51 | ) 52 | 53 | self.assertEqual( 54 | filter_node_id(100), 55 | 100 56 | ) 57 | 58 | def test_remove_backup_filename_id(self): 59 | self.assertEqual( 60 | remove_backup_filename_id("boox__100"), 61 | "boox" 62 | ) 63 | 64 | self.assertEqual( 65 | remove_backup_filename_id("boox_123"), 66 | "boox" 67 | ) 68 | 69 | self.assertEqual( 70 | remove_backup_filename_id("60_000_000_years_ago.pdf__123"), 71 | "60_000_000_years_ago.pdf" 72 | ) 73 | 74 | self.assertEqual( 75 | remove_backup_filename_id("60____years__ago.pdf__123"), 76 | "60____years__ago.pdf" 77 | ) 78 | 79 | self.assertEqual( 80 | remove_backup_filename_id(123), 81 | 123 82 | ) 83 | 84 | self.assertEqual( 85 | remove_backup_filename_id(None), 86 | None 87 | ) 88 | 89 | 90 | # Fake classes used by TestPartsUtils.group_per_model 91 | # 92 | class FakeField: 93 | def __init__(self, name): 94 | self.name = name 95 | 96 | 97 | class FakeMeta1: 98 | 99 | def get_fields(self, include_parents): 100 | return [ 101 | FakeField("field_1"), FakeField("field_2") 102 | ] 103 | 104 | 105 | class FakeMeta2: 106 | 107 | def get_fields(self, include_parents): 108 | return [ 109 | FakeField("field_3"), FakeField("field_4") 110 | ] 111 | 112 | 113 | class FakeModel1: 114 | 115 | _meta = FakeMeta1() 116 | 117 | field_1 = FakeField("field_1") 118 | field_2 = FakeField("field_2") 119 | 120 | 121 | class FakeModel2: 122 | 123 | _meta = FakeMeta2() 124 | 125 | field_1 = FakeField("field_3") 126 | field_2 = FakeField("field_4") 127 | 128 | 129 | class TestPartsUtils(TestCase): 130 | 131 | def test_group_per_model(self): 132 | """ 133 | Asserts correct functionality of 134 | papermerge.core.models.utils.group_per_model 135 | """ 136 | kwargs = {'x': 1, 'y': 2, 'field_1': "right!"} 137 | grouped_kw = group_per_model( 138 | [FakeModel1], **kwargs 139 | ) 140 | self.assertDictEqual( 141 | grouped_kw, 142 | { 143 | FakeModel1: { 144 | 'field_1': "right!" 145 | } 146 | } 147 | ) 148 | 149 | def test_group_per_model_2(self): 150 | """ 151 | Asserts correct functionality of 152 | papermerge.core.models.utils.group_per_model 153 | """ 154 | kwargs = { 155 | 'x': 1, 156 | 'y': 2, 157 | 'field_1': "right!", 158 | 'field_3': "part_of_model2", 159 | 'field_4': "part_of_model2" 160 | } 161 | grouped_kw = group_per_model( 162 | [FakeModel1, FakeModel2], **kwargs 163 | ) 164 | self.assertDictEqual( 165 | grouped_kw, 166 | { 167 | FakeModel1: { 168 | 'field_1': "right!" 169 | }, 170 | FakeModel2: { 171 | 'field_3': "part_of_model2", 172 | 'field_4': "part_of_model2" 173 | } 174 | 175 | } 176 | ) 177 | -------------------------------------------------------------------------------- /papermerge/test/utils.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.contrib.auth.models import ( 3 | Permission 4 | ) 5 | 6 | from papermerge.core.models import ( 7 | Document, 8 | Role 9 | ) 10 | 11 | User = get_user_model() 12 | 13 | 14 | def create_root_user(): 15 | user = User.objects.create_user( 16 | 'admin', 17 | 'admin@mail.com', 18 | is_active=True, 19 | is_superuser=True, 20 | ) 21 | user.save() 22 | 23 | return user 24 | 25 | 26 | def create_margaret_user(): 27 | user = User.objects.create_user( 28 | 'margaret', 29 | 'margaret@mail.com', 30 | is_active=True, 31 | ) 32 | user.save() 33 | 34 | return user 35 | 36 | 37 | def create_user(username: str, perms: list) -> User: 38 | """ 39 | Creates a user and assigns given set of permissions. 40 | 41 | Perms is alist of permission names (as strings). 42 | For example: 43 | 44 | ['auth.view_group', 'auth.change_group'] 45 | """ 46 | user = User.objects.create_user( 47 | username, 48 | is_active=True 49 | ) 50 | 51 | role = Role.objects.create( 52 | name=f"{username}_role" 53 | ) 54 | 55 | for perm_name in perms: 56 | app_label, codename = perm_name.split('.') 57 | perm = Permission.objects.get( 58 | content_type__app_label=app_label, 59 | codename=codename 60 | ) 61 | role.permissions.add(perm) 62 | 63 | role.save() 64 | user.role = role 65 | user.save() 66 | 67 | return user 68 | 69 | 70 | def create_elizabet_user(): 71 | user = User.objects.create_user( 72 | 'elizabet', 73 | 'elizabet@mail.com', 74 | is_active=True, 75 | ) 76 | user.save() 77 | 78 | return user 79 | 80 | 81 | def create_uploader_user(): 82 | user = User.objects.create_user( 83 | 'uploader', 84 | 'uploader@mail.com', 85 | is_active=True, 86 | ) 87 | user.save() 88 | 89 | return user 90 | 91 | 92 | def create_some_doc( 93 | user, 94 | page_count=2, 95 | parent_id=None, 96 | title="document_A" 97 | ): 98 | """ 99 | Returns a (newly created) document instance. 100 | Title, file_name, size, language do not matter. 101 | """ 102 | doc = Document.objects.create_document( 103 | title=title, 104 | file_name="document_A.pdf", 105 | size='36', 106 | lang='DEU', 107 | user=user, 108 | page_count=page_count, 109 | parent_id=parent_id 110 | ) 111 | 112 | return doc 113 | -------------------------------------------------------------------------------- /papermerge/test/views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/papermerge/test/views/__init__.py -------------------------------------------------------------------------------- /papermerge/test/views/test_search_view.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.test import Client 3 | from django.urls import reverse 4 | 5 | from papermerge.core.models import Folder 6 | 7 | from papermerge.test.utils import ( 8 | create_root_user, 9 | ) 10 | 11 | 12 | class TestSearchView(TestCase): 13 | 14 | def setUp(self): 15 | 16 | self.testcase_user = create_root_user() 17 | self.client = Client() 18 | self.client.login(testcase_user=self.testcase_user) 19 | 20 | def test_search(self): 21 | ret = self.client.get( 22 | reverse('admin:search'), 23 | {'q': "ok"} 24 | ) 25 | self.assertEqual( 26 | ret.status_code, 27 | 200 28 | ) 29 | 30 | def test_search_with_matching_folders(self): 31 | """ 32 | Cover case when there is a folder match. 33 | """ 34 | Folder.objects.create( 35 | user=self.testcase_user, 36 | title="ok" 37 | ) 38 | # if there is a folder match 39 | # folders will be displayed. 40 | ret = self.client.get( 41 | reverse('admin:search'), 42 | {'q': "ok"} 43 | ) 44 | self.assertEqual( 45 | ret.status_code, 46 | 200 47 | ) 48 | -------------------------------------------------------------------------------- /papermerge/test/views/test_users_view.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.test import Client 3 | from django.urls import reverse 4 | from django.http import HttpResponseForbidden 5 | 6 | from papermerge.test.utils import ( 7 | create_root_user, 8 | create_margaret_user 9 | ) 10 | 11 | 12 | class TestUsersView(TestCase): 13 | def setUp(self): 14 | 15 | self.root_user = create_root_user() 16 | self.margaret_user = create_margaret_user() 17 | self.client = Client() 18 | 19 | def test_basic_superuser(self): 20 | """ 21 | Superuser can see listed all users 22 | """ 23 | self.client.login( 24 | testcase_user=self.root_user 25 | ) 26 | url = reverse('admin:users') 27 | 28 | ret = self.client.get(url) 29 | self.assertEqual( 30 | ret.status_code, 31 | 200 32 | ) 33 | 34 | self.assertEqual( 35 | ret.context['object_list'].count(), 36 | 2 37 | ) 38 | 39 | def test_basic_margaret(self): 40 | """ 41 | For margaret listing users is not allowed 42 | (only superusers can list other users in the system) 43 | """ 44 | self.client.login( 45 | testcase_user=self.margaret_user 46 | ) 47 | url = reverse('admin:users') 48 | 49 | ret = self.client.get(url) 50 | 51 | self.assertEqual( 52 | ret.status_code, 53 | HttpResponseForbidden.status_code 54 | ) 55 | 56 | def test_margaret_cannot_add_user(self): 57 | self.client.login( 58 | testcase_user=self.margaret_user 59 | ) 60 | url = reverse('admin:user-add') 61 | 62 | ret = self.client.get(url) 63 | 64 | self.assertEqual( 65 | ret.status_code, 66 | HttpResponseForbidden.status_code 67 | ) 68 | 69 | def test_margaret_cannot_change_user(self): 70 | self.client.login( 71 | testcase_user=self.margaret_user 72 | ) 73 | url = reverse( 74 | 'admin:user-update', 75 | args=(self.root_user.id,) 76 | ) 77 | 78 | ret = self.client.get(url) 79 | 80 | self.assertEqual( 81 | ret.status_code, 82 | HttpResponseForbidden.status_code 83 | ) 84 | 85 | def test_margaret_cannot_change_user_password(self): 86 | """ 87 | Margaret can change only her own password. To change 88 | other user's password view - access is denied. 89 | """ 90 | self.client.login( 91 | testcase_user=self.margaret_user 92 | ) 93 | url = reverse( 94 | 'core:user_change_password', 95 | args=(self.root_user.id,) 96 | ) 97 | 98 | ret = self.client.get(url) 99 | 100 | self.assertEqual( 101 | ret.status_code, 102 | HttpResponseForbidden.status_code 103 | ) 104 | -------------------------------------------------------------------------------- /papermerge/test/views/test_utils.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from papermerge.core.views.utils import ( 4 | sanitize_kvstore_list, 5 | sanitize_kvstore 6 | ) 7 | 8 | 9 | class TestCoreViewsUtils(TestCase): 10 | 11 | def test_sanitize_kvstore_correct_keys(self): 12 | input_dict = { 13 | 'id': '1', 14 | 'key': 'X', 15 | 'value': 'Y', 16 | 'kv_type': 'text', 17 | 'kv_format': 'freeform', 18 | 'kv_inherited': False 19 | } 20 | out_dict = sanitize_kvstore(input_dict) 21 | 22 | self.assertDictEqual( 23 | input_dict, 24 | out_dict 25 | ) 26 | 27 | def test_sanitize_with_invalid_keys(self): 28 | """ 29 | input dictionary contains two invalid keys 30 | """ 31 | expected_dict = { 32 | 'id': '1', 33 | 'key': 'X', 34 | 'value': 'Y', 35 | 'kv_type': 'text', 36 | 'kv_format': 'freeform', 37 | 'kv_inherited': False 38 | } 39 | 40 | input_dict = { 41 | 'id': '1', 42 | 'key': 'X', 43 | 'invalid_key_1': 2, # will be filtered out 44 | 'invalid_key_2': 4, # will be filtered out 45 | 'value': 'Y', 46 | 'kv_type': 'text', 47 | 'kv_format': 'freeform', 48 | 'kv_inherited': False 49 | } 50 | 51 | out_dict = sanitize_kvstore(input_dict) 52 | 53 | self.assertDictEqual( 54 | expected_dict, 55 | out_dict 56 | ) 57 | 58 | def test_sanitize_kvstore_list_basic(self): 59 | 60 | with self.assertRaises(ValueError): 61 | # expects a list as argument, will 62 | # raise ValueError exception otherwise 63 | sanitize_kvstore_list({'key': 'value'}) 64 | 65 | def test_sanitize_kvstore_list_all_correct_keys(self): 66 | """ 67 | input list of dictionaries contains exact list of allowed keys 68 | """ 69 | input_dict_list = [ 70 | { 71 | 'id': '1', 72 | 'key': 'X', 73 | 'value': 'Y', 74 | 'kv_type': 'text', 75 | 'kv_format': 'freeform', 76 | 'kv_inherited': False 77 | }, 78 | ] 79 | out_dict_list = sanitize_kvstore_list(input_dict_list) 80 | 81 | self.assertDictEqual( 82 | input_dict_list[0], 83 | out_dict_list[0] 84 | ) 85 | 86 | self.assertEqual( 87 | len(input_dict_list), 88 | len(out_dict_list) 89 | ) 90 | 91 | def test_sanitize_kvstore_list_with_invalid_keys(self): 92 | """ 93 | input list of dictionaries contains two invalid keys 94 | """ 95 | expected_dict_list = [ 96 | { 97 | 'id': '1', 98 | 'key': 'X', 99 | 'value': 'Y', 100 | 'kv_type': 'text', 101 | 'kv_format': 'freeform', 102 | 'kv_inherited': False 103 | }, 104 | ] 105 | input_dict_list = [ 106 | { 107 | 'id': '1', 108 | 'key': 'X', 109 | 'invalid_key_1': 2, # will be filtered out 110 | 'invalid_key_2': 4, # will be filtered out 111 | 'value': 'Y', 112 | 'kv_type': 'text', 113 | 'kv_format': 'freeform', 114 | 'kv_inherited': False 115 | }, 116 | ] 117 | out_dict_list = sanitize_kvstore_list(input_dict_list) 118 | 119 | self.assertDictEqual( 120 | expected_dict_list[0], 121 | out_dict_list[0] 122 | ) 123 | 124 | self.assertEqual( 125 | len(input_dict_list), 126 | len(expected_dict_list) 127 | ) 128 | -------------------------------------------------------------------------------- /papermerge/test/views/test_view.py: -------------------------------------------------------------------------------- 1 | import os 2 | from django.test import TestCase 3 | from django.test import Client 4 | from django.contrib.auth import get_user_model 5 | from django.urls import reverse 6 | 7 | BASE_DIR = os.path.abspath( 8 | os.path.join( 9 | os.path.dirname(__file__), 10 | ".." 11 | ) 12 | ) 13 | 14 | src_file_path = os.path.join( 15 | BASE_DIR, "data", "berlin.pdf" 16 | ) 17 | 18 | 19 | User = get_user_model() 20 | 21 | 22 | class TestBasicUpload(TestCase): 23 | 24 | def setUp(self): 25 | self.user = User.objects.create_user( 26 | username='john', 27 | is_active=True, 28 | ) 29 | self.user.set_password('test') 30 | self.user.save() 31 | self.client = Client() 32 | self.client.login( 33 | username='john', 34 | password='test' 35 | ) 36 | 37 | def test_basic_upload_invalid_input(self): 38 | ret = self.client.post( 39 | reverse('core:upload') 40 | ) 41 | # missing input file 42 | self.assertEqual(ret.status_code, 400) 43 | 44 | def test_basic_upload(self): 45 | 46 | with open(src_file_path, "rb") as fp: 47 | self.client.post( 48 | reverse('core:upload'), 49 | { 50 | 'file': fp, 51 | 'parent_id': -1, 52 | 'name': "berlin.pdf", 53 | 'language': "deu" 54 | }, 55 | ) 56 | -------------------------------------------------------------------------------- /papermerge/test/views/test_views_tags.py: -------------------------------------------------------------------------------- 1 | import json 2 | from django.test import TestCase 3 | from django.test import Client 4 | from django.urls import reverse 5 | from django.http import HttpResponseRedirect 6 | 7 | from papermerge.core.models import ( 8 | Tag, 9 | Folder 10 | ) 11 | 12 | 13 | from papermerge.test.utils import ( 14 | create_root_user, 15 | ) 16 | 17 | 18 | class TestNodesView(TestCase): 19 | def setUp(self): 20 | 21 | self.testcase_user = create_root_user() 22 | self.client = Client() 23 | self.client.login(testcase_user=self.testcase_user) 24 | 25 | def test_alltags_view(self): 26 | """ 27 | GET /alltags/ 28 | 29 | returns all tags of current user 30 | """ 31 | Tag.objects.create( 32 | user=self.testcase_user, 33 | name="tag1" 34 | ) 35 | Tag.objects.create( 36 | user=self.testcase_user, 37 | name="tag2" 38 | ) 39 | 40 | alltags_url = reverse('core:alltags') 41 | 42 | ret = self.client.get( 43 | alltags_url, 44 | content_type='application/json', 45 | HTTP_X_REQUESTED_WITH='XMLHttpRequest', 46 | ) 47 | self.assertEqual( 48 | ret.status_code, 49 | 200 50 | ) 51 | tags = json.loads(ret.content) 52 | 53 | self.assertEqual( 54 | set([ 55 | tag['name'] for tag in tags['tags'] 56 | ]), 57 | set(["tag2", "tag1"]) 58 | ) 59 | 60 | def test_validate_tags_against_xss(self): 61 | 62 | p = Folder.objects.create( 63 | title="P", 64 | user=self.testcase_user 65 | ) 66 | 67 | ret = self.client.post( 68 | reverse('core:tags', args=(p.id, )), 69 | { 70 | 'tags': [ 71 | {"name": "xss"} 72 | ] 73 | }, 74 | content_type='application/json', 75 | HTTP_X_REQUESTED_WITH='XMLHttpRequest', 76 | ) 77 | 78 | self.assertEqual( 79 | ret.status_code, 80 | 400 81 | ) 82 | 83 | def test_associate_tags_to_folder(self): 84 | 85 | p = Folder.objects.create( 86 | title="P", 87 | user=self.testcase_user 88 | ) 89 | 90 | ret = self.client.post( 91 | reverse('core:tags', args=(p.id, )), 92 | { 93 | 'tags': [ 94 | {"name": "red"}, 95 | {"name": "green"} 96 | ] 97 | }, 98 | content_type='application/json', 99 | HTTP_X_REQUESTED_WITH='XMLHttpRequest', 100 | ) 101 | 102 | self.assertEqual( 103 | ret.status_code, 104 | 200 105 | ) 106 | 107 | found_folders = Folder.objects.filter( 108 | tags__name__in=["red", "green"] 109 | ).distinct() 110 | 111 | self.assertEqual( 112 | found_folders.count(), 113 | 1 114 | ) 115 | 116 | def test_create_one_tag_in_tags_view(self): 117 | """ 118 | User create tags in tags list view (left menu - tags). 119 | Tags are created per user. 120 | """ 121 | tag_count = Tag.objects.filter( 122 | user=self.testcase_user, 123 | name="tag_x" 124 | ).count() 125 | 126 | self.assertEqual( 127 | tag_count, 128 | 0 129 | ) 130 | 131 | ret = self.client.post( 132 | reverse('admin:tag-add'), 133 | { 134 | "name": "tag_x", 135 | "fg_color": "#ffffff", 136 | "bg_color": "#c41fff" 137 | }, 138 | ) 139 | 140 | self.assertEqual( 141 | ret.status_code, 142 | HttpResponseRedirect.status_code 143 | ) 144 | 145 | tag_count = Tag.objects.filter( 146 | user=self.testcase_user, 147 | name="tag_x" 148 | ).count() 149 | 150 | self.assertEqual( 151 | tag_count, 152 | 1 153 | ) 154 | -------------------------------------------------------------------------------- /papermerge/wsignals/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/papermerge/wsignals/__init__.py -------------------------------------------------------------------------------- /papermerge/wsignals/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class WsignalsConfig(AppConfig): 5 | # Worker signals app 6 | name = 'papermerge.wsignals' 7 | label = 'wsignals' 8 | 9 | def ready(self): 10 | from papermerge.wsignals import signals # noqa 11 | -------------------------------------------------------------------------------- /papermerge/wsignals/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciur/papermerge/4eaf88479aa339e443c22fb4dba6fbdb7d10b02d/papermerge/wsignals/migrations/__init__.py -------------------------------------------------------------------------------- /papermerge/wsignals/signals.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from django.dispatch import receiver 3 | from django.utils.translation import gettext as _ 4 | 5 | from papermerge.core.signal_definitions import ( 6 | page_ocr, 7 | automates_matching, 8 | WORKER 9 | ) 10 | from papermerge.core.models import Document 11 | from papermerge.core.ocr import COMPLETE 12 | from papermerge.core.automate import apply_automates 13 | 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | # All below handlers are sent from the worker instance. 18 | # Notice that worker must have access to same DB as webapp 19 | # which might not always be the case 20 | 21 | 22 | @receiver(page_ocr, sender=WORKER) 23 | def apply_automates_handler(sender, **kwargs): 24 | """ 25 | Signal sent by the worker when HOCR file is ready i.e. 26 | OCR for page is complete. 27 | 28 | Important! Worker can be deployed on separate computer 29 | as webapp. In such case, django signals sent by the worker instance 30 | will not reach webapp. 31 | """ 32 | document_id = kwargs.get('document_id', False) 33 | page_num = kwargs.get('page_num', False) 34 | status = kwargs.get('status') 35 | 36 | if status == COMPLETE: 37 | logger.debug( 38 | f"Page hocr ready: document_id={document_id} page_num={page_num}" 39 | ) 40 | try: 41 | # will hit the database 42 | apply_automates( 43 | document_id=document_id, 44 | page_num=page_num 45 | ) 46 | except Exception as e: 47 | logger.error(f"Exception {e} in apply_automates_handler.") 48 | raise 49 | 50 | 51 | @receiver(automates_matching) 52 | def automates_matching_handler(sender, **kwargs): 53 | user_id = kwargs.get('user_id') 54 | level = kwargs.get('level') 55 | doc_id = kwargs.get('document_id') 56 | message = kwargs.get('message') 57 | page_num = kwargs.get('page_num') 58 | text = kwargs.get('text') 59 | 60 | try: 61 | # will hit the database 62 | doc = Document.objects.get(id=doc_id) 63 | except Document.DoesNotExist: 64 | try: 65 | # documment was not found, add this logging 66 | # information to UI logs as well. 67 | msg = _( 68 | "Running automates for doc_id=%(doc_id)s," 69 | " page %(page_num)s." 70 | "But in meantime document probably was deleted." 71 | ) % { 72 | 'doc_id': doc_id, 73 | 'page_num': page_num 74 | } 75 | logger.warning(msg) 76 | return 77 | except Exception as e: 78 | logger.error( 79 | f"Exception {e} in during automates_matching_handler. " 80 | ) 81 | raise 82 | 83 | document_title = doc.title 84 | 85 | log_entry_message = _( 86 | "Running automates for document %(document_title)s, page=%(page_num)s," 87 | " doc_id=%(doc_id)s. text=%(text)s" 88 | ) % { 89 | 'document_title': document_title, 90 | 'page_num': page_num, 91 | 'doc_id': doc_id, 92 | 'text': text 93 | } 94 | 95 | log_entry_message += message 96 | 97 | 98 | @receiver(page_ocr) 99 | def page_ocr_handler(sender, **kwargs): 100 | """ 101 | Nicely log starting/completion of OCRing of each page 102 | """ 103 | user_id = kwargs.get('user_id') 104 | level = kwargs.get('level') 105 | doc_id = kwargs.get('document_id') 106 | message = kwargs.get('message') 107 | page_num = kwargs.get('page_num') 108 | lang = kwargs.get('lang') 109 | status = kwargs.get('status') 110 | 111 | if status == COMPLETE: 112 | human_status = _("COMPLETE") 113 | else: 114 | human_status = _("STARTED") 115 | 116 | try: 117 | # will hit the database 118 | doc = Document.objects.get(id=doc_id) 119 | except Document.DoesNotExist: 120 | try: 121 | msg = _( 122 | "%(human_status)s OCR for doc_id=%(doc_id)s," 123 | " page %(page_num)s." 124 | "But in meantime document probably was deleted." 125 | ) % { 126 | 'human_status': human_status, 127 | 'doc_id': doc_id, 128 | 'page_num': page_num 129 | } 130 | logger.warning(msg) 131 | return 132 | except Exception as e: 133 | logger.error( 134 | f"Exception {e} during handling of page_ocr_handler" 135 | ) 136 | raise 137 | 138 | document_title = doc.title 139 | 140 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "papermerge-dms" 3 | version = "2.1.dev2" 4 | description = "Open source document management system designed for scanned documents" 5 | authors = ["Eugen Ciur "] 6 | license = "Apache 2.0 License" 7 | 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.8" 11 | django = "^3.2" 12 | papermerge-core = {git = "https://github.com/papermerge/papermerge-core.git" } 13 | configula = "^0.3.0" 14 | 15 | 16 | [build-system] 17 | requires = ["setuptools >= 40.6.0", "wheel"] 18 | build-backend = "setuptools.build_meta" -------------------------------------------------------------------------------- /requirements/documentation.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | sphinx_rtd_theme 3 | sphinx-reload -------------------------------------------------------------------------------- /requirements/extra/mysql.txt: -------------------------------------------------------------------------------- 1 | celery[redis] 2 | mysqlclient -------------------------------------------------------------------------------- /requirements/extra/pg.txt: -------------------------------------------------------------------------------- 1 | psycopg2-binary 2 | celery[redis] 3 | -------------------------------------------------------------------------------- /requirements/production.txt: -------------------------------------------------------------------------------- 1 | mod_wsgi 2 | python-memcached 3 | -------------------------------------------------------------------------------- /run_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | export DJANGO_SETTINGS_MODULE=config.settings.test 4 | 5 | ./manage.py test \ 6 | papermerge/test/ \ 7 | $@ 8 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [pycodestyle] 2 | exclude=papermerge/test/parts/app_dr/migrations/, 3 | papermerge/test/parts/app_max_p/migrations/, 4 | papermerge/test/parts/app_0/migrations/ 5 | max-line-length = 80 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup, find_packages 3 | 4 | with open("README.md", "r") as readme: 5 | README = readme.read() 6 | 7 | # allow setup.py to be run from any path 8 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) 9 | 10 | short_description = "Open source document management system (DMS)" 11 | 12 | setup( 13 | name='papermerge', 14 | version="1.5.1", 15 | packages=find_packages(include=['papermerge.*']), 16 | include_package_data=True, 17 | license='Apache 2.0 License', 18 | description=short_description, 19 | long_description=README, 20 | long_description_content_type="text/markdown", 21 | url='https://papermerge.com/', 22 | author='Eugen Ciur', 23 | author_email='eugen@papermerge.com', 24 | classifiers=[ 25 | "Programming Language :: Python :: 3", 26 | "License :: OSI Approved :: Apache Software License", 27 | "Operating System :: OS Independent", 28 | ], 29 | python_requires='>=3.7', 30 | ) 31 | --------------------------------------------------------------------------------