mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Compare commits
	
		
			1 Commits
		
	
	
		
			feature-di
			...
			feature-co
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 8ea2f56925 | 
							
								
								
									
										73
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										73
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							| @@ -17,59 +17,18 @@ env: | ||||
|   DEFAULT_PYTHON_VERSION: "3.11" | ||||
|   NLTK_DATA: "/usr/share/nltk_data" | ||||
| jobs: | ||||
|   detect-duplicate: | ||||
|     name: Detect Duplicate Run | ||||
|     runs-on: ubuntu-24.04 | ||||
|     outputs: | ||||
|       should_run: ${{ steps.check.outputs.should_run }} | ||||
|     steps: | ||||
|       - name: Check if workflow should run | ||||
|         id: check | ||||
|         uses: actions/github-script@v8 | ||||
|         with: | ||||
|           github-token: ${{ secrets.GITHUB_TOKEN }} | ||||
|           script: | | ||||
|             if (context.eventName !== 'push') { | ||||
|               core.info('Not a push event; running workflow.'); | ||||
|               core.setOutput('should_run', 'true'); | ||||
|               return; | ||||
|             } | ||||
|  | ||||
|             const ref = context.ref || ''; | ||||
|             if (!ref.startsWith('refs/heads/')) { | ||||
|               core.info('Push is not to a branch; running workflow.'); | ||||
|               core.setOutput('should_run', 'true'); | ||||
|               return; | ||||
|             } | ||||
|  | ||||
|             const branch = ref.substring('refs/heads/'.length); | ||||
|             const { owner, repo } = context.repo; | ||||
|             const prs = await github.paginate(github.rest.pulls.list, { | ||||
|               owner, | ||||
|               repo, | ||||
|               state: 'open', | ||||
|               head: `${owner}:${branch}`, | ||||
|               per_page: 100, | ||||
|             }); | ||||
|  | ||||
|             if (prs.length === 0) { | ||||
|               core.info(`No open PR found for ${branch}; running workflow.`); | ||||
|               core.setOutput('should_run', 'true'); | ||||
|             } else { | ||||
|               core.info(`Found ${prs.length} open PR(s) for ${branch}; skipping duplicate push run.`); | ||||
|               core.setOutput('should_run', 'false'); | ||||
|             } | ||||
|   pre-commit: | ||||
|     needs: | ||||
|       - detect-duplicate | ||||
|     if: needs.detect-duplicate.outputs.should_run == 'true' | ||||
|     # We want to run on external PRs, but not on our own internal PRs as they'll be run | ||||
|     # by the push to the branch. Without this if check, checks are duplicated since | ||||
|     # internal PRs match both the push and pull_request events. | ||||
|     if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository | ||||
|     name: Linting Checks | ||||
|     runs-on: ubuntu-24.04 | ||||
|     steps: | ||||
|       - name: Checkout repository | ||||
|         uses: actions/checkout@v5 | ||||
|       - name: Install python | ||||
|         uses: actions/setup-python@v6 | ||||
|         uses: actions/setup-python@v5 | ||||
|         with: | ||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||
|       - name: Check files | ||||
| @@ -84,7 +43,7 @@ jobs: | ||||
|         uses: actions/checkout@v5 | ||||
|       - name: Set up Python | ||||
|         id: setup-python | ||||
|         uses: actions/setup-python@v6 | ||||
|         uses: actions/setup-python@v5 | ||||
|         with: | ||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||
|       - name: Install uv | ||||
| @@ -138,7 +97,7 @@ jobs: | ||||
|           docker compose --file ${{ github.workspace }}/docker/compose/docker-compose.ci-test.yml up --detach | ||||
|       - name: Set up Python | ||||
|         id: setup-python | ||||
|         uses: actions/setup-python@v6 | ||||
|         uses: actions/setup-python@v5 | ||||
|         with: | ||||
|           python-version: "${{ matrix.python-version }}" | ||||
|       - name: Install uv | ||||
| @@ -183,11 +142,13 @@ jobs: | ||||
|         if: always() | ||||
|         uses: codecov/test-results-action@v1 | ||||
|         with: | ||||
|           token: ${{ secrets.CODECOV_TOKEN }} | ||||
|           flags: backend-python-${{ matrix.python-version }} | ||||
|           files: junit.xml | ||||
|       - name: Upload backend coverage to Codecov | ||||
|         uses: codecov/codecov-action@v5 | ||||
|         with: | ||||
|           token: ${{ secrets.CODECOV_TOKEN }} | ||||
|           flags: backend-python-${{ matrix.python-version }} | ||||
|           files: coverage.xml | ||||
|       - name: Stop containers | ||||
| @@ -207,7 +168,7 @@ jobs: | ||||
|         with: | ||||
|           version: 10 | ||||
|       - name: Use Node.js 20 | ||||
|         uses: actions/setup-node@v5 | ||||
|         uses: actions/setup-node@v4 | ||||
|         with: | ||||
|           node-version: 20.x | ||||
|           cache: 'pnpm' | ||||
| @@ -240,7 +201,7 @@ jobs: | ||||
|         with: | ||||
|           version: 10 | ||||
|       - name: Use Node.js 20 | ||||
|         uses: actions/setup-node@v5 | ||||
|         uses: actions/setup-node@v4 | ||||
|         with: | ||||
|           node-version: 20.x | ||||
|           cache: 'pnpm' | ||||
| @@ -263,11 +224,13 @@ jobs: | ||||
|         uses: codecov/test-results-action@v1 | ||||
|         if: always() | ||||
|         with: | ||||
|           token: ${{ secrets.CODECOV_TOKEN }} | ||||
|           flags: frontend-node-${{ matrix.node-version }} | ||||
|           directory: src-ui/ | ||||
|       - name: Upload frontend coverage to Codecov | ||||
|         uses: codecov/codecov-action@v5 | ||||
|         with: | ||||
|           token: ${{ secrets.CODECOV_TOKEN }} | ||||
|           flags: frontend-node-${{ matrix.node-version }} | ||||
|           directory: src-ui/coverage/ | ||||
|   tests-frontend-e2e: | ||||
| @@ -288,7 +251,7 @@ jobs: | ||||
|         with: | ||||
|           version: 10 | ||||
|       - name: Use Node.js 20 | ||||
|         uses: actions/setup-node@v5 | ||||
|         uses: actions/setup-node@v4 | ||||
|         with: | ||||
|           node-version: 20.x | ||||
|           cache: 'pnpm' | ||||
| @@ -331,7 +294,7 @@ jobs: | ||||
|         with: | ||||
|           version: 10 | ||||
|       - name: Use Node.js 20 | ||||
|         uses: actions/setup-node@v5 | ||||
|         uses: actions/setup-node@v4 | ||||
|         with: | ||||
|           node-version: 20.x | ||||
|           cache: 'pnpm' | ||||
| @@ -473,7 +436,7 @@ jobs: | ||||
|         uses: actions/checkout@v5 | ||||
|       - name: Set up Python | ||||
|         id: setup-python | ||||
|         uses: actions/setup-python@v6 | ||||
|         uses: actions/setup-python@v5 | ||||
|         with: | ||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||
|       - name: Install uv | ||||
| @@ -621,7 +584,7 @@ jobs: | ||||
|           ref: main | ||||
|       - name: Set up Python | ||||
|         id: setup-python | ||||
|         uses: actions/setup-python@v6 | ||||
|         uses: actions/setup-python@v5 | ||||
|         with: | ||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||
|       - name: Install uv | ||||
| @@ -653,7 +616,7 @@ jobs: | ||||
|           git commit -am "Changelog ${{ needs.publish-release.outputs.version }} - GHA" | ||||
|           git push origin ${{ needs.publish-release.outputs.version }}-changelog | ||||
|       - name: Create Pull Request | ||||
|         uses: actions/github-script@v8 | ||||
|         uses: actions/github-script@v7 | ||||
|         with: | ||||
|           script: | | ||||
|             const { repo, owner } = context.repo; | ||||
|   | ||||
							
								
								
									
										7
									
								
								.github/workflows/cleanup-tags.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.github/workflows/cleanup-tags.yml
									
									
									
									
										vendored
									
									
								
							| @@ -6,9 +6,10 @@ | ||||
| # This workflow will not trigger runs on forked repos. | ||||
| name: Cleanup Image Tags | ||||
| on: | ||||
|   workflow_dispatch: | ||||
|   schedule: | ||||
|     - cron: '0 0 * * 0' | ||||
|   delete: | ||||
|   push: | ||||
|     paths: | ||||
|       - ".github/workflows/cleanup-tags.yml" | ||||
| concurrency: | ||||
|   group: registry-tags-cleanup | ||||
|   cancel-in-progress: false | ||||
|   | ||||
							
								
								
									
										8
									
								
								.github/workflows/pr-bot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								.github/workflows/pr-bot.yml
									
									
									
									
										vendored
									
									
								
							| @@ -12,7 +12,7 @@ jobs: | ||||
|     steps: | ||||
|       - name: Label PR by file path or branch name | ||||
|         # see .github/labeler.yml for the labeler config | ||||
|         uses: actions/labeler@v6 | ||||
|         uses: actions/labeler@v5 | ||||
|         with: | ||||
|           repo-token: ${{ secrets.GITHUB_TOKEN }} | ||||
|       - name: Label by size | ||||
| @@ -26,7 +26,7 @@ jobs: | ||||
|           fail_if_xl: 'false' | ||||
|           excluded_files: /\.lock$/ /\.txt$/ ^src-ui/pnpm-lock\.yaml$ ^src-ui/messages\.xlf$ ^src/locale/en_US/LC_MESSAGES/django\.po$ | ||||
|       - name: Label by PR title | ||||
|         uses: actions/github-script@v8 | ||||
|         uses: actions/github-script@v7 | ||||
|         with: | ||||
|           script: | | ||||
|             const pr = context.payload.pull_request; | ||||
| @@ -52,7 +52,7 @@ jobs: | ||||
|             } | ||||
|       - name: Label bot-generated PRs | ||||
|         if: ${{ contains(github.actor, 'dependabot') || contains(github.actor, 'crowdin-bot') }} | ||||
|         uses: actions/github-script@v8 | ||||
|         uses: actions/github-script@v7 | ||||
|         with: | ||||
|           script: | | ||||
|             const pr = context.payload.pull_request; | ||||
| @@ -77,7 +77,7 @@ jobs: | ||||
|             } | ||||
|       - name: Welcome comment | ||||
|         if: ${{ !contains(github.actor, 'bot') }} | ||||
|         uses: actions/github-script@v8 | ||||
|         uses: actions/github-script@v7 | ||||
|         with: | ||||
|           script: | | ||||
|             const pr = context.payload.pull_request; | ||||
|   | ||||
							
								
								
									
										9
									
								
								.github/workflows/repo-maintenance.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										9
									
								
								.github/workflows/repo-maintenance.yml
									
									
									
									
										vendored
									
									
								
							| @@ -15,7 +15,7 @@ jobs: | ||||
|     if: github.repository_owner == 'paperless-ngx' | ||||
|     runs-on: ubuntu-24.04 | ||||
|     steps: | ||||
|       - uses: actions/stale@v10 | ||||
|       - uses: actions/stale@v9 | ||||
|         with: | ||||
|           days-before-stale: 7 | ||||
|           days-before-close: 14 | ||||
| @@ -57,7 +57,7 @@ jobs: | ||||
|     if: github.repository_owner == 'paperless-ngx' | ||||
|     runs-on: ubuntu-24.04 | ||||
|     steps: | ||||
|       - uses: actions/github-script@v8 | ||||
|       - uses: actions/github-script@v7 | ||||
|         with: | ||||
|           script: | | ||||
|             function sleep(ms) { | ||||
| @@ -114,7 +114,7 @@ jobs: | ||||
|     if: github.repository_owner == 'paperless-ngx' | ||||
|     runs-on: ubuntu-24.04 | ||||
|     steps: | ||||
|       - uses: actions/github-script@v8 | ||||
|       - uses: actions/github-script@v7 | ||||
|         with: | ||||
|           script: | | ||||
|             function sleep(ms) { | ||||
| @@ -206,7 +206,7 @@ jobs: | ||||
|     if: github.repository_owner == 'paperless-ngx' | ||||
|     runs-on: ubuntu-24.04 | ||||
|     steps: | ||||
|       - uses: actions/github-script@v8 | ||||
|       - uses: actions/github-script@v7 | ||||
|         with: | ||||
|           script: | | ||||
|             function sleep(ms) { | ||||
| @@ -241,7 +241,6 @@ jobs: | ||||
|                 ) { | ||||
|                   nodes { | ||||
|                     id, | ||||
|                     createdAt, | ||||
|                     number, | ||||
|                     updatedAt, | ||||
|                     upvoteCount, | ||||
|   | ||||
							
								
								
									
										4
									
								
								.github/workflows/translate-strings.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/translate-strings.yml
									
									
									
									
										vendored
									
									
								
							| @@ -17,7 +17,7 @@ jobs: | ||||
|           ref: ${{ github.head_ref }} | ||||
|       - name: Set up Python | ||||
|         id: setup-python | ||||
|         uses: actions/setup-python@v6 | ||||
|         uses: actions/setup-python@v5 | ||||
|       - name: Install system dependencies | ||||
|         run: | | ||||
|           sudo apt-get update -qq | ||||
| @@ -38,7 +38,7 @@ jobs: | ||||
|         with: | ||||
|           version: 10 | ||||
|       - name: Use Node.js 20 | ||||
|         uses: actions/setup-node@v5 | ||||
|         uses: actions/setup-node@v4 | ||||
|         with: | ||||
|           node-version: 20.x | ||||
|           cache: 'pnpm' | ||||
|   | ||||
| @@ -49,7 +49,7 @@ repos: | ||||
|           - 'prettier-plugin-organize-imports@4.1.0' | ||||
|   # Python hooks | ||||
|   - repo: https://github.com/astral-sh/ruff-pre-commit | ||||
|     rev: v0.13.2 | ||||
|     rev: v0.13.0 | ||||
|     hooks: | ||||
|       - id: ruff-check | ||||
|       - id: ruff-format | ||||
| @@ -59,7 +59,7 @@ repos: | ||||
|       - id: pyproject-fmt | ||||
|   # Dockerfile hooks | ||||
|   - repo: https://github.com/AleksaC/hadolint-py | ||||
|     rev: v2.14.0 | ||||
|     rev: v2.12.1b3 | ||||
|     hooks: | ||||
|       - id: hadolint | ||||
|   # Shell script hooks | ||||
|   | ||||
| @@ -2,11 +2,9 @@ | ||||
|  | ||||
| If you feel like contributing to the project, please do! Bug fixes and improvements are always welcome. | ||||
|  | ||||
| ⚠️ Please note: Pull requests that implement a new feature or enhancement _should almost always target an existing feature request_ with evidence of community interest and discussion. This is in order to balance the work of implementing and maintaining new features / enhancements. Pull requests that are opened without meeting this requirement may not be merged. | ||||
|  | ||||
| If you want to implement something big: | ||||
|  | ||||
| - As above, please start with a discussion! Maybe something similar is already in development and we can make it happen together. | ||||
| - Please start a discussion about that in the issues! Maybe something similar is already in development and we can make it happen together. | ||||
| - When making additions to the project, consider if the majority of users will benefit from your change. If not, you're probably better of forking the project. | ||||
| - Also consider if your change will get in the way of other users. A good change is a change that enhances the experience of some users who want that change and does not affect users who do not care about the change. | ||||
| - Please see the [paperless-ngx merge process](#merging-prs) below. | ||||
| @@ -135,7 +133,7 @@ community members. That said, in an effort to keep the repository organized and | ||||
| - Issues, pull requests and discussions that are closed will be locked after 30 days of inactivity. | ||||
| - Discussions with a marked answer will be automatically closed. | ||||
| - Discussions in the 'General' or 'Support' categories will be closed after 180 days of inactivity. | ||||
| - Feature requests that do not meet the following thresholds will be closed: 180 days of inactivity with less than 80 "up-votes", < 5 "up-votes" after 180 days, < 20 "up-votes" after 1 year or < 40 "up-votes" at 2 years. | ||||
| - Feature requests that do not meet the following thresholds will be closed: 180 days of inactivity, < 5 "up-votes" after 180 days, < 20 "up-votes" after 1 year or < 80 "up-votes" at 2 years. | ||||
|  | ||||
| In all cases, threads can be re-opened by project maintainers and, of course, users can always create a new discussion for related concerns. | ||||
| Finally, remember that all information remains searchable and 'closed' feature requests can still serve as inspiration for new features. | ||||
|   | ||||
| @@ -32,7 +32,7 @@ RUN set -eux \ | ||||
| # Purpose: Installs s6-overlay and rootfs | ||||
| # Comments: | ||||
| #  - Don't leave anything extra in here either | ||||
| FROM ghcr.io/astral-sh/uv:0.8.22-python3.12-bookworm-slim AS s6-overlay-base | ||||
| FROM ghcr.io/astral-sh/uv:0.8.17-python3.12-bookworm-slim AS s6-overlay-base | ||||
|  | ||||
| WORKDIR /usr/src/s6 | ||||
|  | ||||
|   | ||||
| @@ -32,7 +32,7 @@ services: | ||||
|     volumes: | ||||
|       - redisdata:/data | ||||
|   db: | ||||
|     image: docker.io/library/postgres:18 | ||||
|     image: docker.io/library/postgres:17 | ||||
|     restart: unless-stopped | ||||
|     volumes: | ||||
|       - pgdata:/var/lib/postgresql/data | ||||
|   | ||||
| @@ -35,7 +35,7 @@ services: | ||||
|     volumes: | ||||
|       - redisdata:/data | ||||
|   db: | ||||
|     image: docker.io/library/postgres:18 | ||||
|     image: docker.io/library/postgres:17 | ||||
|     restart: unless-stopped | ||||
|     volumes: | ||||
|       - pgdata:/var/lib/postgresql/data | ||||
|   | ||||
| @@ -31,7 +31,7 @@ services: | ||||
|     volumes: | ||||
|       - redisdata:/data | ||||
|   db: | ||||
|     image: docker.io/library/postgres:18 | ||||
|     image: docker.io/library/postgres:17 | ||||
|     restart: unless-stopped | ||||
|     volumes: | ||||
|       - pgdata:/var/lib/postgresql/data | ||||
|   | ||||
| @@ -170,11 +170,11 @@ Available options are `postgresql` and `mariadb`. | ||||
|  | ||||
|     !!! note | ||||
|  | ||||
|         A small pool is typically sufficient — for example, a size of 4. | ||||
|         Make sure your PostgreSQL server's max_connections setting is large enough to handle: | ||||
|         ```(Paperless workers + Celery workers) × pool size + safety margin``` | ||||
|         For example, with 4 Paperless workers and 2 Celery workers, and a pool size of 4: | ||||
|         (4 + 2) × 4 + 10 = 34 connections required. | ||||
|     A small pool is typically sufficient — for example, a size of 4. | ||||
|     Make sure your PostgreSQL server's max_connections setting is large enough to handle: | ||||
|     ```(Paperless workers + Celery workers) × pool size + safety margin``` | ||||
|     For example, with 4 Paperless workers and 2 Celery workers, and a pool size of 4: | ||||
|     (4 + 2) × 4 + 10 = 34 connections required. | ||||
|  | ||||
| #### [`PAPERLESS_DB_READ_CACHE_ENABLED=<bool>`](#PAPERLESS_DB_READ_CACHE_ENABLED) {#PAPERLESS_DB_READ_CACHE_ENABLED} | ||||
|  | ||||
| @@ -184,9 +184,9 @@ Available options are `postgresql` and `mariadb`. | ||||
|  | ||||
|     !!! danger | ||||
|  | ||||
|         **Do not modify the database outside the application while it is running.** | ||||
|         This includes actions such as restoring a backup, upgrading the database, or performing manual inserts. All external modifications must be done **only when the application is stopped**. | ||||
|         After making any such changes, you **must invalidate the DB read cache** using the `invalidate_cachalot` management command. | ||||
|     **Do not modify the database outside the application while it is running.** | ||||
|     This includes actions such as restoring a backup, upgrading the database, or performing manual inserts. All external modifications must be done **only when the application is stopped**. | ||||
|     After making any such changes, you **must invalidate the DB read cache** using the `invalidate_cachalot` management command. | ||||
|  | ||||
| #### [`PAPERLESS_READ_CACHE_TTL=<int>`](#PAPERLESS_READ_CACHE_TTL) {#PAPERLESS_READ_CACHE_TTL} | ||||
|  | ||||
| @@ -196,7 +196,7 @@ Available options are `postgresql` and `mariadb`. | ||||
|  | ||||
|     !!! warning | ||||
|  | ||||
|         A high TTL increases memory usage over time. Memory may be used until end of TTL, even if the cache is invalidated with the `invalidate_cachalot` command. | ||||
|     A high TTL increases memory usage over time. Memory may be used until end of TTL, even if the cache is invalidated with the `invalidate_cachalot` command. | ||||
|  | ||||
| In case of an out-of-memory (OOM) situation, Redis may stop accepting new data — including cache entries, scheduled tasks, and documents to consume. | ||||
| If your system has limited RAM, consider configuring a dedicated Redis instance for the read cache, with a memory limit and the eviction policy set to `allkeys-lru`. | ||||
| @@ -1759,11 +1759,6 @@ started by the container. | ||||
|  | ||||
| : Path to an image file in the /media/logo directory, must include 'logo', e.g. `/logo/Atari_logo.svg` | ||||
|  | ||||
| !!! note | ||||
|  | ||||
|     The logo file will be viewable by anyone with access to the Paperless instance login page, | ||||
|     so consider your choice of logo carefully and removing exif data from images before uploading. | ||||
|  | ||||
| #### [`PAPERLESS_ENABLE_UPDATE_CHECK=<bool>`](#PAPERLESS_ENABLE_UPDATE_CHECK) {#PAPERLESS_ENABLE_UPDATE_CHECK} | ||||
|  | ||||
| !!! note | ||||
|   | ||||
| @@ -261,10 +261,6 @@ different means. These are as follows: | ||||
| Paperless is set up to check your mails every 10 minutes. This can be | ||||
| configured via [`PAPERLESS_EMAIL_TASK_CRON`](configuration.md#PAPERLESS_EMAIL_TASK_CRON) | ||||
|  | ||||
| #### Processed Mail | ||||
|  | ||||
| Paperless keeps track of emails it has processed in order to avoid processing the same mail multiple times. This uses the message `UID` provided by the mail server, which should be unique for each message. You can view and manage processed mails from the web UI under Mail > Processed Mails. If you need to re-process a message, you can delete the corresponding processed mail entry, which will allow Paperless-ngx to process the email again the next time the mail fetch task runs. | ||||
|  | ||||
| #### OAuth Email Setup | ||||
|  | ||||
| Paperless-ngx supports OAuth2 authentication for Gmail and Outlook email accounts. To set up an email account with OAuth2, you will need to create a 'developer' app with the respective provider and obtain the client ID and client secret and set the appropriate [configuration variables](configuration.md#email_oauth). You will also need to set either [`PAPERLESS_OAUTH_CALLBACK_BASE_URL`](configuration.md#PAPERLESS_OAUTH_CALLBACK_BASE_URL) or [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) to the correct value for the OAuth2 flow to work correctly. | ||||
| @@ -414,7 +410,7 @@ fields and permissions, which will be merged. | ||||
|  | ||||
| #### Types {#workflow-trigger-types} | ||||
|  | ||||
| Currently, there are four events that correspond to workflow trigger 'types': | ||||
| Currently, there are three events that correspond to workflow trigger 'types': | ||||
|  | ||||
| 1. **Consumption Started**: _before_ a document is consumed, so events can include filters by source (mail, consumption | ||||
|    folder or API), file path, file name, mail rule | ||||
| @@ -427,7 +423,7 @@ Currently, there are four events that correspond to workflow trigger 'types': | ||||
|    added, created, updated date or you can specify a (date) custom field. You can also specify a day offset from the date (positive | ||||
|    offsets will trigger after the date, negative offsets will trigger before). | ||||
|  | ||||
| The following flow diagram illustrates the four document trigger types: | ||||
| The following flow diagram illustrates the three document trigger types: | ||||
|  | ||||
| ```mermaid | ||||
| flowchart TD | ||||
| @@ -637,7 +633,7 @@ When you first delete a document it is moved to the 'trash' until either it is e | ||||
| You can set how long documents remain in the trash before being automatically deleted with [`PAPERLESS_EMPTY_TRASH_DELAY`](configuration.md#PAPERLESS_EMPTY_TRASH_DELAY), which defaults | ||||
| to 30 days. Until the file is actually deleted (e.g. the trash is emptied), all files and database content remains intact and can be restored at any point up until that time. | ||||
|  | ||||
| Additionally you may configure a directory where deleted files are moved to when the trash is emptied with [`PAPERLESS_EMPTY_TRASH_DIR`](configuration.md#PAPERLESS_EMPTY_TRASH_DIR). | ||||
| Additionally you may configure a directory where deleted files are moved to when they the trash is emptied with [`PAPERLESS_EMPTY_TRASH_DIR`](configuration.md#PAPERLESS_EMPTY_TRASH_DIR). | ||||
| Note that the empty trash directory only stores the original file, the archive file and all database information is permanently removed once a document is fully deleted. | ||||
|  | ||||
| ## Best practices {#basic-searching} | ||||
|   | ||||
| @@ -30,10 +30,10 @@ dependencies = [ | ||||
|   "django-cachalot~=2.8.0", | ||||
|   "django-celery-results~=2.6.0", | ||||
|   "django-compression-middleware~=0.5.0", | ||||
|   "django-cors-headers~=4.9.0", | ||||
|   "django-cors-headers~=4.8.0", | ||||
|   "django-extensions~=4.1", | ||||
|   "django-filter~=25.1", | ||||
|   "django-guardian~=3.2.0", | ||||
|   "django-guardian~=3.1.2", | ||||
|   "django-multiselectfield~=1.0.1", | ||||
|   "django-soft-delete~=1.0.18", | ||||
|   "django-treenode>=0.23.2", | ||||
| @@ -54,6 +54,7 @@ dependencies = [ | ||||
|   "ocrmypdf~=16.11.0", | ||||
|   "pathvalidate~=3.3.1", | ||||
|   "pdf2image~=1.17.0", | ||||
|   "psycopg-pool", | ||||
|   "python-dateutil~=2.9.0", | ||||
|   "python-dotenv~=1.1.0", | ||||
|   "python-gnupg~=0.5.4", | ||||
|   | ||||
| @@ -174,7 +174,7 @@ test('bulk edit', async ({ page }) => { | ||||
|   await expect(page.locator('pngx-document-list')).toHaveText( | ||||
|     /Selected 61 of 61 documents/i | ||||
|   ) | ||||
|   await page.getByRole('button', { name: 'None' }).click() | ||||
|   await page.getByRole('button', { name: 'Cancel' }).click() | ||||
|  | ||||
|   await page.locator('pngx-document-card-small').nth(1).click() | ||||
|   await page.locator('pngx-document-card-small').nth(2).click() | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -11,17 +11,17 @@ | ||||
|   }, | ||||
|   "private": true, | ||||
|   "dependencies": { | ||||
|     "@angular/cdk": "^20.2.6", | ||||
|     "@angular/common": "~20.3.2", | ||||
|     "@angular/compiler": "~20.3.2", | ||||
|     "@angular/core": "~20.3.2", | ||||
|     "@angular/forms": "~20.3.2", | ||||
|     "@angular/localize": "~20.3.2", | ||||
|     "@angular/platform-browser": "~20.3.2", | ||||
|     "@angular/platform-browser-dynamic": "~20.3.2", | ||||
|     "@angular/router": "~20.3.2", | ||||
|     "@angular/cdk": "^20.2.2", | ||||
|     "@angular/common": "~20.2.4", | ||||
|     "@angular/compiler": "~20.2.4", | ||||
|     "@angular/core": "~20.2.4", | ||||
|     "@angular/forms": "~20.2.4", | ||||
|     "@angular/localize": "~20.2.4", | ||||
|     "@angular/platform-browser": "~20.2.4", | ||||
|     "@angular/platform-browser-dynamic": "~20.2.4", | ||||
|     "@angular/router": "~20.2.4", | ||||
|     "@ng-bootstrap/ng-bootstrap": "^19.0.1", | ||||
|     "@ng-select/ng-select": "^20.2.2", | ||||
|     "@ng-select/ng-select": "^20.1.3", | ||||
|     "@ngneat/dirty-check-forms": "^3.0.3", | ||||
|     "@popperjs/core": "^2.11.8", | ||||
|     "bootstrap": "^5.3.8", | ||||
| @@ -29,48 +29,47 @@ | ||||
|     "mime-names": "^1.0.0", | ||||
|     "ng2-pdf-viewer": "^10.4.0", | ||||
|     "ngx-bootstrap-icons": "^1.9.3", | ||||
|     "ngx-color": "^10.1.0", | ||||
|     "ngx-color": "^10.0.0", | ||||
|     "ngx-cookie-service": "^20.1.0", | ||||
|     "ngx-device-detector": "^10.1.0", | ||||
|     "ngx-ui-tour-ng-bootstrap": "^17.0.1", | ||||
|     "rxjs": "^7.8.2", | ||||
|     "tslib": "^2.8.1", | ||||
|     "utif": "^3.1.0", | ||||
|     "uuid": "^13.0.0", | ||||
|     "uuid": "^11.1.0", | ||||
|     "zone.js": "^0.15.1" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@angular-builders/custom-webpack": "^20.0.0", | ||||
|     "@angular-builders/jest": "^20.0.0", | ||||
|     "@angular-devkit/core": "^20.3.3", | ||||
|     "@angular-devkit/schematics": "^20.3.3", | ||||
|     "@angular-eslint/builder": "20.3.0", | ||||
|     "@angular-eslint/eslint-plugin": "20.3.0", | ||||
|     "@angular-eslint/eslint-plugin-template": "20.3.0", | ||||
|     "@angular-eslint/schematics": "20.3.0", | ||||
|     "@angular-eslint/template-parser": "20.3.0", | ||||
|     "@angular/build": "^20.3.3", | ||||
|     "@angular/cli": "~20.3.3", | ||||
|     "@angular/compiler-cli": "~20.3.2", | ||||
|     "@angular-devkit/core": "^20.2.2", | ||||
|     "@angular-devkit/schematics": "^20.2.2", | ||||
|     "@angular-eslint/builder": "20.2.0", | ||||
|     "@angular-eslint/eslint-plugin": "20.2.0", | ||||
|     "@angular-eslint/eslint-plugin-template": "20.2.0", | ||||
|     "@angular-eslint/schematics": "20.2.0", | ||||
|     "@angular-eslint/template-parser": "20.2.0", | ||||
|     "@angular/build": "^20.2.2", | ||||
|     "@angular/cli": "~20.2.2", | ||||
|     "@angular/compiler-cli": "~20.2.4", | ||||
|     "@codecov/webpack-plugin": "^1.9.1", | ||||
|     "@playwright/test": "^1.55.1", | ||||
|     "@playwright/test": "^1.55.0", | ||||
|     "@types/jest": "^30.0.0", | ||||
|     "@types/node": "^24.6.1", | ||||
|     "@typescript-eslint/eslint-plugin": "^8.45.0", | ||||
|     "@typescript-eslint/parser": "^8.45.0", | ||||
|     "@typescript-eslint/utils": "^8.45.0", | ||||
|     "eslint": "^9.36.0", | ||||
|     "jest": "30.2.0", | ||||
|     "jest-environment-jsdom": "^30.2.0", | ||||
|     "@types/node": "^24.3.0", | ||||
|     "@typescript-eslint/eslint-plugin": "^8.41.0", | ||||
|     "@typescript-eslint/parser": "^8.41.0", | ||||
|     "@typescript-eslint/utils": "^8.41.0", | ||||
|     "eslint": "^9.34.0", | ||||
|     "jest": "30.1.3", | ||||
|     "jest-environment-jsdom": "^30.1.2", | ||||
|     "jest-junit": "^16.0.0", | ||||
|     "jest-preset-angular": "^15.0.2", | ||||
|     "jest-preset-angular": "^15.0.0", | ||||
|     "jest-websocket-mock": "^2.5.0", | ||||
|     "prettier-plugin-organize-imports": "^4.3.0", | ||||
|     "prettier-plugin-organize-imports": "^4.2.0", | ||||
|     "ts-node": "~10.9.1", | ||||
|     "typescript": "^5.8.3", | ||||
|     "webpack": "^5.102.0" | ||||
|     "webpack": "^5.101.3" | ||||
|   }, | ||||
|   "packageManager": "pnpm@10.17.1", | ||||
|   "pnpm": { | ||||
|     "onlyBuiltDependencies": [ | ||||
|       "@parcel/watcher", | ||||
|   | ||||
							
								
								
									
										3494
									
								
								src-ui/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3494
									
								
								src-ui/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -145,14 +145,4 @@ HTMLCanvasElement.prototype.getContext = < | ||||
|   typeof HTMLCanvasElement.prototype.getContext | ||||
| >jest.fn() | ||||
|  | ||||
| jest.mock('uuid', () => ({ | ||||
|   v4: jest.fn(() => | ||||
|     'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (char: string) => { | ||||
|       const random = Math.floor(Math.random() * 16) | ||||
|       const value = char === 'x' ? random : (random & 0x3) | 0x8 | ||||
|       return value.toString(16) | ||||
|     }) | ||||
|   ), | ||||
| })) | ||||
|  | ||||
| jest.mock('pdfjs-dist') | ||||
|   | ||||
| @@ -16,7 +16,6 @@ import { | ||||
|   NgbNavItem, | ||||
| } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { throwError } from 'rxjs' | ||||
| import { routes } from 'src/app/app-routing.module' | ||||
| import { | ||||
|   PaperlessTask, | ||||
| @@ -29,7 +28,6 @@ import { PermissionsGuard } from 'src/app/guards/permissions.guard' | ||||
| import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' | ||||
| import { PermissionsService } from 'src/app/services/permissions.service' | ||||
| import { TasksService } from 'src/app/services/tasks.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { environment } from 'src/environments/environment' | ||||
| import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' | ||||
| import { PageHeaderComponent } from '../../common/page-header/page-header.component' | ||||
| @@ -125,7 +123,6 @@ describe('TasksComponent', () => { | ||||
|   let router: Router | ||||
|   let httpTestingController: HttpTestingController | ||||
|   let reloadSpy | ||||
|   let toastService: ToastService | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     TestBed.configureTestingModule({ | ||||
| @@ -160,7 +157,6 @@ describe('TasksComponent', () => { | ||||
|     httpTestingController = TestBed.inject(HttpTestingController) | ||||
|     modalService = TestBed.inject(NgbModal) | ||||
|     router = TestBed.inject(Router) | ||||
|     toastService = TestBed.inject(ToastService) | ||||
|     fixture = TestBed.createComponent(TasksComponent) | ||||
|     component = fixture.componentInstance | ||||
|     jest.useFakeTimers() | ||||
| @@ -253,42 +249,6 @@ describe('TasksComponent', () => { | ||||
|     expect(dismissSpy).toHaveBeenCalledWith(selected) | ||||
|   }) | ||||
|  | ||||
|   it('should show an error and re-enable modal buttons when dismissing multiple tasks fails', () => { | ||||
|     component.selectedTasks = new Set([tasks[0].id, tasks[1].id]) | ||||
|     const error = new Error('dismiss failed') | ||||
|     const toastSpy = jest.spyOn(toastService, 'showError') | ||||
|     const dismissSpy = jest | ||||
|       .spyOn(tasksService, 'dismissTasks') | ||||
|       .mockReturnValue(throwError(() => error)) | ||||
|  | ||||
|     let modal: NgbModalRef | ||||
|     modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1])) | ||||
|  | ||||
|     component.dismissTasks() | ||||
|     expect(modal).not.toBeUndefined() | ||||
|  | ||||
|     modal.componentInstance.confirmClicked.emit() | ||||
|  | ||||
|     expect(dismissSpy).toHaveBeenCalledWith(new Set([tasks[0].id, tasks[1].id])) | ||||
|     expect(toastSpy).toHaveBeenCalledWith('Error dismissing tasks', error) | ||||
|     expect(modal.componentInstance.buttonsEnabled).toBe(true) | ||||
|     expect(component.selectedTasks.size).toBe(0) | ||||
|   }) | ||||
|  | ||||
|   it('should show an error when dismissing a single task fails', () => { | ||||
|     const error = new Error('dismiss failed') | ||||
|     const toastSpy = jest.spyOn(toastService, 'showError') | ||||
|     const dismissSpy = jest | ||||
|       .spyOn(tasksService, 'dismissTasks') | ||||
|       .mockReturnValue(throwError(() => error)) | ||||
|  | ||||
|     component.dismissTask(tasks[0]) | ||||
|  | ||||
|     expect(dismissSpy).toHaveBeenCalledWith(new Set([tasks[0].id])) | ||||
|     expect(toastSpy).toHaveBeenCalledWith('Error dismissing task', error) | ||||
|     expect(component.selectedTasks.size).toBe(0) | ||||
|   }) | ||||
|  | ||||
|   it('should support dismiss all tasks', () => { | ||||
|     let modal: NgbModalRef | ||||
|     modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1])) | ||||
|   | ||||
| @@ -24,7 +24,6 @@ import { PaperlessTask } from 'src/app/data/paperless-task' | ||||
| import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' | ||||
| import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' | ||||
| import { TasksService } from 'src/app/services/tasks.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' | ||||
| import { PageHeaderComponent } from '../../common/page-header/page-header.component' | ||||
| import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' | ||||
| @@ -73,7 +72,6 @@ export class TasksComponent | ||||
|   tasksService = inject(TasksService) | ||||
|   private modalService = inject(NgbModal) | ||||
|   private readonly router = inject(Router) | ||||
|   private readonly toastService = inject(ToastService) | ||||
|  | ||||
|   public activeTab: TaskTab | ||||
|   public selectedTasks: Set<number> = new Set() | ||||
| @@ -156,19 +154,11 @@ export class TasksComponent | ||||
|       modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => { | ||||
|         modal.componentInstance.buttonsEnabled = false | ||||
|         modal.close() | ||||
|         this.tasksService.dismissTasks(tasks).subscribe({ | ||||
|           error: (e) => { | ||||
|             this.toastService.showError($localize`Error dismissing tasks`, e) | ||||
|             modal.componentInstance.buttonsEnabled = true | ||||
|           }, | ||||
|         }) | ||||
|         this.tasksService.dismissTasks(tasks) | ||||
|         this.clearSelection() | ||||
|       }) | ||||
|     } else { | ||||
|       this.tasksService.dismissTasks(tasks).subscribe({ | ||||
|         error: (e) => | ||||
|           this.toastService.showError($localize`Error dismissing task`, e), | ||||
|       }) | ||||
|       this.tasksService.dismissTasks(tasks) | ||||
|       this.clearSelection() | ||||
|     } | ||||
|   } | ||||
|   | ||||
| @@ -166,10 +166,13 @@ | ||||
|         </div> | ||||
|  | ||||
|         <div class="nav-group mt-3 mb-1"> | ||||
|           <h6 class="sidebar-heading px-3 text-muted"> | ||||
|           <h6 class="sidebar-heading px-3 text-muted d-flex align-items-center"> | ||||
|             <span i18n>Manage</span> | ||||
|             <button class="btn btn-link p-2 py-0" (click)="manageCollapse.toggle()"> | ||||
|               <i-bs width="0.9em" height="0.9em" [name]="isManageMenuCollapsed ? 'chevron-down' : 'chevron-up'"></i-bs> | ||||
|             </button> | ||||
|           </h6> | ||||
|           <ul class="nav flex-column mb-2"> | ||||
|           <ul class="nav flex-column mb-2" #manageCollapse="ngbCollapse" [(ngbCollapse)]="isManageMenuCollapsed"> | ||||
|             <li class="nav-item app-link" | ||||
|               *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }"> | ||||
|               <a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()" | ||||
| @@ -243,117 +246,124 @@ | ||||
|         </div> | ||||
|  | ||||
|         <div class="nav-group mt-auto mb-1"> | ||||
|           <h6 class="sidebar-heading px-3 pt-4 text-muted"> | ||||
|           <h6 class="sidebar-heading px-3 pt-4 text-muted d-flex align-items-center"> | ||||
|             <span i18n>Administration</span> | ||||
|             <button class="btn btn-link p-2 py-0" (click)="adminCollapse.toggle()"> | ||||
|               <i-bs width="0.9em" height="0.9em" [name]="isAdminMenuCollapsed ? 'chevron-down' : 'chevron-up'"></i-bs> | ||||
|             </button> | ||||
|           </h6> | ||||
|           <ul class="nav flex-column mb-2"> | ||||
|             <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }" | ||||
|               tourAnchor="tour.settings"> | ||||
|               <a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()" | ||||
|                 ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" | ||||
|                 container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> | ||||
|                 <i-bs class="me-1" name="gear"></i-bs><span> <ng-container i18n>Settings</ng-container></span> | ||||
|               </a> | ||||
|             </li> | ||||
|             <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }"> | ||||
|               <a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()" | ||||
|                 ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" | ||||
|                 container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> | ||||
|                 <i-bs class="me-1" name="sliders2-vertical"></i-bs><span> <ng-container i18n>Configuration</ng-container></span> | ||||
|               </a> | ||||
|             </li> | ||||
|             <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }"> | ||||
|               <a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()" | ||||
|                 ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" | ||||
|                 container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> | ||||
|                 <i-bs class="me-1" name="people"></i-bs><span> <ng-container i18n>Users & Groups</ng-container></span> | ||||
|               </a> | ||||
|             </li> | ||||
|             <li class="nav-item app-link" | ||||
|               *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }" | ||||
|               tourAnchor="tour.file-tasks"> | ||||
|               <a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()" | ||||
|                 ngbPopover="File Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" | ||||
|                 container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> | ||||
|                 <i-bs class="me-1" name="list-task"></i-bs><span> <ng-container i18n>File Tasks</ng-container>@if (tasksService.failedFileTasks.length > 0) { | ||||
|                   <span><span class="badge bg-danger ms-2 d-inline">{{tasksService.failedFileTasks.length}}</span></span> | ||||
|                 }</span> | ||||
|                 @if (tasksService.failedFileTasks.length > 0 && slimSidebarEnabled) { | ||||
|                   <span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.failedFileTasks.length}}</span> | ||||
|                 } | ||||
|               </a> | ||||
|             </li> | ||||
|             @if (permissionsService.isAdmin()) { | ||||
|               <li class="nav-item app-link"> | ||||
|                 <a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs" | ||||
|                   i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" | ||||
|                   triggers="mouseenter:mouseleave" popoverClass="popover-slim"> | ||||
|                   <i-bs class="me-1" name="text-left"></i-bs><span> <ng-container i18n>Logs</ng-container></span> | ||||
|           <div class="mb-2"> | ||||
|             <ul class="nav flex-column" #adminCollapse="ngbCollapse" [(ngbCollapse)]="isAdminMenuCollapsed"> | ||||
|               <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }" | ||||
|                 tourAnchor="tour.settings"> | ||||
|                 <a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()" | ||||
|                   ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" | ||||
|                   container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> | ||||
|                   <i-bs class="me-1" name="gear"></i-bs><span> <ng-container i18n>Settings</ng-container></span> | ||||
|                 </a> | ||||
|               </li> | ||||
|             } | ||||
|             <li class="nav-item mt-2" tourAnchor="tour.outro"> | ||||
|               <a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none" | ||||
|                 target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation" | ||||
|                 i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" | ||||
|                 triggers="mouseenter:mouseleave" popoverClass="popover-slim"> | ||||
|                 <i-bs class="d-flex" name="question-circle"></i-bs><span class="ms-1"> <ng-container i18n>Documentation</ng-container></span> | ||||
|               </a> | ||||
|             </li> | ||||
|             <li class="nav-item" [class.visually-hidden]="slimSidebarEnabled"> | ||||
|               <div class="px-3 py-0 text-muted small d-flex align-items-center flex-wrap"> | ||||
|                 <div class="me-3"> | ||||
|                   <a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer" | ||||
|                     href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover | ||||
|                     [disablePopover]="!slimSidebarEnabled" placement="end" container="body" | ||||
|               <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }"> | ||||
|                 <a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()" | ||||
|                   ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" | ||||
|                   container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> | ||||
|                   <i-bs class="me-1" name="sliders2-vertical"></i-bs><span> <ng-container i18n>Configuration</ng-container></span> | ||||
|                 </a> | ||||
|               </li> | ||||
|               <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }"> | ||||
|                 <a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()" | ||||
|                   ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" | ||||
|                   container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> | ||||
|                   <i-bs class="me-1" name="people"></i-bs><span> <ng-container i18n>Users & Groups</ng-container></span> | ||||
|                 </a> | ||||
|               </li> | ||||
|               <li class="nav-item app-link" | ||||
|                 *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }" | ||||
|                 tourAnchor="tour.file-tasks"> | ||||
|                 <a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()" | ||||
|                   ngbPopover="File Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" | ||||
|                   container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> | ||||
|                   <i-bs class="me-1" name="list-task"></i-bs><span> <ng-container i18n>File Tasks</ng-container>@if (tasksService.failedFileTasks.length > 0) { | ||||
|                     <span><span class="badge bg-danger ms-2 d-inline">{{tasksService.failedFileTasks.length}}</span></span> | ||||
|                   }</span> | ||||
|                   @if (tasksService.failedFileTasks.length > 0 && slimSidebarEnabled) { | ||||
|                     <span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.failedFileTasks.length}}</span> | ||||
|                   } | ||||
|                 </a> | ||||
|               </li> | ||||
|               @if (permissionsService.isAdmin()) { | ||||
|                 <li class="nav-item app-link"> | ||||
|                   <a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs" | ||||
|                     i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" | ||||
|                     triggers="mouseenter:mouseleave" popoverClass="popover-slim"> | ||||
|                     {{ versionString }} | ||||
|                     <i-bs class="me-1" name="text-left"></i-bs><span> <ng-container i18n>Logs</ng-container></span> | ||||
|                   </a> | ||||
|                 </div> | ||||
|                 @if (!settingsService.updateCheckingIsSet || appRemoteVersion) { | ||||
|                   <div class="version-check"> | ||||
|                     <ng-template #updateAvailablePopContent> | ||||
|                       <span class="small">Paperless-ngx {{ appRemoteVersion.version }} <ng-container i18n>is | ||||
|                           available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span> | ||||
|                     </ng-template> | ||||
|                     <ng-template #updateCheckingNotEnabledPopContent> | ||||
|                       <p class="small mb-2"> | ||||
|                         <ng-container i18n>Paperless-ngx can automatically check for updates</ng-container> | ||||
|                       </p> | ||||
|                       <div class="btn-group btn-group-xs flex-fill w-100"> | ||||
|                         <button class="btn btn-outline-primary" (click)="setUpdateChecking(true)">Enable</button> | ||||
|                         <button class="btn btn-outline-secondary" (click)="setUpdateChecking(false)">Disable</button> | ||||
|                       </div> | ||||
|                       <p class="small mb-0 mt-2"> | ||||
|                         <a class="small text-decoration-none fst-italic" routerLink="/settings" fragment="update-checking" i18n> | ||||
|                           How does this work? | ||||
|                         </a> | ||||
|                       </p> | ||||
|                     </ng-template> | ||||
|                     @if (settingsService.updateCheckingIsSet) { | ||||
|                       @if (appRemoteVersion.update_available) { | ||||
|                         <a class="small text-decoration-none" target="_blank" rel="noopener noreferrer" | ||||
|                           href="https://github.com/paperless-ngx/paperless-ngx/releases" | ||||
|                           [ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave" | ||||
|                 </li> | ||||
|               } | ||||
|             </ul> | ||||
|             <ul class="nav flex-column"> | ||||
|               <li class="nav-item mt-2" tourAnchor="tour.outro"> | ||||
|                 <a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none" | ||||
|                   target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation" | ||||
|                   i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" | ||||
|                   triggers="mouseenter:mouseleave" popoverClass="popover-slim"> | ||||
|                   <i-bs class="d-flex" name="question-circle"></i-bs><span class="ms-1"> <ng-container i18n>Documentation</ng-container></span> | ||||
|                 </a> | ||||
|               </li> | ||||
|               <li class="nav-item" [class.visually-hidden]="slimSidebarEnabled"> | ||||
|                 <div class="px-3 py-0 text-muted small d-flex align-items-center flex-wrap"> | ||||
|                   <div class="me-3"> | ||||
|                     <a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer" | ||||
|                       href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover | ||||
|                       [disablePopover]="!slimSidebarEnabled" placement="end" container="body" | ||||
|                       triggers="mouseenter:mouseleave" popoverClass="popover-slim"> | ||||
|                       {{ versionString }} | ||||
|                     </a> | ||||
|                   </div> | ||||
|                   @if (!settingsService.updateCheckingIsSet || appRemoteVersion) { | ||||
|                     <div class="version-check"> | ||||
|                       <ng-template #updateAvailablePopContent> | ||||
|                         <span class="small">Paperless-ngx {{ appRemoteVersion.version }} <ng-container i18n>is | ||||
|                             available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span> | ||||
|                       </ng-template> | ||||
|                       <ng-template #updateCheckingNotEnabledPopContent> | ||||
|                         <p class="small mb-2"> | ||||
|                           <ng-container i18n>Paperless-ngx can automatically check for updates</ng-container> | ||||
|                         </p> | ||||
|                         <div class="btn-group btn-group-xs flex-fill w-100"> | ||||
|                           <button class="btn btn-outline-primary" (click)="setUpdateChecking(true)">Enable</button> | ||||
|                           <button class="btn btn-outline-secondary" (click)="setUpdateChecking(false)">Disable</button> | ||||
|                         </div> | ||||
|                         <p class="small mb-0 mt-2"> | ||||
|                           <a class="small text-decoration-none fst-italic" routerLink="/settings" fragment="update-checking" i18n> | ||||
|                             How does this work? | ||||
|                           </a> | ||||
|                         </p> | ||||
|                       </ng-template> | ||||
|                       @if (settingsService.updateCheckingIsSet) { | ||||
|                         @if (appRemoteVersion.update_available) { | ||||
|                           <a class="small text-decoration-none" target="_blank" rel="noopener noreferrer" | ||||
|                             href="https://github.com/paperless-ngx/paperless-ngx/releases" | ||||
|                             [ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave" | ||||
|                             container="body"> | ||||
|                             <i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs> | ||||
|                             @if (appRemoteVersion?.update_available) { | ||||
|                                <ng-container i18n>Update available</ng-container> | ||||
|                             } | ||||
|                           </a> | ||||
|                         } | ||||
|                       } @else { | ||||
|                         <a  *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }" class="small text-decoration-none" routerLink="/settings" fragment="update-checking" | ||||
|                           [ngbPopover]="updateCheckingNotEnabledPopContent" popoverClass="shadow" triggers="mouseenter" | ||||
|                           container="body"> | ||||
|                           <i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs> | ||||
|                           @if (appRemoteVersion?.update_available) { | ||||
|                              <ng-container i18n>Update available</ng-container> | ||||
|                           } | ||||
|                         </a> | ||||
|                       } | ||||
|                     } @else { | ||||
|                       <a  *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }" class="small text-decoration-none" routerLink="/settings" fragment="update-checking" | ||||
|                         [ngbPopover]="updateCheckingNotEnabledPopContent" popoverClass="shadow" triggers="mouseenter" | ||||
|                         container="body"> | ||||
|                         <i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs> | ||||
|                       </a> | ||||
|                     } | ||||
|                   </div> | ||||
|                 } | ||||
|               </div> | ||||
|             </li> | ||||
|           </ul> | ||||
|                     </div> | ||||
|                   } | ||||
|                 </div> | ||||
|               </li> | ||||
|             </ul> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </nav> | ||||
|   | ||||
| @@ -89,6 +89,8 @@ export class AppFrameComponent | ||||
|   appRemoteVersion: AppRemoteVersion | ||||
|  | ||||
|   isMenuCollapsed: boolean = true | ||||
|   isManageMenuCollapsed: boolean = false | ||||
|   isAdminMenuCollapsed: boolean = false | ||||
|  | ||||
|   slimSidebarAnimating: boolean = false | ||||
|  | ||||
|   | ||||
| @@ -41,3 +41,9 @@ | ||||
|     min-width: 140px; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .btn-group-xs { | ||||
|   > .btn { | ||||
|     border-radius: 0.15rem; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -177,16 +177,10 @@ export class CustomFieldEditDialogComponent | ||||
|   } | ||||
|  | ||||
|   public removeSelectOption(index: number) { | ||||
|     const globalIndex = | ||||
|       index + (this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE | ||||
|     this._allSelectOptions.splice(globalIndex, 1) | ||||
|  | ||||
|     const totalPages = Math.max( | ||||
|       1, | ||||
|       Math.ceil(this._allSelectOptions.length / SELECT_OPTION_PAGE_SIZE) | ||||
|     this.selectOptions.removeAt(index) | ||||
|     this._allSelectOptions.splice( | ||||
|       index + (this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE, | ||||
|       1 | ||||
|     ) | ||||
|     const targetPage = Math.min(this.selectOptionsPage, totalPages) | ||||
|  | ||||
|     this.selectOptionsPage = targetPage | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,18 +1,19 @@ | ||||
| <div class="mb-3"> | ||||
|   @if (title) { | ||||
|     <label class="form-label" [for]="inputId">{{title}}</label> | ||||
|     <label [for]="inputId">{{title}}</label> | ||||
|   } | ||||
|  | ||||
|   <div class="input-group" [class.is-invalid]="error"> | ||||
|     <button type="button" class="input-group-text" [style.background-color]="value" (click)="colorPicker.toggle()">   </button> | ||||
|     <span class="input-group-text" [style.background-color]="value">   </span> | ||||
|  | ||||
|     <ng-template #popContent> | ||||
|       <div style="min-width: 200px;" class="pb-3"> | ||||
|         <color-slider [color]="value" (onChangeComplete)="colorChanged($event.color.hex)"></color-slider> | ||||
|       </div> | ||||
|  | ||||
|     </ng-template> | ||||
|  | ||||
|     <input #inputField class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [autoClose]="'outside'" [ngbPopover]="popContent" #colorPicker="ngbPopover" placement="bottom" popoverClass="shadow"> | ||||
|     <input #inputField class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [autoClose]="'outside'" [ngbPopover]="popContent" placement="bottom" popoverClass="shadow"> | ||||
|  | ||||
|     <button class="btn btn-outline-secondary" type="button" (click)="randomize()"> | ||||
|       <i-bs name="dice5"></i-bs> | ||||
|   | ||||
| @@ -42,8 +42,8 @@ describe('ColorComponent', () => { | ||||
|   }) | ||||
|  | ||||
|   it('should set swatch color', () => { | ||||
|     const swatch: HTMLButtonElement = fixture.nativeElement.querySelector( | ||||
|       'button.input-group-text' | ||||
|     const swatch: HTMLSpanElement = fixture.nativeElement.querySelector( | ||||
|       'span.input-group-text' | ||||
|     ) | ||||
|     expect(swatch.style.backgroundColor).toEqual('') | ||||
|     component.value = '#ff0000' | ||||
|   | ||||
| @@ -1,10 +1,7 @@ | ||||
| <div class="row pt-3 pb-3 pb-md-2 align-items-center"> | ||||
|   <div class="col-md text-truncate"> | ||||
|     <h3 class="text-truncate d-flex align-items-center" style="line-height: 1.4"> | ||||
|     <h3 class="text-truncate" style="line-height: 1.4"> | ||||
|       {{title}} | ||||
|       @if (id) { | ||||
|         <span class="badge bg-primary text-primary-text-contrast ms-2 small fs-normal">ID: {{id}}</span> | ||||
|       } | ||||
|       @if (subTitle) { | ||||
|         <span class="h6 mb-0 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle}}</span> | ||||
|       } | ||||
|   | ||||
| @@ -1,10 +1,5 @@ | ||||
| h3 { | ||||
|     min-height: calc(1.325rem + 0.9vw); | ||||
|  | ||||
|     .badge { | ||||
|         font-size: 0.65rem; | ||||
|         line-height: 1; | ||||
|     } | ||||
| } | ||||
|  | ||||
| @media (min-width: 1200px) { | ||||
|   | ||||
| @@ -26,9 +26,6 @@ export class PageHeaderComponent { | ||||
|     return this._title | ||||
|   } | ||||
|  | ||||
|   @Input() | ||||
|   id: number | ||||
|  | ||||
|   @Input() | ||||
|   subTitle: string = '' | ||||
|  | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| <pngx-page-header [(title)]="title" [id]="documentId"> | ||||
| <pngx-page-header [(title)]="title"> | ||||
|   @if (archiveContentRenderType === ContentRenderType.PDF && !useNativePdfViewer) { | ||||
|     @if (previewNumPages) { | ||||
|       <div class="input-group input-group-sm d-none d-md-flex"> | ||||
|   | ||||
| @@ -1212,7 +1212,7 @@ describe('DocumentDetailComponent', () => { | ||||
|   it('should support keyboard shortcuts', () => { | ||||
|     initNormally() | ||||
|  | ||||
|     const hasNextSpy = jest.spyOn(component, 'hasNext').mockReturnValue(true) | ||||
|     jest.spyOn(component, 'hasNext').mockReturnValue(true) | ||||
|     const nextSpy = jest.spyOn(component, 'nextDoc') | ||||
|     document.dispatchEvent( | ||||
|       new KeyboardEvent('keydown', { key: 'arrowright', ctrlKey: true }) | ||||
| @@ -1226,32 +1226,21 @@ describe('DocumentDetailComponent', () => { | ||||
|     ) | ||||
|     expect(prevSpy).toHaveBeenCalled() | ||||
|  | ||||
|     const isDirtySpy = jest | ||||
|       .spyOn(openDocumentsService, 'isDirty') | ||||
|       .mockReturnValue(true) | ||||
|     jest.spyOn(openDocumentsService, 'isDirty').mockReturnValue(true) | ||||
|     const saveSpy = jest.spyOn(component, 'save') | ||||
|     document.dispatchEvent( | ||||
|       new KeyboardEvent('keydown', { key: 's', ctrlKey: true }) | ||||
|     ) | ||||
|     expect(saveSpy).toHaveBeenCalled() | ||||
|  | ||||
|     hasNextSpy.mockReturnValue(true) | ||||
|     jest.spyOn(openDocumentsService, 'isDirty').mockReturnValue(true) | ||||
|     jest.spyOn(component, 'hasNext').mockReturnValue(true) | ||||
|     const saveNextSpy = jest.spyOn(component, 'saveEditNext') | ||||
|     document.dispatchEvent( | ||||
|       new KeyboardEvent('keydown', { key: 's', ctrlKey: true, shiftKey: true }) | ||||
|     ) | ||||
|     expect(saveNextSpy).toHaveBeenCalled() | ||||
|  | ||||
|     saveSpy.mockClear() | ||||
|     saveNextSpy.mockClear() | ||||
|     isDirtySpy.mockReturnValue(true) | ||||
|     hasNextSpy.mockReturnValue(false) | ||||
|     document.dispatchEvent( | ||||
|       new KeyboardEvent('keydown', { key: 's', ctrlKey: true, shiftKey: true }) | ||||
|     ) | ||||
|     expect(saveNextSpy).not.toHaveBeenCalled() | ||||
|     expect(saveSpy).toHaveBeenCalledWith(true) | ||||
|  | ||||
|     const closeSpy = jest.spyOn(component, 'close') | ||||
|     document.dispatchEvent(new KeyboardEvent('keydown', { key: 'escape' })) | ||||
|     expect(closeSpy).toHaveBeenCalled() | ||||
|   | ||||
| @@ -615,10 +615,7 @@ export class DocumentDetailComponent | ||||
|       }) | ||||
|       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
|       .subscribe(() => { | ||||
|         if (this.openDocumentService.isDirty(this.document)) { | ||||
|           if (this.hasNext()) this.saveEditNext() | ||||
|           else this.save(true) | ||||
|         } | ||||
|         if (this.openDocumentService.isDirty(this.document)) this.saveEditNext() | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -1,144 +1,161 @@ | ||||
| <div class="d-flex flex-wrap gap-4"> | ||||
|   <div class="d-flex flex-wrap align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"> | ||||
|     <label class="me-2" i18n>Edit:</label> | ||||
|     @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) { | ||||
|       <pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title | ||||
|         filterPlaceholder="Filter tags" i18n-filterPlaceholder | ||||
|         [disabled]="!userCanEditAll || disabled" | ||||
|         [editing]="true" | ||||
|         [applyOnClose]="applyOnClose" | ||||
|         [createRef]="createTag.bind(this)" | ||||
|         (opened)="openTagsDropdown()" | ||||
|         [(selectionModel)]="tagSelectionModel" | ||||
|         [documentCounts]="tagDocumentCounts" | ||||
|         (apply)="setTags($event)" | ||||
|         shortcutKey="t"> | ||||
|       </pngx-filterable-dropdown> | ||||
|     } | ||||
|     @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) { | ||||
|       <pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title | ||||
|         filterPlaceholder="Filter correspondents" i18n-filterPlaceholder | ||||
|         [disabled]="!userCanEditAll || disabled" | ||||
|         [editing]="true" | ||||
|         [applyOnClose]="applyOnClose" | ||||
|         [createRef]="createCorrespondent.bind(this)" | ||||
|         (opened)="openCorrespondentDropdown()" | ||||
|         [(selectionModel)]="correspondentSelectionModel" | ||||
|         [documentCounts]="correspondentDocumentCounts" | ||||
|         (apply)="setCorrespondents($event)" | ||||
|         shortcutKey="y"> | ||||
|       </pngx-filterable-dropdown> | ||||
|     } | ||||
|     @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) { | ||||
|       <pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title | ||||
|         filterPlaceholder="Filter document types" i18n-filterPlaceholder | ||||
|         [disabled]="!userCanEditAll || disabled" | ||||
|         [editing]="true" | ||||
|         [applyOnClose]="applyOnClose" | ||||
|         [createRef]="createDocumentType.bind(this)" | ||||
|         (opened)="openDocumentTypeDropdown()" | ||||
|         [(selectionModel)]="documentTypeSelectionModel" | ||||
|         [documentCounts]="documentTypeDocumentCounts" | ||||
|         (apply)="setDocumentTypes($event)" | ||||
|         shortcutKey="u"> | ||||
|       </pngx-filterable-dropdown> | ||||
|     } | ||||
|     @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) { | ||||
|       <pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title | ||||
|         filterPlaceholder="Filter storage paths" i18n-filterPlaceholder | ||||
|         [disabled]="!userCanEditAll || disabled" | ||||
|         [editing]="true" | ||||
|         [applyOnClose]="applyOnClose" | ||||
|         [createRef]="createStoragePath.bind(this)" | ||||
|         (opened)="openStoragePathDropdown()" | ||||
|         [(selectionModel)]="storagePathsSelectionModel" | ||||
|         [documentCounts]="storagePathDocumentCounts" | ||||
|         (apply)="setStoragePaths($event)" | ||||
|         shortcutKey="i"> | ||||
|       </pngx-filterable-dropdown> | ||||
|     } | ||||
|     @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) { | ||||
|       <pngx-filterable-dropdown title="Custom fields" icon="ui-radios" i18n-title | ||||
|         filterPlaceholder="Filter custom fields" i18n-filterPlaceholder | ||||
|         [disabled]="!userCanEditAll" | ||||
|         [editing]="true" | ||||
|         [applyOnClose]="applyOnClose" | ||||
|         [createRef]="createCustomField.bind(this)" | ||||
|         (opened)="openCustomFieldsDropdown()" | ||||
|         [(selectionModel)]="customFieldsSelectionModel" | ||||
|         [documentCounts]="customFieldDocumentCounts" | ||||
|         extraButtonTitle="Set values" | ||||
|         i18n-extraButtonTitle | ||||
|         (extraButton)="setCustomFieldValues($event)" | ||||
|         (apply)="setCustomFields($event)"> | ||||
|       </pngx-filterable-dropdown> | ||||
|     } | ||||
|     <div class="btn-group"> | ||||
|       <button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || !userCanEditAll"> | ||||
|         <i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline"> <ng-container i18n>Permissions</ng-container></div> | ||||
|   <div class="d-flex align-items-center" role="group" aria-label="Select"> | ||||
|     <button class="btn btn-sm btn-outline-secondary" (click)="list.selectNone()"> | ||||
|       <i-bs name="slash-circle"></i-bs> <ng-container i18n>Cancel</ng-container> | ||||
|       </button> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div class="d-flex align-items-center gap-2 ms-auto"> | ||||
|     <div class="btn-toolbar"> | ||||
|       <div ngbDropdown> | ||||
|         <button class="btn btn-sm btn-outline-primary" id="dropdownSelect" [disabled]="!userCanEdit && !userCanAdd" ngbDropdownToggle> | ||||
|           <i-bs name="three-dots"></i-bs> | ||||
|           <div class="d-none d-sm-inline"> <ng-container i18n>Actions</ng-container></div> | ||||
|         </button> | ||||
|         <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> | ||||
|           <button ngbDropdownItem (click)="reprocessSelected()" [disabled]="!userCanEditAll && !userCanEditAll"> | ||||
|             <i-bs name="body-text"></i-bs> <ng-container i18n>Reprocess</ng-container> | ||||
|     <div class="d-flex align-items-center gap-2" role="group" aria-label="Select"> | ||||
|       <label class="me-2" i18n>Select:</label> | ||||
|       <div class="btn-group"> | ||||
|         <button class="btn btn-sm btn-outline-primary" (click)="list.selectPage()"> | ||||
|           <i-bs name="file-earmark-check"></i-bs> <ng-container i18n>Page</ng-container> | ||||
|           </button> | ||||
|           <button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll"> | ||||
|             <i-bs name="arrow-clockwise"></i-bs> <ng-container i18n>Rotate</ng-container> | ||||
|           </button> | ||||
|           <button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.selected.size < 2"> | ||||
|             <i-bs name="journals"></i-bs> <ng-container i18n>Merge</ng-container> | ||||
|           </button> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|     <div class="btn-group btn-group-sm"> | ||||
|       <button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()"> | ||||
|         @if (!awaitingDownload) { | ||||
|           <i-bs name="arrow-down"></i-bs> | ||||
|         } | ||||
|         @if (awaitingDownload) { | ||||
|           <div class="spinner-border spinner-border-sm" role="status"> | ||||
|             <span class="visually-hidden">Preparing download...</span> | ||||
|           <button class="btn btn-sm btn-outline-primary" (click)="list.selectAll()"> | ||||
|             <i-bs name="check-all"></i-bs> <ng-container i18n>All</ng-container> | ||||
|             </button> | ||||
|           </div> | ||||
|         } | ||||
|         <div class="d-none d-sm-inline"> <ng-container i18n>Download</ng-container></div> | ||||
|       </button> | ||||
|       <div ngbDropdown class="me-2 d-flex btn-group" role="group"> | ||||
|         <button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle-split rounded-end" ngbDropdownToggle></button> | ||||
|         <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> | ||||
|           <form [formGroup]="downloadForm" class="px-3 py-1"> | ||||
|             <p class="mb-1" i18n>Include:</p> | ||||
|             <div class="form-group ps-3 mb-2"> | ||||
|               <div class="form-check"> | ||||
|                 <input type="checkbox" class="form-check-input" id="downloadFileType_archive" formControlName="downloadFileTypeArchive" /> | ||||
|                 <label class="form-check-label" for="downloadFileType_archive" i18n>Archived files</label> | ||||
|               </div> | ||||
|               <div class="form-check"> | ||||
|                 <input type="checkbox" class="form-check-input" id="downloadFileType_originals" formControlName="downloadFileTypeOriginals" /> | ||||
|                 <label class="form-check-label" for="downloadFileType_originals" i18n>Original files</label> | ||||
|               </div> | ||||
|             </div> | ||||
|             <div class="form-check"> | ||||
|               <input type="checkbox" class="form-check-input" id="downloadUseFormatting" formControlName="downloadUseFormatting" /> | ||||
|               <label class="form-check-label" for="downloadUseFormatting" i18n>Use formatted filename</label> | ||||
|             </div> | ||||
|           </form> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|         <div class="d-flex align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"> | ||||
|           <label class="me-2" i18n>Edit:</label> | ||||
|           @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) { | ||||
|             <pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title | ||||
|               filterPlaceholder="Filter tags" i18n-filterPlaceholder | ||||
|               [disabled]="!userCanEditAll || disabled" | ||||
|               [editing]="true" | ||||
|               [applyOnClose]="applyOnClose" | ||||
|               [createRef]="createTag.bind(this)" | ||||
|               (opened)="openTagsDropdown()" | ||||
|               [(selectionModel)]="tagSelectionModel" | ||||
|               [documentCounts]="tagDocumentCounts" | ||||
|               (apply)="setTags($event)" | ||||
|               shortcutKey="t"> | ||||
|             </pngx-filterable-dropdown> | ||||
|           } | ||||
|           @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) { | ||||
|             <pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title | ||||
|               filterPlaceholder="Filter correspondents" i18n-filterPlaceholder | ||||
|               [disabled]="!userCanEditAll || disabled" | ||||
|               [editing]="true" | ||||
|               [applyOnClose]="applyOnClose" | ||||
|               [createRef]="createCorrespondent.bind(this)" | ||||
|               (opened)="openCorrespondentDropdown()" | ||||
|               [(selectionModel)]="correspondentSelectionModel" | ||||
|               [documentCounts]="correspondentDocumentCounts" | ||||
|               (apply)="setCorrespondents($event)" | ||||
|               shortcutKey="y"> | ||||
|             </pngx-filterable-dropdown> | ||||
|           } | ||||
|           @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) { | ||||
|             <pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title | ||||
|               filterPlaceholder="Filter document types" i18n-filterPlaceholder | ||||
|               [disabled]="!userCanEditAll || disabled" | ||||
|               [editing]="true" | ||||
|               [applyOnClose]="applyOnClose" | ||||
|               [createRef]="createDocumentType.bind(this)" | ||||
|               (opened)="openDocumentTypeDropdown()" | ||||
|               [(selectionModel)]="documentTypeSelectionModel" | ||||
|               [documentCounts]="documentTypeDocumentCounts" | ||||
|               (apply)="setDocumentTypes($event)" | ||||
|               shortcutKey="u"> | ||||
|             </pngx-filterable-dropdown> | ||||
|           } | ||||
|           @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) { | ||||
|             <pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title | ||||
|               filterPlaceholder="Filter storage paths" i18n-filterPlaceholder | ||||
|               [disabled]="!userCanEditAll || disabled" | ||||
|               [editing]="true" | ||||
|               [applyOnClose]="applyOnClose" | ||||
|               [createRef]="createStoragePath.bind(this)" | ||||
|               (opened)="openStoragePathDropdown()" | ||||
|               [(selectionModel)]="storagePathsSelectionModel" | ||||
|               [documentCounts]="storagePathDocumentCounts" | ||||
|               (apply)="setStoragePaths($event)" | ||||
|               shortcutKey="i"> | ||||
|             </pngx-filterable-dropdown> | ||||
|           } | ||||
|           @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) { | ||||
|             <pngx-filterable-dropdown title="Custom fields" icon="ui-radios" i18n-title | ||||
|               filterPlaceholder="Filter custom fields" i18n-filterPlaceholder | ||||
|               [disabled]="!userCanEditAll" | ||||
|               [editing]="true" | ||||
|               [applyOnClose]="applyOnClose" | ||||
|               [createRef]="createCustomField.bind(this)" | ||||
|               (opened)="openCustomFieldsDropdown()" | ||||
|               [(selectionModel)]="customFieldsSelectionModel" | ||||
|               [documentCounts]="customFieldDocumentCounts" | ||||
|               extraButtonTitle="Set values" | ||||
|               i18n-extraButtonTitle | ||||
|               (extraButton)="setCustomFieldValues($event)" | ||||
|               (apply)="setCustomFields($event)"> | ||||
|             </pngx-filterable-dropdown> | ||||
|           } | ||||
|         </div> | ||||
|         <div class="d-flex align-items-center gap-2 ms-auto"> | ||||
|           <div class="btn-toolbar"> | ||||
|  | ||||
|     <div class="btn-group btn-group-sm"> | ||||
|       <button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" [disabled]="!userOwnsAll"> | ||||
|         <i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container> | ||||
|       </button> | ||||
|     </div> | ||||
|   </div> | ||||
| </div> | ||||
|             <button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || !userCanEditAll"> | ||||
|               <i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline"> <ng-container i18n>Permissions</ng-container></div> | ||||
|             </button> | ||||
|  | ||||
|             <div ngbDropdown> | ||||
|               <button class="btn btn-sm btn-outline-primary" id="dropdownSelect" [disabled]="!userCanEdit && !userCanAdd" ngbDropdownToggle> | ||||
|                 <i-bs name="three-dots"></i-bs> | ||||
|                 <div class="d-none d-sm-inline"> <ng-container i18n>Actions</ng-container></div> | ||||
|               </button> | ||||
|               <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> | ||||
|                 <button ngbDropdownItem (click)="reprocessSelected()" [disabled]="!userCanEditAll && !userCanEditAll"> | ||||
|                   <i-bs name="body-text"></i-bs> <ng-container i18n>Reprocess</ng-container> | ||||
|                 </button> | ||||
|                 <button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll"> | ||||
|                   <i-bs name="arrow-clockwise"></i-bs> <ng-container i18n>Rotate</ng-container> | ||||
|                 </button> | ||||
|                 <button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.selected.size < 2"> | ||||
|                   <i-bs name="journals"></i-bs> <ng-container i18n>Merge</ng-container> | ||||
|                 </button> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|  | ||||
|             <div class="btn-group btn-group-sm"> | ||||
|               <button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()"> | ||||
|                 @if (!awaitingDownload) { | ||||
|                   <i-bs name="arrow-down"></i-bs> | ||||
|                 } | ||||
|                 @if (awaitingDownload) { | ||||
|                   <div class="spinner-border spinner-border-sm" role="status"> | ||||
|                     <span class="visually-hidden">Preparing download...</span> | ||||
|                   </div> | ||||
|                 } | ||||
|                 <div class="d-none d-sm-inline"> <ng-container i18n>Download</ng-container></div> | ||||
|               </button> | ||||
|               <div ngbDropdown class="me-2 d-flex btn-group" role="group"> | ||||
|                 <button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle-split rounded-end" ngbDropdownToggle></button> | ||||
|                 <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> | ||||
|                   <form [formGroup]="downloadForm" class="px-3 py-1"> | ||||
|                     <p class="mb-1" i18n>Include:</p> | ||||
|                     <div class="form-group ps-3 mb-2"> | ||||
|                       <div class="form-check"> | ||||
|                         <input type="checkbox" class="form-check-input" id="downloadFileType_archive" formControlName="downloadFileTypeArchive" /> | ||||
|                         <label class="form-check-label" for="downloadFileType_archive" i18n>Archived files</label> | ||||
|                       </div> | ||||
|                       <div class="form-check"> | ||||
|                         <input type="checkbox" class="form-check-input" id="downloadFileType_originals" formControlName="downloadFileTypeOriginals" /> | ||||
|                         <label class="form-check-label" for="downloadFileType_originals" i18n>Original files</label> | ||||
|                       </div> | ||||
|                     </div> | ||||
|                     <div class="form-check"> | ||||
|                       <input type="checkbox" class="form-check-input" id="downloadUseFormatting" formControlName="downloadUseFormatting" /> | ||||
|                       <label class="form-check-label" for="downloadUseFormatting" i18n>Use formatted filename</label> | ||||
|                     </div> | ||||
|                   </form> | ||||
|                 </div> | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
|             <div class="btn-group btn-group-sm"> | ||||
|               <button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" [disabled]="!userOwnsAll"> | ||||
|                 <i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container> | ||||
|                 </button> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|   | ||||
| @@ -5,7 +5,3 @@ | ||||
| .dropdown-menu{ | ||||
|     --bs-dropdown-min-width: 12rem; | ||||
| } | ||||
|  | ||||
| .btn-group .btn { | ||||
|   white-space: nowrap; | ||||
| } | ||||
|   | ||||
| @@ -1,36 +1,16 @@ | ||||
| <pngx-page-header [title]="getTitle()"> | ||||
|   <div ngbDropdown class="btn-group flex-fill d-sm-none"> | ||||
|     <button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle> | ||||
|  | ||||
|   <div ngbDropdown class="btn-group flex-fill"> | ||||
|     <button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle> | ||||
|       <i-bs name="text-indent-left"></i-bs> | ||||
|       <div class="d-none d-sm-inline"> <ng-container i18n>Select</ng-container></div> | ||||
|       @if (list.selected.size > 0) { | ||||
|         <pngx-clearable-badge [selected]="list.selected.size > 0" [number]="list.selected.size" (cleared)="list.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span> | ||||
|       } | ||||
|     </button> | ||||
|     <div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow"> | ||||
|     <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> | ||||
|       <button ngbDropdownItem (click)="list.selectNone()" i18n>Select none</button> | ||||
|       <button ngbDropdownItem (click)="list.selectPage()" i18n>Select page</button> | ||||
|       <button ngbDropdownItem (click)="list.selectAll()" i18n>Select all</button> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div class="d-none d-sm-flex flex-fill me-3"> | ||||
|     <div class="input-group input-group-sm"> | ||||
|       <span class="input-group-text border-0">Select:</span> | ||||
|     </div> | ||||
|     <div class="btn-group btn-group-sm flex-nowrap"> | ||||
|       @if (list.selected.size > 0) { | ||||
|         <button class="btn btn-sm btn-outline-secondary" (click)="list.selectNone()"> | ||||
|           <i-bs name="slash-circle"></i-bs> <ng-container i18n>None</ng-container> | ||||
|         </button> | ||||
|       } | ||||
|       <button class="btn btn-sm btn-outline-primary" (click)="list.selectPage()"> | ||||
|         <i-bs name="file-earmark-check"></i-bs> <ng-container i18n>Page</ng-container> | ||||
|       </button> | ||||
|       <button class="btn btn-sm btn-outline-primary" (click)="list.selectAll()"> | ||||
|         <i-bs name="check-all"></i-bs> <ng-container i18n>All</ng-container> | ||||
|       </button> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div ngbDropdown class="btn-group flex-fill"> | ||||
|     <button class="btn btn-sm btn-outline-primary" id="dropdownDisplayFields" ngbDropdownToggle> | ||||
|       <i-bs name="card-heading"></i-bs> | ||||
| @@ -146,13 +126,8 @@ | ||||
|       @if (!list.isReloading && isFiltered) { | ||||
|         <button class="btn btn-link py-0" (click)="resetFilters()"> | ||||
|           <i-bs width="1em" height="1em" name="x"></i-bs><small i18n>Reset filters</small> | ||||
|         </button> | ||||
|       } | ||||
|       @if (!list.isReloading && list.selected.size > 0) { | ||||
|         <button class="btn btn-link py-0" (click)="list.selectNone()"> | ||||
|           <i-bs width="1em" height="1em" name="slash-circle" class="me-1"></i-bs><small i18n>Clear selection</small> | ||||
|         </button> | ||||
|       } | ||||
|           </button> | ||||
|         } | ||||
|       </div> | ||||
|       @if (list.collectionSize) { | ||||
|         <ngb-pagination [pageSize]="list.pageSize" [collectionSize]="list.collectionSize" [(page)]="list.currentPage" [maxSize]="5" | ||||
|   | ||||
| @@ -56,7 +56,6 @@ import { | ||||
|   filterRulesDiffer, | ||||
|   isFullTextFilterRule, | ||||
| } from 'src/app/utils/filter-rules' | ||||
| import { ClearableBadgeComponent } from '../common/clearable-badge/clearable-badge.component' | ||||
| import { CustomFieldDisplayComponent } from '../common/custom-field-display/custom-field-display.component' | ||||
| import { PageHeaderComponent } from '../common/page-header/page-header.component' | ||||
| import { PreviewPopupComponent } from '../common/preview-popup/preview-popup.component' | ||||
| @@ -73,7 +72,6 @@ import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-vi | ||||
|   templateUrl: './document-list.component.html', | ||||
|   styleUrls: ['./document-list.component.scss'], | ||||
|   imports: [ | ||||
|     ClearableBadgeComponent, | ||||
|     CustomFieldDisplayComponent, | ||||
|     PageHeaderComponent, | ||||
|     BulkEditorComponent, | ||||
|   | ||||
| @@ -109,11 +109,10 @@ | ||||
|     <li class="list-group-item"> | ||||
|       <div class="row"> | ||||
|         <div class="col" i18n>Name</div> | ||||
|         <div class="col-1 d-none d-sm-block" i18n>Sort Order</div> | ||||
|         <div class="col-2" i18n>Account</div> | ||||
|         <div class="col-2 d-none d-sm-block" i18n>Status</div> | ||||
|         <div class="col d-none d-sm-block" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ProcessedMail }">Processed Mail</div> | ||||
|         <div class="col-3" i18n>Actions</div> | ||||
|         <div class="col d-none d-sm-block" i18n>Sort Order</div> | ||||
|         <div class="col" i18n>Account</div> | ||||
|         <div class="col d-none d-sm-block" i18n>Status</div> | ||||
|         <div class="col" i18n>Actions</div> | ||||
|       </div> | ||||
|     </li> | ||||
|  | ||||
| @@ -128,9 +127,9 @@ | ||||
|       <li class="list-group-item"> | ||||
|         <div class="row fade" [class.show]="showRules"> | ||||
|           <div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editMailRule(rule)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailRule) || !userCanEdit(rule)">{{rule.name}}</button></div> | ||||
|           <div class="col-1 d-flex align-items-center d-none d-sm-flex">{{rule.order}}</div> | ||||
|           <div class="col-2 d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div> | ||||
|           <div class="col-2 d-flex align-items-center d-none d-sm-flex"> | ||||
|           <div class="col d-flex align-items-center d-none d-sm-flex">{{rule.order}}</div> | ||||
|           <div class="col d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div> | ||||
|           <div class="col d-flex align-items-center d-none d-sm-flex"> | ||||
|             <div class="form-check form-switch mb-0"> | ||||
|               <input #inputField type="checkbox" class="form-check-input cursor-pointer" [id]="rule.id+'_enable'" [(ngModel)]="rule.enabled" (change)="onMailRuleEnableToggled(rule)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailRule }"> | ||||
|               <label class="form-check-label cursor-pointer" [for]="rule.id+'_enable'"> | ||||
| @@ -138,12 +137,7 @@ | ||||
|               </label> | ||||
|             </div> | ||||
|           </div> | ||||
|           <div class="col d-flex align-items-center d-none d-sm-flex" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ProcessedMail }"> | ||||
|             <button class="btn btn-sm btn-outline-secondary" type="button" (click)="viewProcessedMail(rule)"> | ||||
|               <i-bs width="1em" height="1em" name="clock-history"></i-bs> <ng-container i18n>View Processed Mail</ng-container> | ||||
|             </button> | ||||
|           </div> | ||||
|           <div class="col-3"> | ||||
|           <div class="col"> | ||||
|             <div class="btn-group d-block d-sm-none"> | ||||
|               <div ngbDropdown container="body" class="d-inline-block"> | ||||
|                 <button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle> | ||||
|   | ||||
| @@ -409,13 +409,4 @@ describe('MailComponent', () => { | ||||
|     jest.advanceTimersByTime(200) | ||||
|     expect(editSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should open processed mails dialog', () => { | ||||
|     completeSetup() | ||||
|     let modal: NgbModalRef | ||||
|     modalService.activeInstances.subscribe((refs) => (modal = refs[0])) | ||||
|     component.viewProcessedMail(mailRules[0] as MailRule) | ||||
|     const dialog = modal.componentInstance as any | ||||
|     expect(dialog.rule).toEqual(mailRules[0]) | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -27,7 +27,6 @@ import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule- | ||||
| import { PageHeaderComponent } from '../../common/page-header/page-header.component' | ||||
| import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component' | ||||
| import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' | ||||
| import { ProcessedMailDialogComponent } from './processed-mail-dialog/processed-mail-dialog.component' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'pngx-mail', | ||||
| @@ -348,14 +347,6 @@ export class MailComponent | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   viewProcessedMail(rule: MailRule) { | ||||
|     const modal = this.modalService.open(ProcessedMailDialogComponent, { | ||||
|       backdrop: 'static', | ||||
|       size: 'xl', | ||||
|     }) | ||||
|     modal.componentInstance.rule = rule | ||||
|   } | ||||
|  | ||||
|   userCanEdit(obj: ObjectWithPermissions): boolean { | ||||
|     return this.permissionsService.currentUserHasObjectPermissions( | ||||
|       PermissionAction.Change, | ||||
|   | ||||
| @@ -1,107 +0,0 @@ | ||||
| <div class="modal-header"> | ||||
|   <h6 class="modal-title" id="modal-basic-title" i18n>Processed Mail for <em>{{ rule.name }}</em></h6> | ||||
|   <button class="btn btn-sm btn-link text-muted me-auto p-0 p-md-2" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true"> | ||||
|     <i-bs name="question-circle"></i-bs> | ||||
|   </button> | ||||
|   <ng-template #infoPopover> | ||||
|     <a href="https://docs.paperless-ngx.com/usage#processed-mail" target="_blank" referrerpolicy="noopener noreferrer" i18n>Read more</a> | ||||
|     <i-bs class="ms-1" width=".8em" height=".8em" name="box-arrow-up-right"></i-bs> | ||||
|   </ng-template> | ||||
|   <button type="button" class="btn-close" aria-label="Close" (click)="close()"></button> | ||||
| </div> | ||||
| <div class="modal-body"> | ||||
|   @if (loading) { | ||||
|     <div class="text-center my-5"> | ||||
|       <div class="spinner-border" role="status"> | ||||
|         <span class="visually-hidden" i18n>Loading...</span> | ||||
|       </div> | ||||
|     </div> | ||||
|   } @else if (processedMails.length === 0) { | ||||
|     <span i18n>No processed email messages found.</span> | ||||
|   } @else { | ||||
|     <div class="table-responsive"> | ||||
|       <table class="table table-hover table-sm align-middle"> | ||||
|         <thead> | ||||
|           <tr> | ||||
|             <th scope="col" style="width: 40px;"> | ||||
|               <div class="form-check m-0 ms-2 me-n2"> | ||||
|                 <input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="toggleAllEnabled" [disabled]="processedMails.length === 0" (click)="toggleAll($event); $event.stopPropagation();"> | ||||
|                 <label class="form-check-label" for="all-objects"></label> | ||||
|               </div> | ||||
|             </th> | ||||
|             <th scope="col" i18n>Subject</th> | ||||
|             <th scope="col" i18n>Received</th> | ||||
|             <th scope="col" i18n>Processed</th> | ||||
|             <th scope="col" i18n>Status</th> | ||||
|             <th scope="col" i18n>Error</th> | ||||
|           </tr> | ||||
|         </thead> | ||||
|         <tbody> | ||||
|           @for (mail of processedMails; track mail.id) { | ||||
|             <ng-template #statusTooltip> | ||||
|               <div class="small text-light font-monospace"> | ||||
|                   {{mail.status}} | ||||
|               </div> | ||||
|             </ng-template> | ||||
|             <tr> | ||||
|               <td> | ||||
|                 <div class="form-check m-0 ms-2 me-n2"> | ||||
|                   <input type="checkbox" class="form-check-input" [id]="mail.id" [checked]="selectedMailIds.has(mail.id)" (click)="toggleSelected(mail); $event.stopPropagation();"> | ||||
|                   <label class="form-check-label" [for]="mail.id"></label> | ||||
|                 </div> | ||||
|               </td> | ||||
|               <td>{{ mail.subject }}</td> | ||||
|               <td>{{ mail.received | customDate:'longDate' }}</td> | ||||
|               <td>{{ mail.processed | customDate:'longDate' }}</td> | ||||
|               <td> | ||||
|                 @switch (mail.status) { | ||||
|                   @case ('SUCCESS') { | ||||
|                     <i-bs name="check-circle" title="SUCCESS" class="text-success" [ngbTooltip]="statusTooltip"></i-bs> | ||||
|                   } | ||||
|                   @case ('FAILED') { | ||||
|                     <i-bs name="exclamation-triangle" title="FAILED" class="text-danger" [ngbTooltip]="statusTooltip"></i-bs> | ||||
|                   } | ||||
|                   @default { | ||||
|                     <i-bs name="slash-circle" title="{{ mail.status }}" class="text-muted" [ngbTooltip]="statusTooltip"></i-bs> | ||||
|                   } | ||||
|                 } | ||||
|               </td> | ||||
|               <td> | ||||
|                 <ng-template #errorPopover> | ||||
|                   <pre class="small text-light"> | ||||
|                     {{ mail.error }} | ||||
|                   </pre> | ||||
|                 </ng-template> | ||||
|                 @if (mail.error) { | ||||
|                   <span class="text-danger" triggers="mouseenter:mouseleave" [ngbPopover]="errorPopover">{{ mail.error | slice:0:20 }}</span> | ||||
|                 } | ||||
|               </td> | ||||
|             </tr> | ||||
|           } | ||||
|         </tbody> | ||||
|       </table> | ||||
|     </div> | ||||
|     <div class="btn-toolbar"> | ||||
|       <button type="button" class="btn btn-outline-secondary me-2" (click)="clearSelection()" [disabled]="selectedMailIds.size === 0" i18n>Clear</button> | ||||
|       <pngx-confirm-button | ||||
|         label="Delete selected" | ||||
|         i18n-label | ||||
|         title="Delete selected" | ||||
|         i18n-title | ||||
|         buttonClasses="btn-outline-danger" | ||||
|         iconName="trash" | ||||
|         [disabled]="selectedMailIds.size === 0" | ||||
|         (confirm)="deleteSelected()"> | ||||
|       </pngx-confirm-button> | ||||
|       <div class="ms-auto"> | ||||
|         <ngb-pagination | ||||
|           [collectionSize]="processedMails.length" | ||||
|           [(page)]="page" | ||||
|           [pageSize]="50" | ||||
|           [maxSize]="5" | ||||
|           (pageChange)="loadProcessedMails()"> | ||||
|         </ngb-pagination> | ||||
|       </div> | ||||
|     </div> | ||||
|   } | ||||
| </div> | ||||
| @@ -1,8 +0,0 @@ | ||||
| ::ng-deep .popover { | ||||
|     max-width: 350px; | ||||
|  | ||||
|     pre { | ||||
|         white-space: pre-wrap; | ||||
|         word-break: break-word; | ||||
|     } | ||||
| } | ||||
| @@ -1,150 +0,0 @@ | ||||
| import { DatePipe } from '@angular/common' | ||||
| import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' | ||||
| import { | ||||
|   HttpTestingController, | ||||
|   provideHttpClientTesting, | ||||
| } from '@angular/common/http/testing' | ||||
| import { ComponentFixture, TestBed } from '@angular/core/testing' | ||||
| import { FormsModule } from '@angular/forms' | ||||
| import { By } from '@angular/platform-browser' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { environment } from 'src/environments/environment' | ||||
| import { ProcessedMailDialogComponent } from './processed-mail-dialog.component' | ||||
|  | ||||
| describe('ProcessedMailDialogComponent', () => { | ||||
|   let component: ProcessedMailDialogComponent | ||||
|   let fixture: ComponentFixture<ProcessedMailDialogComponent> | ||||
|   let httpTestingController: HttpTestingController | ||||
|   let toastService: ToastService | ||||
|  | ||||
|   const rule: any = { id: 10, name: 'Mail Rule' } // minimal rule object for tests | ||||
|   const mails = [ | ||||
|     { | ||||
|       id: 1, | ||||
|       rule: rule.id, | ||||
|       folder: 'INBOX', | ||||
|       uid: 111, | ||||
|       subject: 'A', | ||||
|       received: new Date().toISOString(), | ||||
|       processed: new Date().toISOString(), | ||||
|       status: 'SUCCESS', | ||||
|       error: null, | ||||
|     }, | ||||
|     { | ||||
|       id: 2, | ||||
|       rule: rule.id, | ||||
|       folder: 'INBOX', | ||||
|       uid: 222, | ||||
|       subject: 'B', | ||||
|       received: new Date().toISOString(), | ||||
|       processed: new Date().toISOString(), | ||||
|       status: 'FAILED', | ||||
|       error: 'Oops', | ||||
|     }, | ||||
|   ] | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     await TestBed.configureTestingModule({ | ||||
|       imports: [ | ||||
|         ProcessedMailDialogComponent, | ||||
|         FormsModule, | ||||
|         NgxBootstrapIconsModule.pick(allIcons), | ||||
|       ], | ||||
|       providers: [ | ||||
|         DatePipe, | ||||
|         NgbActiveModal, | ||||
|         provideHttpClient(withInterceptorsFromDi()), | ||||
|         provideHttpClientTesting(), | ||||
|       ], | ||||
|     }).compileComponents() | ||||
|  | ||||
|     httpTestingController = TestBed.inject(HttpTestingController) | ||||
|     toastService = TestBed.inject(ToastService) | ||||
|     fixture = TestBed.createComponent(ProcessedMailDialogComponent) | ||||
|     component = fixture.componentInstance | ||||
|     component.rule = rule | ||||
|   }) | ||||
|  | ||||
|   afterEach(() => { | ||||
|     httpTestingController.verify() | ||||
|   }) | ||||
|  | ||||
|   function expectListRequest(ruleId: number) { | ||||
|     const req = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}processed_mail/?page=1&page_size=50&ordering=-processed_at&rule=${ruleId}` | ||||
|     ) | ||||
|     expect(req.request.method).toEqual('GET') | ||||
|     return req | ||||
|   } | ||||
|  | ||||
|   it('should load processed mails on init', () => { | ||||
|     fixture.detectChanges() | ||||
|     const req = expectListRequest(rule.id) | ||||
|     req.flush({ count: 2, results: mails }) | ||||
|     expect(component.loading).toBeFalsy() | ||||
|     expect(component.processedMails).toEqual(mails) | ||||
|   }) | ||||
|  | ||||
|   it('should delete selected mails and reload', () => { | ||||
|     fixture.detectChanges() | ||||
|     // initial load | ||||
|     const initialReq = expectListRequest(rule.id) | ||||
|     initialReq.flush({ count: 0, results: [] }) | ||||
|  | ||||
|     // select a couple of mails and delete | ||||
|     component.selectedMailIds.add(5) | ||||
|     component.selectedMailIds.add(6) | ||||
|     const toastInfoSpy = jest.spyOn(toastService, 'showInfo') | ||||
|     component.deleteSelected() | ||||
|  | ||||
|     const delReq = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}processed_mail/bulk_delete/` | ||||
|     ) | ||||
|     expect(delReq.request.method).toEqual('POST') | ||||
|     expect(delReq.request.body).toEqual({ mail_ids: [5, 6] }) | ||||
|     delReq.flush({}) | ||||
|  | ||||
|     // reload after delete | ||||
|     const reloadReq = expectListRequest(rule.id) | ||||
|     reloadReq.flush({ count: 0, results: [] }) | ||||
|     expect(toastInfoSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should toggle all, toggle selected, and clear selection', () => { | ||||
|     fixture.detectChanges() | ||||
|     // initial load with two mails | ||||
|     const req = expectListRequest(rule.id) | ||||
|     req.flush({ count: 2, results: mails }) | ||||
|     fixture.detectChanges() | ||||
|  | ||||
|     // toggle all via header checkbox | ||||
|     const inputs = fixture.debugElement.queryAll( | ||||
|       By.css('input.form-check-input') | ||||
|     ) | ||||
|     const header = inputs[0].nativeElement as HTMLInputElement | ||||
|     header.dispatchEvent(new Event('click')) | ||||
|     header.checked = true | ||||
|     header.dispatchEvent(new Event('click')) | ||||
|     expect(component.selectedMailIds.size).toEqual(mails.length) | ||||
|  | ||||
|     // toggle a single mail | ||||
|     component.toggleSelected(mails[0] as any) | ||||
|     expect(component.selectedMailIds.has(mails[0].id)).toBeFalsy() | ||||
|     component.toggleSelected(mails[0] as any) | ||||
|     expect(component.selectedMailIds.has(mails[0].id)).toBeTruthy() | ||||
|  | ||||
|     // clear selection | ||||
|     component.clearSelection() | ||||
|     expect(component.selectedMailIds.size).toEqual(0) | ||||
|     expect(component.toggleAllEnabled).toBeFalsy() | ||||
|   }) | ||||
|  | ||||
|   it('should close the dialog', () => { | ||||
|     const activeModal = TestBed.inject(NgbActiveModal) | ||||
|     const closeSpy = jest.spyOn(activeModal, 'close') | ||||
|     component.close() | ||||
|     expect(closeSpy).toHaveBeenCalled() | ||||
|   }) | ||||
| }) | ||||
| @@ -1,96 +0,0 @@ | ||||
| import { SlicePipe } from '@angular/common' | ||||
| import { Component, inject, Input, OnInit } from '@angular/core' | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { | ||||
|   NgbActiveModal, | ||||
|   NgbPagination, | ||||
|   NgbPopoverModule, | ||||
|   NgbTooltipModule, | ||||
| } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { ConfirmButtonComponent } from 'src/app/components/common/confirm-button/confirm-button.component' | ||||
| import { MailRule } from 'src/app/data/mail-rule' | ||||
| import { ProcessedMail } from 'src/app/data/processed-mail' | ||||
| import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' | ||||
| import { ProcessedMailService } from 'src/app/services/rest/processed-mail.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'pngx-processed-mail-dialog', | ||||
|   imports: [ | ||||
|     ConfirmButtonComponent, | ||||
|     CustomDatePipe, | ||||
|     NgbPagination, | ||||
|     NgbPopoverModule, | ||||
|     NgbTooltipModule, | ||||
|     NgxBootstrapIconsModule, | ||||
|     FormsModule, | ||||
|     ReactiveFormsModule, | ||||
|     SlicePipe, | ||||
|   ], | ||||
|   templateUrl: './processed-mail-dialog.component.html', | ||||
|   styleUrl: './processed-mail-dialog.component.scss', | ||||
| }) | ||||
| export class ProcessedMailDialogComponent implements OnInit { | ||||
|   private readonly activeModal = inject(NgbActiveModal) | ||||
|   private readonly processedMailService = inject(ProcessedMailService) | ||||
|   private readonly toastService = inject(ToastService) | ||||
|  | ||||
|   public processedMails: ProcessedMail[] = [] | ||||
|  | ||||
|   public loading: boolean = true | ||||
|   public toggleAllEnabled: boolean = false | ||||
|   public readonly selectedMailIds: Set<number> = new Set<number>() | ||||
|  | ||||
|   public page: number = 1 | ||||
|  | ||||
|   @Input() rule: MailRule | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|     this.loadProcessedMails() | ||||
|   } | ||||
|  | ||||
|   public close() { | ||||
|     this.activeModal.close() | ||||
|   } | ||||
|  | ||||
|   private loadProcessedMails(): void { | ||||
|     this.loading = true | ||||
|     this.clearSelection() | ||||
|     this.processedMailService | ||||
|       .list(this.page, 50, 'processed_at', true, { rule: this.rule.id }) | ||||
|       .subscribe((result) => { | ||||
|         this.processedMails = result.results | ||||
|         this.loading = false | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   public deleteSelected(): void { | ||||
|     this.processedMailService | ||||
|       .bulk_delete(Array.from(this.selectedMailIds)) | ||||
|       .subscribe(() => { | ||||
|         this.toastService.showInfo($localize`Processed mail(s) deleted`) | ||||
|         this.loadProcessedMails() | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   public toggleAll(event: PointerEvent) { | ||||
|     if ((event.target as HTMLInputElement).checked) { | ||||
|       this.selectedMailIds.clear() | ||||
|       this.processedMails.forEach((mail) => this.selectedMailIds.add(mail.id)) | ||||
|     } else { | ||||
|       this.clearSelection() | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public clearSelection() { | ||||
|     this.toggleAllEnabled = false | ||||
|     this.selectedMailIds.clear() | ||||
|   } | ||||
|  | ||||
|   public toggleSelected(mail: ProcessedMail) { | ||||
|     this.selectedMailIds.has(mail.id) | ||||
|       ? this.selectedMailIds.delete(mail.id) | ||||
|       : this.selectedMailIds.add(mail.id) | ||||
|   } | ||||
| } | ||||
| @@ -71,20 +71,4 @@ describe('TagListComponent', () => { | ||||
|       'Do you really want to delete the tag "Tag1"?' | ||||
|     ) | ||||
|   }) | ||||
|  | ||||
|   it('should filter out child tags if name filter is empty, otherwise show all', () => { | ||||
|     const tags = [ | ||||
|       { id: 1, name: 'Tag1', parent: null }, | ||||
|       { id: 2, name: 'Tag2', parent: 1 }, | ||||
|       { id: 3, name: 'Tag3', parent: null }, | ||||
|     ] | ||||
|     component['_nameFilter'] = null // Simulate empty name filter | ||||
|     const filtered = component.filterData(tags as any) | ||||
|     expect(filtered.length).toBe(2) | ||||
|     expect(filtered.find((t) => t.id === 2)).toBeUndefined() | ||||
|  | ||||
|     component['_nameFilter'] = 'Tag2' // Simulate non-empty name filter | ||||
|     const filteredWithName = component.filterData(tags as any) | ||||
|     expect(filteredWithName.length).toBe(3) | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -62,8 +62,6 @@ export class TagListComponent extends ManagementListComponent<Tag> { | ||||
|   } | ||||
|  | ||||
|   filterData(data: Tag[]) { | ||||
|     return this.nameFilter?.length | ||||
|       ? [...data] | ||||
|       : data.filter((tag) => !tag.parent) | ||||
|     return data.filter((tag) => !tag.parent) | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,12 +0,0 @@ | ||||
| import { ObjectWithId } from './object-with-id' | ||||
|  | ||||
| export interface ProcessedMail extends ObjectWithId { | ||||
|   rule: number // MailRule.id | ||||
|   folder: string | ||||
|   uid: number | ||||
|   subject: string | ||||
|   received: Date | ||||
|   processed: Date | ||||
|   status: string | ||||
|   error: string | ||||
| } | ||||
| @@ -28,7 +28,6 @@ export enum PermissionType { | ||||
|   ShareLink = '%s_sharelink', | ||||
|   CustomField = '%s_customfield', | ||||
|   Workflow = '%s_workflow', | ||||
|   ProcessedMail = '%s_processedmail', | ||||
| } | ||||
|  | ||||
| @Injectable({ | ||||
|   | ||||
| @@ -1,39 +0,0 @@ | ||||
| import { HttpTestingController } from '@angular/common/http/testing' | ||||
| import { TestBed } from '@angular/core/testing' | ||||
| import { Subscription } from 'rxjs' | ||||
| import { environment } from 'src/environments/environment' | ||||
| import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec' | ||||
| import { ProcessedMailService } from './processed-mail.service' | ||||
|  | ||||
| let httpTestingController: HttpTestingController | ||||
| let service: ProcessedMailService | ||||
| let subscription: Subscription | ||||
| const endpoint = 'processed_mail' | ||||
|  | ||||
| // run common tests | ||||
| commonAbstractPaperlessServiceTests(endpoint, ProcessedMailService) | ||||
|  | ||||
| describe('Additional service tests for ProcessedMailService', () => { | ||||
|   beforeEach(() => { | ||||
|     // Dont need to setup again | ||||
|  | ||||
|     httpTestingController = TestBed.inject(HttpTestingController) | ||||
|     service = TestBed.inject(ProcessedMailService) | ||||
|   }) | ||||
|  | ||||
|   afterEach(() => { | ||||
|     subscription?.unsubscribe() | ||||
|     httpTestingController.verify() | ||||
|   }) | ||||
|  | ||||
|   it('should call appropriate api endpoint for bulk delete', () => { | ||||
|     const ids = [1, 2, 3] | ||||
|     subscription = service.bulk_delete(ids).subscribe() | ||||
|     const req = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}${endpoint}/bulk_delete/` | ||||
|     ) | ||||
|     expect(req.request.method).toEqual('POST') | ||||
|     expect(req.request.body).toEqual({ mail_ids: ids }) | ||||
|     req.flush({}) | ||||
|   }) | ||||
| }) | ||||
| @@ -1,19 +0,0 @@ | ||||
| import { Injectable } from '@angular/core' | ||||
| import { ProcessedMail } from 'src/app/data/processed-mail' | ||||
| import { AbstractPaperlessService } from './abstract-paperless-service' | ||||
|  | ||||
| @Injectable({ | ||||
|   providedIn: 'root', | ||||
| }) | ||||
| export class ProcessedMailService extends AbstractPaperlessService<ProcessedMail> { | ||||
|   constructor() { | ||||
|     super() | ||||
|     this.resourceName = 'processed_mail' | ||||
|   } | ||||
|  | ||||
|   public bulk_delete(mailIds: number[]) { | ||||
|     return this.http.post(`${this.getResourceUrl()}bulk_delete/`, { | ||||
|       mail_ids: mailIds, | ||||
|     }) | ||||
|   } | ||||
| } | ||||
| @@ -51,7 +51,7 @@ describe('TasksService', () => { | ||||
|   }) | ||||
|  | ||||
|   it('calls acknowledge_tasks api endpoint on dismiss and reloads', () => { | ||||
|     tasksService.dismissTasks(new Set([1, 2, 3])).subscribe() | ||||
|     tasksService.dismissTasks(new Set([1, 2, 3])) | ||||
|     const req = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}tasks/acknowledge/` | ||||
|     ) | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { HttpClient } from '@angular/common/http' | ||||
| import { Injectable, inject } from '@angular/core' | ||||
| import { Observable, Subject } from 'rxjs' | ||||
| import { first, takeUntil, tap } from 'rxjs/operators' | ||||
| import { first, takeUntil } from 'rxjs/operators' | ||||
| import { | ||||
|   PaperlessTask, | ||||
|   PaperlessTaskName, | ||||
| @@ -68,17 +68,14 @@ export class TasksService { | ||||
|   } | ||||
|  | ||||
|   public dismissTasks(task_ids: Set<number>) { | ||||
|     return this.http | ||||
|     this.http | ||||
|       .post(`${this.baseUrl}tasks/acknowledge/`, { | ||||
|         tasks: [...task_ids], | ||||
|       }) | ||||
|       .pipe( | ||||
|         first(), | ||||
|         takeUntil(this.unsubscribeNotifer), | ||||
|         tap(() => { | ||||
|           this.reload() | ||||
|         }) | ||||
|       ) | ||||
|       .pipe(first()) | ||||
|       .subscribe((r) => { | ||||
|         this.reload() | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   public cancelPending(): void { | ||||
|   | ||||
| @@ -51,17 +51,17 @@ import { | ||||
|   check, | ||||
|   check2All, | ||||
|   checkAll, | ||||
|   checkCircle, | ||||
|   checkCircleFill, | ||||
|   checkLg, | ||||
|   chevronDoubleLeft, | ||||
|   chevronDoubleRight, | ||||
|   chevronDown, | ||||
|   chevronRight, | ||||
|   chevronUp, | ||||
|   clipboard, | ||||
|   clipboardCheck, | ||||
|   clipboardCheckFill, | ||||
|   clipboardFill, | ||||
|   clockHistory, | ||||
|   dash, | ||||
|   dashCircle, | ||||
|   diagram3, | ||||
| @@ -265,17 +265,17 @@ const icons = { | ||||
|   check, | ||||
|   check2All, | ||||
|   checkAll, | ||||
|   checkCircle, | ||||
|   checkCircleFill, | ||||
|   checkLg, | ||||
|   chevronDoubleLeft, | ||||
|   chevronDoubleRight, | ||||
|   chevronDown, | ||||
|   chevronRight, | ||||
|   chevronUp, | ||||
|   clipboard, | ||||
|   clipboardCheck, | ||||
|   clipboardCheckFill, | ||||
|   clipboardFill, | ||||
|   clockHistory, | ||||
|   dash, | ||||
|   dashCircle, | ||||
|   diagram3, | ||||
|   | ||||
| @@ -164,9 +164,6 @@ class BarcodePlugin(ConsumeTaskPlugin): | ||||
|                         mailrule_id=self.input_doc.mailrule_id, | ||||
|                         # Can't use same folder or the consume might grab it again | ||||
|                         original_file=(tmp_dir / new_document.name).resolve(), | ||||
|                         # Adding optional original_path for later uses in | ||||
|                         # workflow matching | ||||
|                         original_path=self.input_doc.original_file, | ||||
|                     ), | ||||
|                     # All the same metadata | ||||
|                     self.metadata, | ||||
|   | ||||
| @@ -156,7 +156,6 @@ class ConsumableDocument: | ||||
|  | ||||
|     source: DocumentSource | ||||
|     original_file: Path | ||||
|     original_path: Path | None = None | ||||
|     mailrule_id: int | None = None | ||||
|     mime_type: str = dataclasses.field(init=False, default=None) | ||||
|  | ||||
|   | ||||
| @@ -82,13 +82,6 @@ def _is_ignored(filepath: Path) -> bool: | ||||
|  | ||||
|  | ||||
| def _consume(filepath: Path) -> None: | ||||
|     # Check permissions early | ||||
|     try: | ||||
|         filepath.stat() | ||||
|     except (PermissionError, OSError): | ||||
|         logger.warning(f"Not consuming file {filepath}: Permission denied.") | ||||
|         return | ||||
|  | ||||
|     if filepath.is_dir() or _is_ignored(filepath): | ||||
|         return | ||||
|  | ||||
| @@ -330,12 +323,7 @@ class Command(BaseCommand): | ||||
|  | ||||
|                         # Also make sure the file exists still, some scanners might write a | ||||
|                         # temporary file first | ||||
|                         try: | ||||
|                             file_still_exists = filepath.exists() and filepath.is_file() | ||||
|                         except (PermissionError, OSError):  # pragma: no cover | ||||
|                             # If we can't check, let it fail in the _consume function | ||||
|                             file_still_exists = True | ||||
|                             continue | ||||
|                         file_still_exists = filepath.exists() and filepath.is_file() | ||||
|  | ||||
|                         if waited_long_enough and file_still_exists: | ||||
|                             _consume(filepath) | ||||
|   | ||||
| @@ -92,9 +92,6 @@ class Command(MultiProcessMixin, ProgressBarMixin, BaseCommand): | ||||
|                 # doc to doc is obviously not useful | ||||
|                 if first_doc.pk == second_doc.pk: | ||||
|                     continue | ||||
|                 # Skip empty documents (e.g. password-protected) | ||||
|                 if first_doc.content.strip() == "" or second_doc.content.strip() == "": | ||||
|                     continue | ||||
|                 # Skip matching which have already been matched together | ||||
|                 # doc 1 to doc 2 is the same as doc 2 to doc 1 | ||||
|                 doc_1_to_doc_2 = (first_doc.pk, second_doc.pk) | ||||
|   | ||||
| @@ -314,19 +314,11 @@ def consumable_document_matches_workflow( | ||||
|         trigger_matched = False | ||||
|  | ||||
|     # Document path vs trigger path | ||||
|  | ||||
|     # Use the original_path if set, else us the original_file | ||||
|     match_against = ( | ||||
|         document.original_path | ||||
|         if document.original_path is not None | ||||
|         else document.original_file | ||||
|     ) | ||||
|  | ||||
|     if ( | ||||
|         trigger.filter_path is not None | ||||
|         and len(trigger.filter_path) > 0 | ||||
|         and not fnmatch( | ||||
|             match_against, | ||||
|             document.original_file, | ||||
|             trigger.filter_path, | ||||
|         ) | ||||
|     ): | ||||
|   | ||||
| @@ -161,21 +161,3 @@ class PaperlessNotePermissions(BasePermission): | ||||
|         perms = self.perms_map[request.method] | ||||
|  | ||||
|         return request.user.has_perms(perms) | ||||
|  | ||||
|  | ||||
| class AcknowledgeTasksPermissions(BasePermission): | ||||
|     """ | ||||
|     Permissions class that checks for model permissions for acknowledging tasks. | ||||
|     """ | ||||
|  | ||||
|     perms_map = { | ||||
|         "POST": ["documents.change_paperlesstask"], | ||||
|     } | ||||
|  | ||||
|     def has_permission(self, request, view): | ||||
|         if not request.user or not request.user.is_authenticated:  # pragma: no cover | ||||
|             return False | ||||
|  | ||||
|         perms = self.perms_map.get(request.method, []) | ||||
|  | ||||
|         return request.user.has_perms(perms) | ||||
|   | ||||
| @@ -76,9 +76,7 @@ def check_sanity(*, progress=False, scheduled=True) -> SanityCheckMessages: | ||||
|     messages = SanityCheckMessages() | ||||
|  | ||||
|     present_files = { | ||||
|         x.resolve() | ||||
|         for x in Path(settings.MEDIA_ROOT).glob("**/*") | ||||
|         if not x.is_dir() and x.name not in settings.IGNORABLE_FILES | ||||
|         x.resolve() for x in Path(settings.MEDIA_ROOT).glob("**/*") if not x.is_dir() | ||||
|     } | ||||
|  | ||||
|     lockfile = Path(settings.MEDIA_LOCK).resolve() | ||||
|   | ||||
| @@ -6,7 +6,6 @@ import re | ||||
| from datetime import datetime | ||||
| from decimal import Decimal | ||||
| from typing import TYPE_CHECKING | ||||
| from typing import Literal | ||||
|  | ||||
| import magic | ||||
| from celery import states | ||||
| @@ -253,35 +252,6 @@ class OwnedObjectSerializer( | ||||
|             except KeyError: | ||||
|                 pass | ||||
|  | ||||
|     def _get_perms(self, obj, codename: str, target: Literal["users", "groups"]): | ||||
|         """ | ||||
|         Get the given permissions from context or from django-guardian. | ||||
|  | ||||
|         :param codename: The permission codename, e.g. 'view' or 'change' | ||||
|         :param target: 'users' or 'groups' | ||||
|         """ | ||||
|         key = f"{target}_{codename}_perms" | ||||
|         cached = self.context.get(key, {}).get(obj.pk) | ||||
|         if cached is not None: | ||||
|             return list(cached) | ||||
|  | ||||
|         # Permission not found in the context, get it from guardian | ||||
|         if target == "users": | ||||
|             return list( | ||||
|                 get_users_with_perms( | ||||
|                     obj, | ||||
|                     only_with_perms_in=[f"{codename}_{obj.__class__.__name__.lower()}"], | ||||
|                     with_group_users=False, | ||||
|                 ).values_list("id", flat=True), | ||||
|             ) | ||||
|         else:  # groups | ||||
|             return list( | ||||
|                 get_groups_with_only_permission( | ||||
|                     obj, | ||||
|                     codename=f"{codename}_{obj.__class__.__name__.lower()}", | ||||
|                 ).values_list("id", flat=True), | ||||
|             ) | ||||
|  | ||||
|     @extend_schema_field( | ||||
|         field={ | ||||
|             "type": "object", | ||||
| @@ -316,14 +286,31 @@ class OwnedObjectSerializer( | ||||
|         }, | ||||
|     ) | ||||
|     def get_permissions(self, obj) -> dict: | ||||
|         view_codename = f"view_{obj.__class__.__name__.lower()}" | ||||
|         change_codename = f"change_{obj.__class__.__name__.lower()}" | ||||
|  | ||||
|         return { | ||||
|             "view": { | ||||
|                 "users": self._get_perms(obj, "view", "users"), | ||||
|                 "groups": self._get_perms(obj, "view", "groups"), | ||||
|                 "users": get_users_with_perms( | ||||
|                     obj, | ||||
|                     only_with_perms_in=[view_codename], | ||||
|                     with_group_users=False, | ||||
|                 ).values_list("id", flat=True), | ||||
|                 "groups": get_groups_with_only_permission( | ||||
|                     obj, | ||||
|                     codename=view_codename, | ||||
|                 ).values_list("id", flat=True), | ||||
|             }, | ||||
|             "change": { | ||||
|                 "users": self._get_perms(obj, "change", "users"), | ||||
|                 "groups": self._get_perms(obj, "change", "groups"), | ||||
|                 "users": get_users_with_perms( | ||||
|                     obj, | ||||
|                     only_with_perms_in=[change_codename], | ||||
|                     with_group_users=False, | ||||
|                 ).values_list("id", flat=True), | ||||
|                 "groups": get_groups_with_only_permission( | ||||
|                     obj, | ||||
|                     codename=change_codename, | ||||
|                 ).values_list("id", flat=True), | ||||
|             }, | ||||
|         } | ||||
|  | ||||
|   | ||||
| @@ -135,44 +135,6 @@ class TestTasks(DirectoriesMixin, APITestCase): | ||||
|         response = self.client.get(self.ENDPOINT + "?acknowledged=false") | ||||
|         self.assertEqual(len(response.data), 0) | ||||
|  | ||||
|     def test_acknowledge_tasks_requires_change_permission(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - A regular user initially without change permissions | ||||
|             - A regular user with change permissions | ||||
|         WHEN: | ||||
|             - API call is made to acknowledge tasks | ||||
|         THEN: | ||||
|             - The first user is forbidden from acknowledging tasks | ||||
|             - The second user is allowed to acknowledge tasks | ||||
|         """ | ||||
|         regular_user = User.objects.create_user(username="test") | ||||
|         self.client.force_authenticate(user=regular_user) | ||||
|  | ||||
|         task = PaperlessTask.objects.create( | ||||
|             task_id=str(uuid.uuid4()), | ||||
|             task_file_name="task_one.pdf", | ||||
|         ) | ||||
|  | ||||
|         response = self.client.post( | ||||
|             self.ENDPOINT + "acknowledge/", | ||||
|             {"tasks": [task.id]}, | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) | ||||
|  | ||||
|         regular_user2 = User.objects.create_user(username="test2") | ||||
|         regular_user2.user_permissions.add( | ||||
|             Permission.objects.get(codename="change_paperlesstask"), | ||||
|         ) | ||||
|         regular_user2.save() | ||||
|         self.client.force_authenticate(user=regular_user2) | ||||
|  | ||||
|         response = self.client.post( | ||||
|             self.ENDPOINT + "acknowledge/", | ||||
|             {"tasks": [task.id]}, | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, status.HTTP_200_OK) | ||||
|  | ||||
|     def test_tasks_owner_aware(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|   | ||||
| @@ -614,16 +614,14 @@ class TestBarcodeNewConsume( | ||||
|             self.assertIsNotFile(temp_copy) | ||||
|  | ||||
|             # Check the split files exist | ||||
|             # Check the original_path is set | ||||
|             # Check the source is unchanged | ||||
|             # Check the overrides are unchanged | ||||
|             for ( | ||||
|                 new_input_doc, | ||||
|                 new_doc_overrides, | ||||
|             ) in self.get_all_consume_delay_call_args(): | ||||
|                 self.assertIsFile(new_input_doc.original_file) | ||||
|                 self.assertEqual(new_input_doc.original_path, temp_copy) | ||||
|                 self.assertEqual(new_input_doc.source, DocumentSource.ConsumeFolder) | ||||
|                 self.assertIsFile(new_input_doc.original_file) | ||||
|                 self.assertEqual(overrides, new_doc_overrides) | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -209,26 +209,6 @@ class TestConsumer(DirectoriesMixin, ConsumerThreadMixin, TransactionTestCase): | ||||
|         # assert that we have an error logged with this invalid file. | ||||
|         error_logger.assert_called_once() | ||||
|  | ||||
|     @mock.patch("documents.management.commands.document_consumer.logger.warning") | ||||
|     def test_permission_error_on_prechecks(self, warning_logger): | ||||
|         filepath = Path(self.dirs.consumption_dir) / "selinux.txt" | ||||
|         filepath.touch() | ||||
|  | ||||
|         original_stat = Path.stat | ||||
|  | ||||
|         def raising_stat(self, *args, **kwargs): | ||||
|             if self == filepath: | ||||
|                 raise PermissionError("Permission denied") | ||||
|             return original_stat(self, *args, **kwargs) | ||||
|  | ||||
|         with mock.patch("pathlib.Path.stat", new=raising_stat): | ||||
|             document_consumer._consume(filepath) | ||||
|  | ||||
|         warning_logger.assert_called_once() | ||||
|         (args, _) = warning_logger.call_args | ||||
|         self.assertIn("Permission denied", args[0]) | ||||
|         self.consume_file_mock.assert_not_called() | ||||
|  | ||||
|     @override_settings(CONSUMPTION_DIR="does_not_exist") | ||||
|     def test_consumption_directory_invalid(self): | ||||
|         self.assertRaises(CommandError, call_command, "document_consumer", "--oneshot") | ||||
|   | ||||
| @@ -206,29 +206,3 @@ class TestFuzzyMatchCommand(TestCase): | ||||
|         self.assertEqual(Document.objects.count(), 2) | ||||
|         self.assertIsNotNone(Document.objects.get(pk=1)) | ||||
|         self.assertIsNotNone(Document.objects.get(pk=2)) | ||||
|  | ||||
|     def test_empty_content(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - 2 documents exist, content is empty (pw-protected) | ||||
|         WHEN: | ||||
|             - Command is called | ||||
|         THEN: | ||||
|             - No matches are found | ||||
|         """ | ||||
|         Document.objects.create( | ||||
|             checksum="BEEFCAFE", | ||||
|             title="A", | ||||
|             content="", | ||||
|             mime_type="application/pdf", | ||||
|             filename="test.pdf", | ||||
|         ) | ||||
|         Document.objects.create( | ||||
|             checksum="DEADBEAF", | ||||
|             title="A", | ||||
|             content="", | ||||
|             mime_type="application/pdf", | ||||
|             filename="other_test.pdf", | ||||
|         ) | ||||
|         stdout, _ = self.call_command() | ||||
|         self.assertIn("No matches found", stdout) | ||||
|   | ||||
| @@ -169,13 +169,6 @@ class TestSanityCheck(DirectoriesMixin, TestCase): | ||||
|         messages = check_sanity() | ||||
|         self.assertFalse(messages.has_warning) | ||||
|  | ||||
|     def test_ignore_ignorable_files(self): | ||||
|         self.make_test_data() | ||||
|         Path(self.dirs.media_dir, ".DS_Store").touch() | ||||
|         Path(self.dirs.media_dir, "desktop.ini").touch() | ||||
|         messages = check_sanity() | ||||
|         self.assertFalse(messages.has_warning) | ||||
|  | ||||
|     def test_archive_filename_no_checksum(self): | ||||
|         doc = self.make_test_data() | ||||
|         doc.archive_checksum = None | ||||
|   | ||||
| @@ -1,23 +1,17 @@ | ||||
| import json | ||||
| import tempfile | ||||
| from datetime import timedelta | ||||
| from pathlib import Path | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.contrib.auth.models import Group | ||||
| from django.contrib.auth.models import Permission | ||||
| from django.contrib.auth.models import User | ||||
| from django.db import connection | ||||
| from django.test import TestCase | ||||
| from django.test import override_settings | ||||
| from django.test.utils import CaptureQueriesContext | ||||
| from django.utils import timezone | ||||
| from guardian.shortcuts import assign_perm | ||||
| from rest_framework import status | ||||
|  | ||||
| from documents.models import Document | ||||
| from documents.models import ShareLink | ||||
| from documents.models import Tag | ||||
| from documents.tests.utils import DirectoriesMixin | ||||
| from paperless.models import ApplicationConfiguration | ||||
|  | ||||
| @@ -160,113 +154,3 @@ class TestViews(DirectoriesMixin, TestCase): | ||||
|         response.render() | ||||
|         self.assertEqual(response.request["PATH_INFO"], "/accounts/login/") | ||||
|         self.assertContains(response, b"Share link has expired") | ||||
|  | ||||
|     def test_list_with_full_permissions(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Tags with different permissions | ||||
|         WHEN: | ||||
|             - Request to get tag list with full permissions is made | ||||
|         THEN: | ||||
|             - Tag list is returned with the right permission information | ||||
|         """ | ||||
|         user2 = User.objects.create(username="user2") | ||||
|         user3 = User.objects.create(username="user3") | ||||
|         group1 = Group.objects.create(name="group1") | ||||
|         group2 = Group.objects.create(name="group2") | ||||
|         group3 = Group.objects.create(name="group3") | ||||
|         t1 = Tag.objects.create(name="invoice", pk=1) | ||||
|         assign_perm("view_tag", self.user, t1) | ||||
|         assign_perm("view_tag", user2, t1) | ||||
|         assign_perm("view_tag", user3, t1) | ||||
|         assign_perm("view_tag", group1, t1) | ||||
|         assign_perm("view_tag", group2, t1) | ||||
|         assign_perm("view_tag", group3, t1) | ||||
|         assign_perm("change_tag", self.user, t1) | ||||
|         assign_perm("change_tag", user2, t1) | ||||
|         assign_perm("change_tag", group1, t1) | ||||
|         assign_perm("change_tag", group2, t1) | ||||
|  | ||||
|         Tag.objects.create(name="bank statement", pk=2) | ||||
|         d1 = Document.objects.create( | ||||
|             title="Invoice 1", | ||||
|             content="This is the invoice of a very expensive item", | ||||
|             checksum="A", | ||||
|         ) | ||||
|         d1.tags.add(t1) | ||||
|         d2 = Document.objects.create( | ||||
|             title="Invoice 2", | ||||
|             content="Internet invoice, I should pay it to continue contributing", | ||||
|             checksum="B", | ||||
|         ) | ||||
|         d2.tags.add(t1) | ||||
|  | ||||
|         view_permissions = Permission.objects.filter( | ||||
|             codename__contains="view_tag", | ||||
|         ) | ||||
|         self.user.user_permissions.add(*view_permissions) | ||||
|         self.user.save() | ||||
|  | ||||
|         self.client.force_login(self.user) | ||||
|         response = self.client.get("/api/tags/?page=1&full_perms=true") | ||||
|         results = json.loads(response.content)["results"] | ||||
|         for tag in results: | ||||
|             if tag["name"] == "invoice": | ||||
|                 assert tag["permissions"] == { | ||||
|                     "view": { | ||||
|                         "users": [self.user.pk, user2.pk, user3.pk], | ||||
|                         "groups": [group1.pk, group2.pk, group3.pk], | ||||
|                     }, | ||||
|                     "change": { | ||||
|                         "users": [self.user.pk, user2.pk], | ||||
|                         "groups": [group1.pk, group2.pk], | ||||
|                     }, | ||||
|                 } | ||||
|             elif tag["name"] == "bank statement": | ||||
|                 assert tag["permissions"] == { | ||||
|                     "view": {"users": [], "groups": []}, | ||||
|                     "change": {"users": [], "groups": []}, | ||||
|                 } | ||||
|             else: | ||||
|                 assert False, f"Unexpected tag found: {tag['name']}" | ||||
|  | ||||
|     def test_list_no_n_plus_1_queries(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Tags with different permissions | ||||
|         WHEN: | ||||
|             - Request to get tag list with full permissions is made | ||||
|         THEN: | ||||
|             - Permissions are not queried in database tag by tag, | ||||
|              i.e. there are no N+1 queries | ||||
|         """ | ||||
|         view_permissions = Permission.objects.filter( | ||||
|             codename__contains="view_tag", | ||||
|         ) | ||||
|         self.user.user_permissions.add(*view_permissions) | ||||
|         self.user.save() | ||||
|         self.client.force_login(self.user) | ||||
|  | ||||
|         # Start by a small list, and count the number of SQL queries | ||||
|         for i in range(2): | ||||
|             Tag.objects.create(name=f"tag_{i}") | ||||
|  | ||||
|         with CaptureQueriesContext(connection) as ctx_small: | ||||
|             response_small = self.client.get("/api/tags/?full_perms=true") | ||||
|             assert response_small.status_code == 200 | ||||
|         num_queries_small = len(ctx_small.captured_queries) | ||||
|  | ||||
|         # Complete the list, and count the number of SQL queries again | ||||
|         for i in range(2, 50): | ||||
|             Tag.objects.create(name=f"tag_{i}") | ||||
|  | ||||
|         with CaptureQueriesContext(connection) as ctx_large: | ||||
|             response_large = self.client.get("/api/tags/?full_perms=true") | ||||
|             assert response_large.status_code == 200 | ||||
|         num_queries_large = len(ctx_large.captured_queries) | ||||
|  | ||||
|         # A few additional queries are allowed, but not a linear explosion | ||||
|         assert num_queries_large <= num_queries_small + 5, ( | ||||
|             f"Possible N+1 queries detected: {num_queries_small} queries for 2 tags, " | ||||
|             f"but {num_queries_large} queries for 50 tags" | ||||
|         ) | ||||
|   | ||||
| @@ -5,11 +5,9 @@ import platform | ||||
| import re | ||||
| import tempfile | ||||
| import zipfile | ||||
| from collections import defaultdict | ||||
| from datetime import datetime | ||||
| from pathlib import Path | ||||
| from time import mktime | ||||
| from typing import Literal | ||||
| from unicodedata import normalize | ||||
| from urllib.parse import quote | ||||
| from urllib.parse import urlparse | ||||
| @@ -21,7 +19,6 @@ from celery import states | ||||
| from django.conf import settings | ||||
| from django.contrib.auth.models import Group | ||||
| from django.contrib.auth.models import User | ||||
| from django.contrib.contenttypes.models import ContentType | ||||
| from django.db import connections | ||||
| from django.db.migrations.loader import MigrationLoader | ||||
| from django.db.migrations.recorder import MigrationRecorder | ||||
| @@ -59,8 +56,6 @@ from drf_spectacular.utils import OpenApiParameter | ||||
| from drf_spectacular.utils import extend_schema | ||||
| from drf_spectacular.utils import extend_schema_view | ||||
| from drf_spectacular.utils import inline_serializer | ||||
| from guardian.utils import get_group_obj_perms_model | ||||
| from guardian.utils import get_user_obj_perms_model | ||||
| from langdetect import detect | ||||
| from packaging import version as packaging_version | ||||
| from redis import Redis | ||||
| @@ -136,7 +131,6 @@ from documents.models import WorkflowAction | ||||
| from documents.models import WorkflowTrigger | ||||
| from documents.parsers import get_parser_class_for_mime_type | ||||
| from documents.parsers import parse_date_generator | ||||
| from documents.permissions import AcknowledgeTasksPermissions | ||||
| from documents.permissions import PaperlessAdminPermissions | ||||
| from documents.permissions import PaperlessNotePermissions | ||||
| from documents.permissions import PaperlessObjectPermissions | ||||
| @@ -260,104 +254,7 @@ class PassUserMixin(GenericAPIView): | ||||
|         return super().get_serializer(*args, **kwargs) | ||||
|  | ||||
|  | ||||
| class BulkPermissionMixin: | ||||
|     """ | ||||
|     Prefetch Django-Guardian permissions for a list before serialization, to avoid N+1 queries. | ||||
|     """ | ||||
|  | ||||
|     def _get_object_perms( | ||||
|         self, | ||||
|         objects: list, | ||||
|         perm_codenames: list[str], | ||||
|         actor: Literal["users", "groups"], | ||||
|     ) -> dict[int, dict[str, list[int]]]: | ||||
|         """ | ||||
|         Collect object-level permissions for either users or groups. | ||||
|         """ | ||||
|         model = self.queryset.model | ||||
|         obj_perm_model = ( | ||||
|             get_user_obj_perms_model(model) | ||||
|             if actor == "users" | ||||
|             else get_group_obj_perms_model(model) | ||||
|         ) | ||||
|         id_field = "user_id" if actor == "users" else "group_id" | ||||
|         ctype = ContentType.objects.get_for_model(model) | ||||
|         object_pks = [obj.pk for obj in objects] | ||||
|  | ||||
|         perms_qs = obj_perm_model.objects.filter( | ||||
|             content_type=ctype, | ||||
|             object_pk__in=object_pks, | ||||
|             permission__codename__in=perm_codenames, | ||||
|         ).values_list("object_pk", id_field, "permission__codename") | ||||
|  | ||||
|         perms: dict[int, dict[str, list[int]]] = defaultdict(lambda: defaultdict(list)) | ||||
|         for object_pk, actor_id, codename in perms_qs: | ||||
|             perms[int(object_pk)][codename].append(actor_id) | ||||
|  | ||||
|         # Ensure that all objects have all codenames, even if empty | ||||
|         for pk in object_pks: | ||||
|             for codename in perm_codenames: | ||||
|                 perms[pk][codename] | ||||
|  | ||||
|         return perms | ||||
|  | ||||
|     def get_serializer_context(self): | ||||
|         """ | ||||
|         Get all permissions of the current list of objects at once and pass them to the serializer. | ||||
|         This avoid fetching permissions object by object in database. | ||||
|         """ | ||||
|         context = super().get_serializer_context() | ||||
|         try: | ||||
|             full_perms = get_boolean( | ||||
|                 str(self.request.query_params.get("full_perms", "false")), | ||||
|             ) | ||||
|         except ValueError: | ||||
|             full_perms = False | ||||
|  | ||||
|         if not full_perms: | ||||
|             return context | ||||
|  | ||||
|         # Check which objects are being paginated | ||||
|         page = getattr(self, "paginator", None) | ||||
|         if page and hasattr(page, "page"): | ||||
|             queryset = page.page.object_list | ||||
|         elif hasattr(self, "page"): | ||||
|             queryset = self.page | ||||
|         else: | ||||
|             queryset = self.filter_queryset(self.get_queryset()) | ||||
|  | ||||
|         model_name = self.queryset.model.__name__.lower() | ||||
|         permission_name_view = f"view_{model_name}" | ||||
|         permission_name_change = f"change_{model_name}" | ||||
|  | ||||
|         user_perms = self._get_object_perms( | ||||
|             objects=queryset, | ||||
|             perm_codenames=[permission_name_view, permission_name_change], | ||||
|             actor="users", | ||||
|         ) | ||||
|         group_perms = self._get_object_perms( | ||||
|             objects=queryset, | ||||
|             perm_codenames=[permission_name_view, permission_name_change], | ||||
|             actor="groups", | ||||
|         ) | ||||
|  | ||||
|         context["users_view_perms"] = { | ||||
|             pk: user_perms[pk][permission_name_view] for pk in user_perms | ||||
|         } | ||||
|         context["users_change_perms"] = { | ||||
|             pk: user_perms[pk][permission_name_change] for pk in user_perms | ||||
|         } | ||||
|         context["groups_view_perms"] = { | ||||
|             pk: group_perms[pk][permission_name_view] for pk in group_perms | ||||
|         } | ||||
|         context["groups_change_perms"] = { | ||||
|             pk: group_perms[pk][permission_name_change] for pk in group_perms | ||||
|         } | ||||
|  | ||||
|         return context | ||||
|  | ||||
|  | ||||
| class PermissionsAwareDocumentCountMixin(BulkPermissionMixin, PassUserMixin): | ||||
| class PermissionsAwareDocumentCountMixin(PassUserMixin): | ||||
|     """ | ||||
|     Mixin to add document count to queryset, permissions-aware if needed | ||||
|     """ | ||||
| @@ -2488,11 +2385,7 @@ class TasksViewSet(ReadOnlyModelViewSet): | ||||
|             queryset = PaperlessTask.objects.filter(task_id=task_id) | ||||
|         return queryset | ||||
|  | ||||
|     @action( | ||||
|         methods=["post"], | ||||
|         detail=False, | ||||
|         permission_classes=[IsAuthenticated, AcknowledgeTasksPermissions], | ||||
|     ) | ||||
|     @action(methods=["post"], detail=False) | ||||
|     def acknowledge(self, request): | ||||
|         serializer = AcknowledgeTasksViewSerializer(data=request.data) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|   | ||||
| @@ -2,7 +2,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: paperless-ngx\n" | ||||
| "Report-Msgid-Bugs-To: \n" | ||||
| "POT-Creation-Date: 2025-09-30 16:50+0000\n" | ||||
| "POT-Creation-Date: 2025-09-17 22:44+0000\n" | ||||
| "PO-Revision-Date: 2022-02-17 04:17\n" | ||||
| "Last-Translator: \n" | ||||
| "Language-Team: English\n" | ||||
| @@ -1191,44 +1191,44 @@ msgstr "" | ||||
| msgid "workflow runs" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:141 | ||||
| #: documents/serialisers.py:140 | ||||
| #, python-format | ||||
| msgid "Invalid regular expression: %(error)s" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:607 | ||||
| #: documents/serialisers.py:594 | ||||
| msgid "Invalid color." | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:636 | ||||
| #: documents/serialisers.py:623 | ||||
| msgid "Invalid parent tag." | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:1793 | ||||
| #: documents/serialisers.py:1780 | ||||
| #, python-format | ||||
| msgid "File type %(type)s not supported" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:1837 | ||||
| #: documents/serialisers.py:1824 | ||||
| #, python-format | ||||
| msgid "Custom field id must be an integer: %(id)s" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:1844 | ||||
| #: documents/serialisers.py:1831 | ||||
| #, python-format | ||||
| msgid "Custom field with id %(id)s does not exist" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:1861 documents/serialisers.py:1871 | ||||
| #: documents/serialisers.py:1848 documents/serialisers.py:1858 | ||||
| msgid "" | ||||
| "Custom fields must be a list of integers or an object mapping ids to values." | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:1866 | ||||
| #: documents/serialisers.py:1853 | ||||
| msgid "Some custom fields don't exist or were specified twice." | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:1936 | ||||
| #: documents/serialisers.py:1923 | ||||
| msgid "Invalid variable detected." | ||||
| msgstr "" | ||||
|  | ||||
| @@ -1827,7 +1827,7 @@ msgstr "" | ||||
| msgid "Chinese Traditional" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless/urls.py:370 | ||||
| #: paperless/urls.py:368 | ||||
| msgid "Paperless-ngx administration" | ||||
| msgstr "" | ||||
|  | ||||
|   | ||||
| @@ -922,7 +922,7 @@ CELERY_ACCEPT_CONTENT = ["application/json", "application/x-python-serialize"] | ||||
| CELERY_BEAT_SCHEDULE = _parse_beat_schedule() | ||||
|  | ||||
| # https://docs.celeryq.dev/en/stable/userguide/configuration.html#beat-schedule-filename | ||||
| CELERY_BEAT_SCHEDULE_FILENAME = str(DATA_DIR / "celerybeat-schedule.db") | ||||
| CELERY_BEAT_SCHEDULE_FILENAME = DATA_DIR / "celerybeat-schedule.db" | ||||
|  | ||||
|  | ||||
| # Cachalot: Database read cache. | ||||
| @@ -1003,18 +1003,6 @@ THREADS_PER_WORKER = os.getenv( | ||||
| # Paperless Specific Settings                                                 # | ||||
| ############################################################################### | ||||
|  | ||||
| IGNORABLE_FILES: Final[list[str]] = [ | ||||
|     ".DS_Store", | ||||
|     ".DS_STORE", | ||||
|     "._*", | ||||
|     ".stfolder/*", | ||||
|     ".stversions/*", | ||||
|     ".localized/*", | ||||
|     "desktop.ini", | ||||
|     "@eaDir/*", | ||||
|     "Thumbs.db", | ||||
| ] | ||||
|  | ||||
| CONSUMER_POLLING = int(os.getenv("PAPERLESS_CONSUMER_POLLING", 0)) | ||||
|  | ||||
| CONSUMER_POLLING_DELAY = int(os.getenv("PAPERLESS_CONSUMER_POLLING_DELAY", 5)) | ||||
| @@ -1037,7 +1025,7 @@ CONSUMER_IGNORE_PATTERNS = list( | ||||
|     json.loads( | ||||
|         os.getenv( | ||||
|             "PAPERLESS_CONSUMER_IGNORE_PATTERNS", | ||||
|             json.dumps(IGNORABLE_FILES), | ||||
|             '[".DS_Store", ".DS_STORE", "._*", ".stfolder/*", ".stversions/*", ".localized/*", "desktop.ini", "@eaDir/*", "Thumbs.db"]', | ||||
|         ), | ||||
|     ), | ||||
| ) | ||||
|   | ||||
| @@ -57,7 +57,6 @@ from paperless.views import UserViewSet | ||||
| from paperless_mail.views import MailAccountViewSet | ||||
| from paperless_mail.views import MailRuleViewSet | ||||
| from paperless_mail.views import OauthCallbackView | ||||
| from paperless_mail.views import ProcessedMailViewSet | ||||
|  | ||||
| api_router = DefaultRouter() | ||||
| api_router.register(r"correspondents", CorrespondentViewSet) | ||||
| @@ -78,7 +77,6 @@ api_router.register(r"workflow_actions", WorkflowActionViewSet) | ||||
| api_router.register(r"workflows", WorkflowViewSet) | ||||
| api_router.register(r"custom_fields", CustomFieldViewSet) | ||||
| api_router.register(r"config", ApplicationConfigurationViewSet) | ||||
| api_router.register(r"processed_mail", ProcessedMailViewSet) | ||||
|  | ||||
|  | ||||
| urlpatterns = [ | ||||
|   | ||||
| @@ -1,12 +0,0 @@ | ||||
| from django_filters import FilterSet | ||||
|  | ||||
| from paperless_mail.models import ProcessedMail | ||||
|  | ||||
|  | ||||
| class ProcessedMailFilterSet(FilterSet): | ||||
|     class Meta: | ||||
|         model = ProcessedMail | ||||
|         fields = { | ||||
|             "rule": ["exact"], | ||||
|             "status": ["exact"], | ||||
|         } | ||||
| @@ -6,7 +6,6 @@ from documents.serialisers import OwnedObjectSerializer | ||||
| from documents.serialisers import TagsField | ||||
| from paperless_mail.models import MailAccount | ||||
| from paperless_mail.models import MailRule | ||||
| from paperless_mail.models import ProcessedMail | ||||
|  | ||||
|  | ||||
| class ObfuscatedPasswordField(serializers.CharField): | ||||
| @@ -131,20 +130,3 @@ class MailRuleSerializer(OwnedObjectSerializer): | ||||
|         if value > 36500:  # ~100 years | ||||
|             raise serializers.ValidationError("Maximum mail age is unreasonably large.") | ||||
|         return value | ||||
|  | ||||
|  | ||||
| class ProcessedMailSerializer(OwnedObjectSerializer): | ||||
|     class Meta: | ||||
|         model = ProcessedMail | ||||
|         fields = [ | ||||
|             "id", | ||||
|             "owner", | ||||
|             "rule", | ||||
|             "folder", | ||||
|             "uid", | ||||
|             "subject", | ||||
|             "received", | ||||
|             "processed", | ||||
|             "status", | ||||
|             "error", | ||||
|         ] | ||||
|   | ||||
| @@ -3,7 +3,6 @@ from unittest import mock | ||||
|  | ||||
| from django.contrib.auth.models import Permission | ||||
| from django.contrib.auth.models import User | ||||
| from django.utils import timezone | ||||
| from guardian.shortcuts import assign_perm | ||||
| from rest_framework import status | ||||
| from rest_framework.test import APITestCase | ||||
| @@ -14,7 +13,6 @@ from documents.models import Tag | ||||
| from documents.tests.utils import DirectoriesMixin | ||||
| from paperless_mail.models import MailAccount | ||||
| from paperless_mail.models import MailRule | ||||
| from paperless_mail.models import ProcessedMail | ||||
| from paperless_mail.tests.test_mail import BogusMailBox | ||||
|  | ||||
|  | ||||
| @@ -723,285 +721,3 @@ class TestAPIMailRules(DirectoriesMixin, APITestCase): | ||||
|  | ||||
|         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) | ||||
|         self.assertIn("maximum_age", response.data) | ||||
|  | ||||
|  | ||||
| class TestAPIProcessedMails(DirectoriesMixin, APITestCase): | ||||
|     ENDPOINT = "/api/processed_mail/" | ||||
|  | ||||
|     def setUp(self): | ||||
|         super().setUp() | ||||
|  | ||||
|         self.user = User.objects.create_user(username="temp_admin") | ||||
|         self.user.user_permissions.add(*Permission.objects.all()) | ||||
|         self.user.save() | ||||
|         self.client.force_authenticate(user=self.user) | ||||
|  | ||||
|     def test_get_processed_mails_owner_aware(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Configured processed mails with different users | ||||
|         WHEN: | ||||
|             - API call is made to get processed mails | ||||
|         THEN: | ||||
|             - Only unowned, owned by user or granted processed mails are provided | ||||
|         """ | ||||
|         user2 = User.objects.create_user(username="temp_admin2") | ||||
|  | ||||
|         account = MailAccount.objects.create( | ||||
|             name="Email1", | ||||
|             username="username1", | ||||
|             password="password1", | ||||
|             imap_server="server.example.com", | ||||
|             imap_port=443, | ||||
|             imap_security=MailAccount.ImapSecurity.SSL, | ||||
|             character_set="UTF-8", | ||||
|         ) | ||||
|  | ||||
|         rule = MailRule.objects.create( | ||||
|             name="Rule1", | ||||
|             account=account, | ||||
|             folder="INBOX", | ||||
|             filter_from="from@example.com", | ||||
|             order=0, | ||||
|         ) | ||||
|  | ||||
|         pm1 = ProcessedMail.objects.create( | ||||
|             rule=rule, | ||||
|             folder="INBOX", | ||||
|             uid="1", | ||||
|             subject="Subj1", | ||||
|             received=timezone.now(), | ||||
|             processed=timezone.now(), | ||||
|             status="SUCCESS", | ||||
|             error=None, | ||||
|         ) | ||||
|  | ||||
|         pm2 = ProcessedMail.objects.create( | ||||
|             rule=rule, | ||||
|             folder="INBOX", | ||||
|             uid="2", | ||||
|             subject="Subj2", | ||||
|             received=timezone.now(), | ||||
|             processed=timezone.now(), | ||||
|             status="FAILED", | ||||
|             error="err", | ||||
|             owner=self.user, | ||||
|         ) | ||||
|  | ||||
|         ProcessedMail.objects.create( | ||||
|             rule=rule, | ||||
|             folder="INBOX", | ||||
|             uid="3", | ||||
|             subject="Subj3", | ||||
|             received=timezone.now(), | ||||
|             processed=timezone.now(), | ||||
|             status="SUCCESS", | ||||
|             error=None, | ||||
|             owner=user2, | ||||
|         ) | ||||
|  | ||||
|         pm4 = ProcessedMail.objects.create( | ||||
|             rule=rule, | ||||
|             folder="INBOX", | ||||
|             uid="4", | ||||
|             subject="Subj4", | ||||
|             received=timezone.now(), | ||||
|             processed=timezone.now(), | ||||
|             status="SUCCESS", | ||||
|             error=None, | ||||
|         ) | ||||
|         pm4.owner = user2 | ||||
|         pm4.save() | ||||
|         assign_perm("view_processedmail", self.user, pm4) | ||||
|  | ||||
|         response = self.client.get(self.ENDPOINT) | ||||
|  | ||||
|         self.assertEqual(response.status_code, status.HTTP_200_OK) | ||||
|         self.assertEqual(response.data["count"], 3) | ||||
|         returned_ids = {r["id"] for r in response.data["results"]} | ||||
|         self.assertSetEqual(returned_ids, {pm1.id, pm2.id, pm4.id}) | ||||
|  | ||||
|     def test_get_processed_mails_filter_by_rule(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Processed mails belonging to two different rules | ||||
|         WHEN: | ||||
|             - API call is made with rule filter | ||||
|         THEN: | ||||
|             - Only processed mails for that rule are returned | ||||
|         """ | ||||
|         account = MailAccount.objects.create( | ||||
|             name="Email1", | ||||
|             username="username1", | ||||
|             password="password1", | ||||
|             imap_server="server.example.com", | ||||
|             imap_port=443, | ||||
|             imap_security=MailAccount.ImapSecurity.SSL, | ||||
|             character_set="UTF-8", | ||||
|         ) | ||||
|  | ||||
|         rule1 = MailRule.objects.create( | ||||
|             name="Rule1", | ||||
|             account=account, | ||||
|             folder="INBOX", | ||||
|             filter_from="from1@example.com", | ||||
|             order=0, | ||||
|         ) | ||||
|         rule2 = MailRule.objects.create( | ||||
|             name="Rule2", | ||||
|             account=account, | ||||
|             folder="INBOX", | ||||
|             filter_from="from2@example.com", | ||||
|             order=1, | ||||
|         ) | ||||
|  | ||||
|         pm1 = ProcessedMail.objects.create( | ||||
|             rule=rule1, | ||||
|             folder="INBOX", | ||||
|             uid="r1-1", | ||||
|             subject="R1-A", | ||||
|             received=timezone.now(), | ||||
|             processed=timezone.now(), | ||||
|             status="SUCCESS", | ||||
|             error=None, | ||||
|             owner=self.user, | ||||
|         ) | ||||
|         pm2 = ProcessedMail.objects.create( | ||||
|             rule=rule1, | ||||
|             folder="INBOX", | ||||
|             uid="r1-2", | ||||
|             subject="R1-B", | ||||
|             received=timezone.now(), | ||||
|             processed=timezone.now(), | ||||
|             status="FAILED", | ||||
|             error="e", | ||||
|         ) | ||||
|         ProcessedMail.objects.create( | ||||
|             rule=rule2, | ||||
|             folder="INBOX", | ||||
|             uid="r2-1", | ||||
|             subject="R2-A", | ||||
|             received=timezone.now(), | ||||
|             processed=timezone.now(), | ||||
|             status="SUCCESS", | ||||
|             error=None, | ||||
|         ) | ||||
|  | ||||
|         response = self.client.get(f"{self.ENDPOINT}?rule={rule1.pk}") | ||||
|  | ||||
|         self.assertEqual(response.status_code, status.HTTP_200_OK) | ||||
|         returned_ids = {r["id"] for r in response.data["results"]} | ||||
|         self.assertSetEqual(returned_ids, {pm1.id, pm2.id}) | ||||
|  | ||||
|     def test_bulk_delete_processed_mails(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Processed mails belonging to two different rules and different users | ||||
|         WHEN: | ||||
|             - API call is made to bulk delete some of the processed mails | ||||
|         THEN: | ||||
|             - Only the specified processed mails are deleted, respecting ownership and permissions | ||||
|         """ | ||||
|         user2 = User.objects.create_user(username="temp_admin2") | ||||
|  | ||||
|         account = MailAccount.objects.create( | ||||
|             name="Email1", | ||||
|             username="username1", | ||||
|             password="password1", | ||||
|             imap_server="server.example.com", | ||||
|             imap_port=443, | ||||
|             imap_security=MailAccount.ImapSecurity.SSL, | ||||
|             character_set="UTF-8", | ||||
|         ) | ||||
|  | ||||
|         rule = MailRule.objects.create( | ||||
|             name="Rule1", | ||||
|             account=account, | ||||
|             folder="INBOX", | ||||
|             filter_from="from@example.com", | ||||
|             order=0, | ||||
|         ) | ||||
|  | ||||
|         # unowned and owned by self, and one with explicit object perm | ||||
|         pm_unowned = ProcessedMail.objects.create( | ||||
|             rule=rule, | ||||
|             folder="INBOX", | ||||
|             uid="u1", | ||||
|             subject="Unowned", | ||||
|             received=timezone.now(), | ||||
|             processed=timezone.now(), | ||||
|             status="SUCCESS", | ||||
|             error=None, | ||||
|         ) | ||||
|         pm_owned = ProcessedMail.objects.create( | ||||
|             rule=rule, | ||||
|             folder="INBOX", | ||||
|             uid="u2", | ||||
|             subject="Owned", | ||||
|             received=timezone.now(), | ||||
|             processed=timezone.now(), | ||||
|             status="FAILED", | ||||
|             error="e", | ||||
|             owner=self.user, | ||||
|         ) | ||||
|         pm_granted = ProcessedMail.objects.create( | ||||
|             rule=rule, | ||||
|             folder="INBOX", | ||||
|             uid="u3", | ||||
|             subject="Granted", | ||||
|             received=timezone.now(), | ||||
|             processed=timezone.now(), | ||||
|             status="SUCCESS", | ||||
|             error=None, | ||||
|             owner=user2, | ||||
|         ) | ||||
|         assign_perm("delete_processedmail", self.user, pm_granted) | ||||
|         pm_forbidden = ProcessedMail.objects.create( | ||||
|             rule=rule, | ||||
|             folder="INBOX", | ||||
|             uid="u4", | ||||
|             subject="Forbidden", | ||||
|             received=timezone.now(), | ||||
|             processed=timezone.now(), | ||||
|             status="SUCCESS", | ||||
|             error=None, | ||||
|             owner=user2, | ||||
|         ) | ||||
|  | ||||
|         # Success for allowed items | ||||
|         response = self.client.post( | ||||
|             f"{self.ENDPOINT}bulk_delete/", | ||||
|             data={ | ||||
|                 "mail_ids": [pm_unowned.id, pm_owned.id, pm_granted.id], | ||||
|             }, | ||||
|             format="json", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, status.HTTP_200_OK) | ||||
|         self.assertEqual(response.data["result"], "OK") | ||||
|         self.assertSetEqual( | ||||
|             set(response.data["deleted_mail_ids"]), | ||||
|             {pm_unowned.id, pm_owned.id, pm_granted.id}, | ||||
|         ) | ||||
|         self.assertFalse(ProcessedMail.objects.filter(id=pm_unowned.id).exists()) | ||||
|         self.assertFalse(ProcessedMail.objects.filter(id=pm_owned.id).exists()) | ||||
|         self.assertFalse(ProcessedMail.objects.filter(id=pm_granted.id).exists()) | ||||
|         self.assertTrue(ProcessedMail.objects.filter(id=pm_forbidden.id).exists()) | ||||
|  | ||||
|         # 403 and not deleted | ||||
|         response = self.client.post( | ||||
|             f"{self.ENDPOINT}bulk_delete/", | ||||
|             data={ | ||||
|                 "mail_ids": [pm_forbidden.id], | ||||
|             }, | ||||
|             format="json", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) | ||||
|         self.assertTrue(ProcessedMail.objects.filter(id=pm_forbidden.id).exists()) | ||||
|  | ||||
|         # missing mail_ids | ||||
|         response = self.client.post( | ||||
|             f"{self.ENDPOINT}bulk_delete/", | ||||
|             data={"mail_ids": "not-a-list"}, | ||||
|             format="json", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) | ||||
|   | ||||
| @@ -3,10 +3,8 @@ import logging | ||||
| from datetime import timedelta | ||||
|  | ||||
| from django.http import HttpResponseBadRequest | ||||
| from django.http import HttpResponseForbidden | ||||
| from django.http import HttpResponseRedirect | ||||
| from django.utils import timezone | ||||
| from django_filters.rest_framework import DjangoFilterBackend | ||||
| from drf_spectacular.types import OpenApiTypes | ||||
| from drf_spectacular.utils import extend_schema | ||||
| from drf_spectacular.utils import extend_schema_view | ||||
| @@ -14,29 +12,23 @@ from drf_spectacular.utils import inline_serializer | ||||
| from httpx_oauth.oauth2 import GetAccessTokenError | ||||
| from rest_framework import serializers | ||||
| from rest_framework.decorators import action | ||||
| from rest_framework.filters import OrderingFilter | ||||
| from rest_framework.generics import GenericAPIView | ||||
| from rest_framework.permissions import IsAuthenticated | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.viewsets import ModelViewSet | ||||
| from rest_framework.viewsets import ReadOnlyModelViewSet | ||||
|  | ||||
| from documents.filters import ObjectOwnedOrGrantedPermissionsFilter | ||||
| from documents.permissions import PaperlessObjectPermissions | ||||
| from documents.permissions import has_perms_owner_aware | ||||
| from documents.views import PassUserMixin | ||||
| from paperless.views import StandardPagination | ||||
| from paperless_mail.filters import ProcessedMailFilterSet | ||||
| from paperless_mail.mail import MailError | ||||
| from paperless_mail.mail import get_mailbox | ||||
| from paperless_mail.mail import mailbox_login | ||||
| from paperless_mail.models import MailAccount | ||||
| from paperless_mail.models import MailRule | ||||
| from paperless_mail.models import ProcessedMail | ||||
| from paperless_mail.oauth import PaperlessMailOAuth2Manager | ||||
| from paperless_mail.serialisers import MailAccountSerializer | ||||
| from paperless_mail.serialisers import MailRuleSerializer | ||||
| from paperless_mail.serialisers import ProcessedMailSerializer | ||||
| from paperless_mail.tasks import process_mail_accounts | ||||
|  | ||||
|  | ||||
| @@ -134,34 +126,6 @@ class MailAccountViewSet(ModelViewSet, PassUserMixin): | ||||
|         return Response({"result": "OK"}) | ||||
|  | ||||
|  | ||||
| class ProcessedMailViewSet(ReadOnlyModelViewSet, PassUserMixin): | ||||
|     permission_classes = (IsAuthenticated, PaperlessObjectPermissions) | ||||
|     serializer_class = ProcessedMailSerializer | ||||
|     pagination_class = StandardPagination | ||||
|     filter_backends = ( | ||||
|         DjangoFilterBackend, | ||||
|         OrderingFilter, | ||||
|         ObjectOwnedOrGrantedPermissionsFilter, | ||||
|     ) | ||||
|     filterset_class = ProcessedMailFilterSet | ||||
|  | ||||
|     queryset = ProcessedMail.objects.all().order_by("-processed") | ||||
|  | ||||
|     @action(methods=["post"], detail=False) | ||||
|     def bulk_delete(self, request): | ||||
|         mail_ids = request.data.get("mail_ids", []) | ||||
|         if not isinstance(mail_ids, list) or not all( | ||||
|             isinstance(i, int) for i in mail_ids | ||||
|         ): | ||||
|             return HttpResponseBadRequest("mail_ids must be a list of integers") | ||||
|         mails = ProcessedMail.objects.filter(id__in=mail_ids) | ||||
|         for mail in mails: | ||||
|             if not has_perms_owner_aware(request.user, "delete_processedmail", mail): | ||||
|                 return HttpResponseForbidden("Insufficient permissions") | ||||
|             mail.delete() | ||||
|         return Response({"result": "OK", "deleted_mail_ids": mail_ids}) | ||||
|  | ||||
|  | ||||
| class MailRuleViewSet(ModelViewSet, PassUserMixin): | ||||
|     model = MailRule | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user