Compare commits
1 Commits
dev
...
fix-tasks-
Author | SHA1 | Date | |
---|---|---|---|
|
337092f345 |
@ -149,7 +149,7 @@ RUN --mount=type=cache,target=/root/.cache/uv,id=pip-cache \
|
||||
&& apt-get install --yes --quiet ${BUILD_PACKAGES}
|
||||
|
||||
RUN set -eux \
|
||||
&& npm update -g pnpm
|
||||
&& npm update npm -g
|
||||
|
||||
# add users, setup scripts
|
||||
# Mount the compiled frontend to expected location
|
||||
|
@ -33,7 +33,7 @@
|
||||
"label": "Start: Frontend Angular",
|
||||
"description": "Start the Frontend Angular Dev Server",
|
||||
"type": "shell",
|
||||
"command": "pnpm start",
|
||||
"command": "npm start",
|
||||
"isBackground": true,
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/src-ui"
|
||||
@ -173,8 +173,8 @@
|
||||
},
|
||||
{
|
||||
"label": "Maintenance: Install Frontend Dependencies",
|
||||
"description": "Install frontend (pnpm) dependencies",
|
||||
"type": "pnpm",
|
||||
"description": "Install frontend (npm) dependencies",
|
||||
"type": "npm",
|
||||
"script": "install",
|
||||
"path": "src-ui",
|
||||
"group": "clean",
|
||||
@ -185,7 +185,7 @@
|
||||
"description": "Clean install frontend dependencies and build the frontend for production",
|
||||
"label": "Maintenance: Compile frontend for production",
|
||||
"type": "shell",
|
||||
"command": "pnpm install && ./node_modules/.bin/ng build --configuration production",
|
||||
"command": "npm ci && ./node_modules/.bin/ng build --configuration production",
|
||||
"group": "none",
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
|
54
.github/dependabot.yml
vendored
54
.github/dependabot.yml
vendored
@ -1,15 +1,14 @@
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
# https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/configuration-options-for-dependency-updates#package-ecosystem
|
||||
|
||||
version: 2
|
||||
# Required for uv support for now
|
||||
enable-beta-ecosystems: true
|
||||
updates:
|
||||
|
||||
# Enable version updates for pnpm
|
||||
# Enable version updates for npm
|
||||
- package-ecosystem: "npm"
|
||||
target-branch: "dev"
|
||||
# Look for `pnpm-lock.yaml` file in the `/src-ui` directory
|
||||
# Look for `package.json` and `lock` files in the `/src-ui` directory
|
||||
directory: "/src-ui"
|
||||
open-pull-requests-limit: 10
|
||||
schedule:
|
||||
@ -90,50 +89,3 @@ updates:
|
||||
- "major"
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
# Update Dockerfile in root directory
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
reviewers:
|
||||
- "paperless-ngx/ci-cd"
|
||||
labels:
|
||||
- "ci-cd"
|
||||
- "dependencies"
|
||||
commit-message:
|
||||
prefix: "docker"
|
||||
include: "scope"
|
||||
|
||||
# Update Docker Compose files in docker/compose directory
|
||||
- package-ecosystem: "docker-compose"
|
||||
directory: "/docker/compose/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
reviewers:
|
||||
- "paperless-ngx/ci-cd"
|
||||
labels:
|
||||
- "ci-cd"
|
||||
- "dependencies"
|
||||
commit-message:
|
||||
prefix: "docker-compose"
|
||||
include: "scope"
|
||||
groups:
|
||||
# Individual groups for each image
|
||||
gotenberg:
|
||||
patterns:
|
||||
- "docker.io/gotenberg/gotenberg*"
|
||||
tika:
|
||||
patterns:
|
||||
- "docker.io/apache/tika*"
|
||||
redis:
|
||||
patterns:
|
||||
- "docker.io/library/redis*"
|
||||
mariadb:
|
||||
patterns:
|
||||
- "docker.io/library/mariadb*"
|
||||
postgres:
|
||||
patterns:
|
||||
- "docker.io/library/postgres*"
|
||||
|
54
.github/workflows/ci.yml
vendored
54
.github/workflows/ci.yml
vendored
@ -190,33 +190,29 @@ jobs:
|
||||
- pre-commit
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
-
|
||||
name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'src-ui/package-lock.json'
|
||||
- name: Cache frontend dependencies
|
||||
id: cache-frontend-deps
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.pnpm-store
|
||||
~/.npm
|
||||
~/.cache
|
||||
key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||
key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/package-lock.json') }}
|
||||
-
|
||||
name: Install dependencies
|
||||
if: steps.cache-frontend-deps.outputs.cache-hit != 'true'
|
||||
run: cd src-ui && pnpm install
|
||||
run: cd src-ui && npm ci
|
||||
-
|
||||
name: Install Playwright
|
||||
if: steps.cache-frontend-deps.outputs.cache-hit != 'true'
|
||||
run: cd src-ui && pnpm playwright install --with-deps
|
||||
run: cd src-ui && npx playwright install --with-deps
|
||||
|
||||
tests-frontend:
|
||||
name: "Frontend Tests (Node ${{ matrix.node-version }} - ${{ matrix.shard-index }}/${{ matrix.shard-count }})"
|
||||
@ -231,36 +227,32 @@ jobs:
|
||||
shard-count: [4]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
-
|
||||
name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'src-ui/package-lock.json'
|
||||
- name: Cache frontend dependencies
|
||||
id: cache-frontend-deps
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.pnpm-store
|
||||
~/.npm
|
||||
~/.cache
|
||||
key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||
key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/package-lock.json') }}
|
||||
- name: Re-link Angular cli
|
||||
run: cd src-ui && pnpm link @angular/cli
|
||||
run: cd src-ui && npm link @angular/cli
|
||||
-
|
||||
name: Linting checks
|
||||
run: cd src-ui && pnpm run lint
|
||||
run: cd src-ui && npm run lint
|
||||
-
|
||||
name: Run Jest unit tests
|
||||
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
||||
run: cd src-ui && npm run test -- --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
||||
-
|
||||
name: Run Playwright e2e tests
|
||||
run: cd src-ui && pnpm exec playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
||||
run: cd src-ui && npx playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
||||
-
|
||||
name: Upload frontend test results to Codecov
|
||||
uses: codecov/test-results-action@v1
|
||||
@ -284,35 +276,30 @@ jobs:
|
||||
- tests-frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
-
|
||||
name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
-
|
||||
name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'src-ui/package-lock.json'
|
||||
-
|
||||
name: Cache frontend dependencies
|
||||
id: cache-frontend-deps
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.pnpm-store
|
||||
~/.npm
|
||||
~/.cache
|
||||
key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/package-lock.json') }}
|
||||
-
|
||||
name: Re-link Angular cli
|
||||
run: cd src-ui && pnpm link @angular/cli
|
||||
run: cd src-ui && npm link @angular/cli
|
||||
-
|
||||
name: Build frontend and upload analysis
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
run: cd src-ui && pnpm run build --configuration=production
|
||||
run: cd src-ui && ng build --configuration=production
|
||||
|
||||
build-docker-image:
|
||||
name: Build Docker image for ${{ github.ref_name }}
|
||||
@ -521,7 +508,8 @@ jobs:
|
||||
requirements.txt \
|
||||
LICENSE \
|
||||
README.md \
|
||||
paperless.conf.example
|
||||
paperless.conf.example \
|
||||
webserver.py
|
||||
do
|
||||
cp --verbose ${file_name} dist/paperless-ngx/
|
||||
done
|
||||
|
@ -32,7 +32,7 @@ repos:
|
||||
rev: v2.4.0
|
||||
hooks:
|
||||
- id: codespell
|
||||
exclude: "(^src-ui/src/locale/)|(^src-ui/pnpm-lock.yaml)|(^src-ui/e2e/)|(^src/paperless_mail/tests/samples/)"
|
||||
exclude: "(^src-ui/src/locale/)|(^src-ui/e2e/)|(^src/paperless_mail/tests/samples/)"
|
||||
exclude_types:
|
||||
- pofile
|
||||
- json
|
||||
|
15
Dockerfile
15
Dockerfile
@ -4,17 +4,15 @@
|
||||
# Stage: compile-frontend
|
||||
# Purpose: Compiles the frontend
|
||||
# Notes:
|
||||
# - Does PNPM stuff with Typescript and such
|
||||
# - Does NPM stuff with Typescript and such
|
||||
FROM --platform=$BUILDPLATFORM docker.io/node:20-bookworm-slim AS compile-frontend
|
||||
|
||||
COPY ./src-ui /src/src-ui
|
||||
|
||||
WORKDIR /src/src-ui
|
||||
RUN set -eux \
|
||||
&& npm update -g pnpm \
|
||||
&& npm install -g corepack@latest \
|
||||
&& corepack enable \
|
||||
&& pnpm install
|
||||
&& npm update npm -g \
|
||||
&& npm ci
|
||||
|
||||
ARG PNGX_TAG_VERSION=
|
||||
# Add the tag to the environment file if its a tagged dev build
|
||||
@ -32,7 +30,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.6.5-python3.12-bookworm-slim AS s6-overlay-base
|
||||
FROM ghcr.io/astral-sh/uv:0.6.3-python3.12-bookworm-slim AS s6-overlay-base
|
||||
|
||||
WORKDIR /usr/src/s6
|
||||
|
||||
@ -192,6 +190,11 @@ RUN set -eux \
|
||||
&& rm --force --verbose *.deb \
|
||||
&& rm --recursive --force --verbose /var/lib/apt/lists/*
|
||||
|
||||
# Copy webserver config
|
||||
# Changes very infrequently
|
||||
WORKDIR /usr/src/paperless/
|
||||
COPY --chown=1000:1000 webserver.py /usr/src/paperless/webserver.py
|
||||
|
||||
WORKDIR /usr/src/paperless/src/
|
||||
|
||||
# Python dependencies
|
||||
|
@ -38,7 +38,7 @@ services:
|
||||
- redisdata:/data
|
||||
|
||||
db:
|
||||
image: docker.io/library/postgres:17
|
||||
image: docker.io/library/postgres:16
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
@ -38,7 +38,7 @@ services:
|
||||
- redisdata:/data
|
||||
|
||||
db:
|
||||
image: docker.io/library/postgres:17
|
||||
image: docker.io/library/postgres:16
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
@ -34,7 +34,7 @@ services:
|
||||
- redisdata:/data
|
||||
|
||||
db:
|
||||
image: docker.io/library/postgres:17
|
||||
image: docker.io/library/postgres:16
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
@ -3,18 +3,8 @@
|
||||
|
||||
cd ${PAPERLESS_SRC_DIR}
|
||||
|
||||
# Translate between things, preferring GRANIAN_
|
||||
export GRANIAN_HOST=${GRANIAN_HOST:-${PAPERLESS_BIND_ADDR:-"::"}}
|
||||
export GRANIAN_PORT=${GRANIAN_PORT:-${PAPERLESS_PORT:-8000}}
|
||||
export GRANIAN_WORKERS=${GRANIAN_WORKERS:-${PAPERLESS_WEBSERVER_WORKERS:-1}}
|
||||
|
||||
# Only set GRANIAN_URL_PATH_PREFIX if PAPERLESS_FORCE_SCRIPT_NAME is set
|
||||
if [[ -n "${PAPERLESS_FORCE_SCRIPT_NAME}" ]]; then
|
||||
export GRANIAN_URL_PATH_PREFIX=${PAPERLESS_FORCE_SCRIPT_NAME}
|
||||
fi
|
||||
|
||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||
exec granian --interface asginl --ws "paperless.asgi:application"
|
||||
exec python3 /usr/src/paperless/webserver.py
|
||||
else
|
||||
exec s6-setuidgid paperless granian --interface asginl --ws "paperless.asgi:application"
|
||||
exec s6-setuidgid paperless python3 /usr/src/paperless/webserver.py
|
||||
fi
|
||||
|
@ -413,3 +413,11 @@ Initial API version.
|
||||
list of strings. When creating or updating a custom field value of a
|
||||
document for a select type custom field, the value should be the `id` of
|
||||
the option whereas previously was the index of the option.
|
||||
|
||||
#### Version 8
|
||||
|
||||
- PaperlessTask objects now have a `task_name` field which replaces the old
|
||||
`type` field. The `type` field is now used to represent the way the task
|
||||
was created. Additionally, the tasks endpoint now returns different types
|
||||
of tasks other than simply 'file' tasks. See the API schema for more
|
||||
information.
|
||||
|
@ -140,7 +140,7 @@ To build the front end once use this command:
|
||||
```bash
|
||||
# src-ui/
|
||||
|
||||
$ pnpm install
|
||||
$ npm install
|
||||
$ ng build --configuration production
|
||||
```
|
||||
|
||||
@ -176,7 +176,7 @@ To add a new development package `uv add --dev <package>`
|
||||
## Front end development
|
||||
|
||||
The front end is built using AngularJS. In order to get started, you need Node.js (version 14.15+) and
|
||||
`pnpm`.
|
||||
`npm`.
|
||||
|
||||
!!! note
|
||||
|
||||
@ -185,7 +185,7 @@ The front end is built using AngularJS. In order to get started, you need Node.j
|
||||
1. Install the Angular CLI. You might need sudo privileges to perform this command:
|
||||
|
||||
```bash
|
||||
pnpm install -g @angular/cli
|
||||
npm install -g @angular/cli
|
||||
```
|
||||
|
||||
2. Make sure that it's on your path.
|
||||
@ -193,7 +193,7 @@ The front end is built using AngularJS. In order to get started, you need Node.j
|
||||
3. Install all necessary modules:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
npm install
|
||||
```
|
||||
|
||||
4. You can launch a development server by running:
|
||||
@ -207,7 +207,7 @@ The front end is built using AngularJS. In order to get started, you need Node.j
|
||||
restart it.
|
||||
|
||||
By default, the development server is available on `http://localhost:4200/` and is configured to access the API at
|
||||
`http://localhost:8000/api/`, which is the default of the backend. If you enabled `DEBUG` on the back end, several security overrides for allowed hosts and CORS are in place so that the front end behaves exactly as in production.
|
||||
`http://localhost:8000/api/`, which is the default of the backend. If you enabled `DEBUG` on the back end, several security overrides for allowed hosts, CORS and X-Frame-Options are in place so that the front end behaves exactly as in production.
|
||||
|
||||
### Testing and code style
|
||||
|
||||
|
@ -837,7 +837,7 @@ Paperless-ngx consists of the following components:
|
||||
|
||||
```shell-session
|
||||
cd /path/to/paperless/src/
|
||||
granian --interface asginl --ws "paperless.asgi:application"
|
||||
python3 webserver.py
|
||||
```
|
||||
|
||||
or by any other means such as Apache `mod_wsgi`.
|
||||
|
@ -37,7 +37,7 @@ dependencies = [
|
||||
"djangorestframework~=3.15",
|
||||
"djangorestframework-guardian~=0.3.0",
|
||||
"drf-spectacular~=0.28",
|
||||
"drf-spectacular-sidecar~=2025.3.1",
|
||||
"drf-spectacular-sidecar~=2025.2.1",
|
||||
"drf-writable-nested~=0.7.1",
|
||||
"filelock~=3.17.0",
|
||||
"flower~=2.0.1",
|
||||
@ -48,7 +48,7 @@ dependencies = [
|
||||
"jinja2~=3.1.5",
|
||||
"langdetect~=1.0.9",
|
||||
"nltk~=3.9.1",
|
||||
"ocrmypdf~=16.10.0",
|
||||
"ocrmypdf~=16.9.0",
|
||||
"pathvalidate~=3.2.3",
|
||||
"pdf2image~=1.17.0",
|
||||
"python-dateutil~=2.9.0",
|
||||
@ -73,12 +73,12 @@ optional-dependencies.mariadb = [
|
||||
"mysqlclient~=2.2.7",
|
||||
]
|
||||
optional-dependencies.postgres = [
|
||||
"psycopg[c]==3.2.5",
|
||||
"psycopg[c]==3.2.4",
|
||||
# Direct dependency for proper resolution of the pre-built wheels
|
||||
"psycopg-c==3.2.5",
|
||||
"psycopg-c==3.2.4",
|
||||
]
|
||||
optional-dependencies.webserver = [
|
||||
"granian~=2.0.1",
|
||||
"granian~=1.7.6",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
@ -343,8 +343,8 @@ environments = [
|
||||
[tool.uv.sources]
|
||||
# Markers are chosen to select these almost exclusively when building the Docker image
|
||||
psycopg-c = [
|
||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-3.2.5/psycopg_c-3.2.5-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
|
||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-3.2.5/psycopg_c-3.2.5-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
|
||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-3.2.4/psycopg_c-3.2.4-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
|
||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-3.2.4/psycopg_c-3.2.4-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
|
||||
]
|
||||
zxing-cpp = [
|
||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/zxing-2.3.0/zxing_cpp-2.3.0-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
|
||||
|
@ -9,21 +9,7 @@ Requires=redis.service
|
||||
User=paperless
|
||||
Group=paperless
|
||||
WorkingDirectory=/opt/paperless/src
|
||||
|
||||
Environment=GRANIAN_HOST=::
|
||||
Environment=GRANIAN_PORT=8000
|
||||
Environment=GRANIAN_WORKERS=1
|
||||
|
||||
ExecStart=/bin/sh -c '\
|
||||
# Host: GRANIAN_HOST -> PAPERLESS_BIND_ADDR -> default \
|
||||
[ -n "$PAPERLESS_BIND_ADDR" ] && export GRANIAN_HOST=$PAPERLESS_BIND_ADDR; \
|
||||
# Port: GRANIAN_PORT -> PAPERLESS_PORT -> default \
|
||||
[ -n "$PAPERLESS_PORT" ] && export GRANIAN_PORT=$PAPERLESS_PORT; \
|
||||
# Workers: GRANIAN_WORKERS -> PAPERLESS_WEBSERVER_WORKERS -> default \
|
||||
[ -n "$PAPERLESS_WEBSERVER_WORKERS" ] && export GRANIAN_WORKERS=$PAPERLESS_WEBSERVER_WORKERS; \
|
||||
# URL path prefix: only set if PAPERLESS_FORCE_SCRIPT_NAME exists \
|
||||
[ -n "$PAPERLESS_FORCE_SCRIPT_NAME" ] && export GRANIAN_URL_PATH_PREFIX=$PAPERLESS_FORCE_SCRIPT_NAME; \
|
||||
exec granian --interface asginl --ws "paperless.asgi:application"'
|
||||
ExecStart=python3 webserver.py
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
@ -1 +0,0 @@
|
||||
shamefully-hoist=true
|
@ -178,8 +178,7 @@
|
||||
"schematicCollections": [
|
||||
"@angular-eslint/schematics"
|
||||
],
|
||||
"analytics": false,
|
||||
"packageManager": "pnpm"
|
||||
"analytics": false
|
||||
},
|
||||
"schematics": {
|
||||
"@angular-eslint/schematics:application": {
|
||||
|
@ -7,9 +7,7 @@ module.exports = {
|
||||
'abstract-name-filter-service',
|
||||
'abstract-paperless-service',
|
||||
],
|
||||
transformIgnorePatterns: [
|
||||
`<rootDir>/node_modules/.pnpm/(?!.*\\.mjs$|lodash-es)`,
|
||||
],
|
||||
transformIgnorePatterns: [`<rootDir>/node_modules/(?!.*\\.mjs$|lodash-es)`],
|
||||
moduleNameMapper: {
|
||||
'^src/(.*)': '<rootDir>/src/$1',
|
||||
},
|
||||
|
File diff suppressed because it is too large
Load Diff
19419
src-ui/package-lock.json
generated
Normal file
19419
src-ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -2,7 +2,6 @@
|
||||
"name": "paperless-ui",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
@ -12,17 +11,17 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^19.2.2",
|
||||
"@angular/common": "~19.2.1",
|
||||
"@angular/compiler": "~19.2.1",
|
||||
"@angular/core": "~19.2.1",
|
||||
"@angular/forms": "~19.2.1",
|
||||
"@angular/localize": "~19.2.1",
|
||||
"@angular/platform-browser": "~19.2.1",
|
||||
"@angular/platform-browser-dynamic": "~19.2.1",
|
||||
"@angular/router": "~19.2.1",
|
||||
"@angular/cdk": "^19.2.1",
|
||||
"@angular/common": "~19.2.0",
|
||||
"@angular/compiler": "~19.2.0",
|
||||
"@angular/core": "~19.2.0",
|
||||
"@angular/forms": "~19.2.0",
|
||||
"@angular/localize": "~19.2.0",
|
||||
"@angular/platform-browser": "~19.2.0",
|
||||
"@angular/platform-browser-dynamic": "~19.2.0",
|
||||
"@angular/router": "~19.2.0",
|
||||
"@ng-bootstrap/ng-bootstrap": "^18.0.0",
|
||||
"@ng-select/ng-select": "^14.2.3",
|
||||
"@ng-select/ng-select": "^14.2.2",
|
||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"bootstrap": "^5.3.3",
|
||||
@ -44,24 +43,24 @@
|
||||
"devDependencies": {
|
||||
"@angular-builders/custom-webpack": "^19.0.0",
|
||||
"@angular-builders/jest": "^19.0.0",
|
||||
"@angular-devkit/build-angular": "^19.2.1",
|
||||
"@angular-devkit/core": "^19.2.1",
|
||||
"@angular-devkit/schematics": "^19.2.1",
|
||||
"@angular-eslint/builder": "19.2.1",
|
||||
"@angular-eslint/eslint-plugin": "19.2.1",
|
||||
"@angular-eslint/eslint-plugin-template": "19.2.1",
|
||||
"@angular-eslint/schematics": "19.2.1",
|
||||
"@angular-eslint/template-parser": "19.2.1",
|
||||
"@angular/cli": "~19.2.1",
|
||||
"@angular/compiler-cli": "~19.2.1",
|
||||
"@angular-devkit/build-angular": "^19.0.4",
|
||||
"@angular-devkit/core": "^19.2.0",
|
||||
"@angular-devkit/schematics": "^19.2.0",
|
||||
"@angular-eslint/builder": "19.2.0",
|
||||
"@angular-eslint/eslint-plugin": "19.2.0",
|
||||
"@angular-eslint/eslint-plugin-template": "19.2.0",
|
||||
"@angular-eslint/schematics": "19.2.0",
|
||||
"@angular-eslint/template-parser": "19.2.0",
|
||||
"@angular/cli": "~19.2.0",
|
||||
"@angular/compiler-cli": "~19.2.0",
|
||||
"@codecov/webpack-plugin": "^1.9.0",
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.13.9",
|
||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||
"@typescript-eslint/parser": "^8.26.1",
|
||||
"@typescript-eslint/utils": "^8.26.1",
|
||||
"eslint": "^9.22.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.26.0",
|
||||
"@typescript-eslint/parser": "^8.26.0",
|
||||
"@typescript-eslint/utils": "^8.0.0",
|
||||
"eslint": "^9.21.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-junit": "^16.0.0",
|
||||
@ -72,14 +71,5 @@
|
||||
"ts-node": "~10.9.1",
|
||||
"typescript": "^5.5.4"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@parcel/watcher",
|
||||
"canvas",
|
||||
"esbuild",
|
||||
"lmdb",
|
||||
"msgpackr-extract"
|
||||
]
|
||||
},
|
||||
"typings": "./src/typings.d.ts"
|
||||
}
|
||||
|
@ -21,7 +21,7 @@ export default defineConfig({
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
port,
|
||||
command: 'pnpm run start',
|
||||
command: 'npm run start',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 2 * 60 * 1000,
|
||||
},
|
||||
|
12447
src-ui/pnpm-lock.yaml
generated
12447
src-ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -36,13 +36,7 @@ export const routes: Routes = [
|
||||
component: AppFrameComponent,
|
||||
canDeactivate: [DirtyDocGuard],
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
component: DashboardComponent,
|
||||
data: {
|
||||
componentName: 'AppFrameComponent',
|
||||
},
|
||||
},
|
||||
{ path: 'dashboard', component: DashboardComponent },
|
||||
{
|
||||
path: 'documents',
|
||||
component: DocumentListComponent,
|
||||
@ -53,7 +47,6 @@ export const routes: Routes = [
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.Document,
|
||||
},
|
||||
componentName: 'DocumentListComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -66,7 +59,6 @@ export const routes: Routes = [
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.SavedView,
|
||||
},
|
||||
componentName: 'DocumentListComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -78,7 +70,6 @@ export const routes: Routes = [
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.Document,
|
||||
},
|
||||
componentName: 'DocumentDetailComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -90,7 +81,6 @@ export const routes: Routes = [
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.Document,
|
||||
},
|
||||
componentName: 'DocumentDetailComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -102,7 +92,6 @@ export const routes: Routes = [
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.Document,
|
||||
},
|
||||
componentName: 'DocumentAsnComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -114,7 +103,6 @@ export const routes: Routes = [
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.Tag,
|
||||
},
|
||||
componentName: 'TagListComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -126,7 +114,6 @@ export const routes: Routes = [
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.DocumentType,
|
||||
},
|
||||
componentName: 'DocumentTypeListComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -138,7 +125,6 @@ export const routes: Routes = [
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.Correspondent,
|
||||
},
|
||||
componentName: 'CorrespondentListComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -150,7 +136,6 @@ export const routes: Routes = [
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.StoragePath,
|
||||
},
|
||||
componentName: 'StoragePathListComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -159,7 +144,6 @@ export const routes: Routes = [
|
||||
canActivate: [PermissionsGuard],
|
||||
data: {
|
||||
requireAdmin: true,
|
||||
componentName: 'LogsComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -171,7 +155,6 @@ export const routes: Routes = [
|
||||
action: PermissionAction.Delete,
|
||||
type: PermissionType.Document,
|
||||
},
|
||||
componentName: 'TrashComponent',
|
||||
},
|
||||
},
|
||||
// redirect old paths
|
||||
@ -197,7 +180,6 @@ export const routes: Routes = [
|
||||
action: PermissionAction.Change,
|
||||
type: PermissionType.UISettings,
|
||||
},
|
||||
componentName: 'SettingsComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -210,7 +192,6 @@ export const routes: Routes = [
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.UISettings,
|
||||
},
|
||||
componentName: 'SettingsComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -222,7 +203,6 @@ export const routes: Routes = [
|
||||
action: PermissionAction.Change,
|
||||
type: PermissionType.AppConfig,
|
||||
},
|
||||
componentName: 'ConfigComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -234,7 +214,6 @@ export const routes: Routes = [
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.PaperlessTask,
|
||||
},
|
||||
componentName: 'TasksComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -246,7 +225,6 @@ export const routes: Routes = [
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.CustomField,
|
||||
},
|
||||
componentName: 'CustomFieldsComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -258,7 +236,6 @@ export const routes: Routes = [
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.Workflow,
|
||||
},
|
||||
componentName: 'WorkflowsComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -270,7 +247,6 @@ export const routes: Routes = [
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.MailAccount,
|
||||
},
|
||||
componentName: 'MailComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -282,7 +258,6 @@ export const routes: Routes = [
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.User,
|
||||
},
|
||||
componentName: 'UsersAndGroupsComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -294,7 +269,6 @@ export const routes: Routes = [
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.SavedView,
|
||||
},
|
||||
componentName: 'SavedViewsComponent',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
@ -15,7 +15,7 @@
|
||||
</svg>
|
||||
<div class="ms-2 ms-md-3 d-inline-block" [class.d-md-none]="slimSidebarEnabled">
|
||||
@if (customAppTitle?.length) {
|
||||
<div class="d-flex flex-column align-items-start custom-title">
|
||||
<div class="d-flex flex-column align-items-start">
|
||||
<span class="title">{{customAppTitle}}</span>
|
||||
<span class="byline text-uppercase font-monospace" i18n>by Paperless-ngx</span>
|
||||
</div>
|
||||
|
@ -244,7 +244,7 @@ main {
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 366px) and (max-width: 768px) {
|
||||
@media screen and (max-width: 768px) {
|
||||
.navbar-toggler {
|
||||
// compensate for 2 buttons on the right
|
||||
margin-right: 45px;
|
||||
@ -257,13 +257,6 @@ main {
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 345px) {
|
||||
.custom-title {
|
||||
max-width: 110px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
:host ::ng-deep .dropdown.show .dropdown-toggle,
|
||||
:host ::ng-deep .dropdown-toggle:hover {
|
||||
opacity: 0.7;
|
||||
|
@ -62,7 +62,6 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
|
||||
this.emailAddress = ''
|
||||
this.emailSubject = ''
|
||||
this.emailMessage = ''
|
||||
this.close()
|
||||
this.toastService.showInfo($localize`Email sent`)
|
||||
},
|
||||
error: (e) => {
|
||||
|
@ -7,7 +7,6 @@ import {
|
||||
tick,
|
||||
} from '@angular/core/testing'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
|
||||
import {
|
||||
DEFAULT_MATCHING_ALGORITHM,
|
||||
MATCH_ALL,
|
||||
@ -45,11 +44,6 @@ const nullItem = {
|
||||
name: 'Not assigned',
|
||||
}
|
||||
|
||||
const negativeNullItem = {
|
||||
id: NEGATIVE_NULL_FILTER_VALUE,
|
||||
name: 'Not assigned',
|
||||
}
|
||||
|
||||
let selectionModel: FilterableDropdownSelectionModel
|
||||
|
||||
describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => {
|
||||
@ -70,7 +64,6 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
hotkeyService = TestBed.inject(HotKeyService)
|
||||
fixture = TestBed.createComponent(FilterableDropdownComponent)
|
||||
component = fixture.componentInstance
|
||||
component.selectionModel = new FilterableDropdownSelectionModel()
|
||||
selectionModel = new FilterableDropdownSelectionModel()
|
||||
})
|
||||
|
||||
@ -81,7 +74,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
})
|
||||
|
||||
it('should support reset', () => {
|
||||
component.selectionModel.items = items
|
||||
component.items = items
|
||||
component.selectionModel = selectionModel
|
||||
selectionModel.set(items[0].id, ToggleableItemState.Selected)
|
||||
expect(selectionModel.getSelectedItems()).toHaveLength(1)
|
||||
@ -103,7 +96,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
})
|
||||
|
||||
it('should emit change when items selected', () => {
|
||||
component.selectionModel.items = items
|
||||
component.items = items
|
||||
component.selectionModel = selectionModel
|
||||
let newModel: FilterableDropdownSelectionModel
|
||||
component.selectionModelChange.subscribe((model) => (newModel = model))
|
||||
@ -117,11 +110,11 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
selectionModel.set(items[0].id, ToggleableItemState.NotSelected)
|
||||
expect(newModel.getSelectedItems()).toEqual([])
|
||||
|
||||
expect(component.selectionModel.items).toEqual([nullItem, ...items])
|
||||
expect(component.items).toEqual([nullItem, ...items])
|
||||
})
|
||||
|
||||
it('should emit change when items excluded', () => {
|
||||
component.selectionModel.items = items
|
||||
component.items = items
|
||||
component.selectionModel = selectionModel
|
||||
let newModel: FilterableDropdownSelectionModel
|
||||
component.selectionModelChange.subscribe((model) => (newModel = model))
|
||||
@ -131,7 +124,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
})
|
||||
|
||||
it('should emit change when items excluded', () => {
|
||||
component.selectionModel.items = items
|
||||
component.items = items
|
||||
component.selectionModel = selectionModel
|
||||
let newModel: FilterableDropdownSelectionModel
|
||||
component.selectionModelChange.subscribe((model) => (newModel = model))
|
||||
@ -146,8 +139,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
})
|
||||
|
||||
it('should exclude items when excluded and not editing', () => {
|
||||
component.selectionModel.items = items
|
||||
component.selectionModel.manyToOne = true
|
||||
component.items = items
|
||||
component.manyToOne = true
|
||||
component.selectionModel = selectionModel
|
||||
selectionModel.set(items[0].id, ToggleableItemState.Selected)
|
||||
component.excludeClicked(items[0].id)
|
||||
@ -156,8 +149,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
})
|
||||
|
||||
it('should toggle when items excluded and editing', () => {
|
||||
component.selectionModel.items = items
|
||||
component.selectionModel.manyToOne = true
|
||||
component.items = items
|
||||
component.manyToOne = true
|
||||
component.editing = true
|
||||
component.selectionModel = selectionModel
|
||||
selectionModel.set(items[0].id, ToggleableItemState.NotSelected)
|
||||
@ -167,8 +160,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
})
|
||||
|
||||
it('should hide count for item if adding will increase size of set', () => {
|
||||
component.selectionModel.items = items
|
||||
component.selectionModel.manyToOne = true
|
||||
component.items = items
|
||||
component.manyToOne = true
|
||||
component.selectionModel = selectionModel
|
||||
expect(component.hideCount(items[0])).toBeFalsy()
|
||||
selectionModel.logicalOperator = LogicalOperator.Or
|
||||
@ -177,7 +170,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
|
||||
it('should enforce single select when editing', () => {
|
||||
component.editing = true
|
||||
component.selectionModel.items = items
|
||||
component.items = items
|
||||
component.selectionModel = selectionModel
|
||||
let newModel: FilterableDropdownSelectionModel
|
||||
component.selectionModelChange.subscribe((model) => (newModel = model))
|
||||
@ -189,11 +182,11 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
})
|
||||
|
||||
it('should support manyToOne selecting', () => {
|
||||
component.selectionModel.items = items
|
||||
component.items = items
|
||||
selectionModel.manyToOne = false
|
||||
component.selectionModel = selectionModel
|
||||
component.selectionModel.manyToOne = true
|
||||
expect(component.selectionModel.manyToOne).toBeTruthy()
|
||||
component.manyToOne = true
|
||||
expect(component.manyToOne).toBeTruthy()
|
||||
let newModel: FilterableDropdownSelectionModel
|
||||
component.selectionModelChange.subscribe((model) => (newModel = model))
|
||||
|
||||
@ -204,10 +197,12 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
})
|
||||
|
||||
it('should dynamically enable / disable modifier toggle', () => {
|
||||
component.selectionModel.items = items
|
||||
component.items = items
|
||||
component.selectionModel = selectionModel
|
||||
expect(component.modifierToggleEnabled).toBeTruthy()
|
||||
component.selectionModel.manyToOne = true
|
||||
selectionModel.toggle(null)
|
||||
expect(component.modifierToggleEnabled).toBeFalsy()
|
||||
component.manyToOne = true
|
||||
expect(component.modifierToggleEnabled).toBeFalsy()
|
||||
selectionModel.toggle(items[0].id)
|
||||
selectionModel.toggle(items[1].id)
|
||||
@ -215,7 +210,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
})
|
||||
|
||||
it('should apply changes and close when apply button clicked', () => {
|
||||
component.selectionModel.items = items
|
||||
component.items = items
|
||||
component.icon = 'tag-fill'
|
||||
component.editing = true
|
||||
component.selectionModel = selectionModel
|
||||
@ -237,7 +232,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
})
|
||||
|
||||
it('should apply on close if enabled', () => {
|
||||
component.selectionModel.items = items
|
||||
component.items = items
|
||||
component.icon = 'tag-fill'
|
||||
component.editing = true
|
||||
component.applyOnClose = true
|
||||
@ -255,7 +250,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
})
|
||||
|
||||
it('should focus text filter on open, support filtering, clear on close', fakeAsync(() => {
|
||||
component.selectionModel.items = items
|
||||
component.items = items
|
||||
component.icon = 'tag-fill'
|
||||
fixture.nativeElement
|
||||
.querySelector('button')
|
||||
@ -282,7 +277,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
}))
|
||||
|
||||
it('should toggle & close on enter inside filter field if 1 item remains', fakeAsync(() => {
|
||||
component.selectionModel.items = items
|
||||
component.items = items
|
||||
component.icon = 'tag-fill'
|
||||
expect(component.selectionModel.getSelectedItems()).toEqual([])
|
||||
fixture.nativeElement
|
||||
@ -302,7 +297,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
}))
|
||||
|
||||
it('should apply & close on enter inside filter field if 1 item remains if editing', fakeAsync(() => {
|
||||
component.selectionModel.items = items
|
||||
component.items = items
|
||||
component.icon = 'tag-fill'
|
||||
component.editing = true
|
||||
let applyResult: ChangedItems
|
||||
@ -324,7 +319,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
}))
|
||||
|
||||
it('should support arrow keyboard navigation', fakeAsync(() => {
|
||||
component.selectionModel.items = items
|
||||
component.items = items
|
||||
component.icon = 'tag-fill'
|
||||
fixture.nativeElement
|
||||
.querySelector('button')
|
||||
@ -369,7 +364,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
}))
|
||||
|
||||
it('should support arrow keyboard navigation after tab keyboard navigation', fakeAsync(() => {
|
||||
component.selectionModel.items = items
|
||||
component.items = items
|
||||
component.icon = 'tag-fill'
|
||||
fixture.nativeElement
|
||||
.querySelector('button')
|
||||
@ -405,7 +400,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
}))
|
||||
|
||||
it('should support arrow keyboard navigation after click', fakeAsync(() => {
|
||||
component.selectionModel.items = items
|
||||
component.items = items
|
||||
component.icon = 'tag-fill'
|
||||
fixture.nativeElement
|
||||
.querySelector('button')
|
||||
@ -430,9 +425,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
}))
|
||||
|
||||
it('should toggle logical operator', fakeAsync(() => {
|
||||
component.selectionModel.items = items
|
||||
component.items = items
|
||||
component.icon = 'tag-fill'
|
||||
component.selectionModel.manyToOne = true
|
||||
component.manyToOne = true
|
||||
selectionModel.set(items[0].id, ToggleableItemState.Selected)
|
||||
selectionModel.set(items[1].id, ToggleableItemState.Selected)
|
||||
component.selectionModel = selectionModel
|
||||
@ -459,7 +454,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
}))
|
||||
|
||||
it('should toggle intersection include / exclude', fakeAsync(() => {
|
||||
component.selectionModel.items = items
|
||||
component.items = items
|
||||
component.icon = 'tag-fill'
|
||||
selectionModel.set(items[0].id, ToggleableItemState.Selected)
|
||||
selectionModel.set(items[1].id, ToggleableItemState.Selected)
|
||||
@ -488,53 +483,22 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
expect(changedResult.getExcludedItems()).toEqual(items)
|
||||
}))
|
||||
|
||||
it('should update null item selection on toggleIntersection', () => {
|
||||
component.selectionModel.items = items
|
||||
component.selectionModel = selectionModel
|
||||
component.selectionModel.intersection = Intersection.Include
|
||||
component.selectionModel.set(null, ToggleableItemState.Selected)
|
||||
component.selectionModel.intersection = Intersection.Exclude
|
||||
component.selectionModel.toggleIntersection()
|
||||
expect(component.selectionModel.getExcludedItems()).toEqual([
|
||||
negativeNullItem,
|
||||
])
|
||||
|
||||
component.selectionModel.intersection = Intersection.Include
|
||||
component.selectionModel.toggleIntersection()
|
||||
expect(component.selectionModel.getSelectedItems()).toEqual([nullItem])
|
||||
})
|
||||
|
||||
it('selection model should sort items by state', () => {
|
||||
component.items = items.concat([{ id: null, name: 'Null B' }])
|
||||
component.selectionModel = selectionModel
|
||||
component.selectionModel.items = items.concat([{ id: 3, name: 'Item3' }])
|
||||
selectionModel.toggle(items[1].id)
|
||||
selectionModel.apply()
|
||||
expect(selectionModel.items.length).toEqual(4)
|
||||
expect(selectionModel.items).toEqual([
|
||||
nullItem,
|
||||
{ id: null, name: 'Null B' },
|
||||
items[1],
|
||||
{ id: 3, name: 'Item3' },
|
||||
items[0],
|
||||
])
|
||||
|
||||
selectionModel.intersection = Intersection.Exclude
|
||||
selectionModel.toggleIntersection()
|
||||
selectionModel.apply()
|
||||
expect(selectionModel.items).toEqual([
|
||||
negativeNullItem,
|
||||
items[1],
|
||||
{ id: 3, name: 'Item3' },
|
||||
items[0],
|
||||
])
|
||||
|
||||
// coverage
|
||||
selectionModel.items = selectionModel.items.reverse()
|
||||
selectionModel.apply()
|
||||
})
|
||||
|
||||
it('selection model should sort items by state and document counts = 0, if set', () => {
|
||||
const tagA = { id: 4, name: 'Tag A' }
|
||||
component.selectionModel.items = items.concat([tagA])
|
||||
component.items = items.concat([tagA])
|
||||
component.selectionModel = selectionModel
|
||||
component.documentCounts = [
|
||||
{ id: 1, document_count: 0 }, // Tag1
|
||||
@ -565,7 +529,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
})
|
||||
|
||||
it('should set support create, keep open model and call createRef method', fakeAsync(() => {
|
||||
component.selectionModel.items = items
|
||||
component.items = items
|
||||
component.icon = 'tag-fill'
|
||||
component.selectionModel = selectionModel
|
||||
fixture.nativeElement
|
||||
@ -585,7 +549,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
}))
|
||||
|
||||
it('should call create on enter inside filter field if 0 items remain while editing', fakeAsync(() => {
|
||||
component.selectionModel.items = items
|
||||
component.items = items
|
||||
component.icon = 'tag-fill'
|
||||
component.editing = true
|
||||
component.createRef = jest.fn()
|
||||
@ -605,7 +569,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
const id = 1
|
||||
const state = ToggleableItemState.Selected
|
||||
component.selectionModel = selectionModel
|
||||
component.selectionModel.manyToOne = true
|
||||
component.manyToOne = true
|
||||
component.selectionModel.singleSelect = true
|
||||
component.selectionModel.intersection = Intersection.Include
|
||||
component.selectionModel['temporarySelectionStates'].set(id, state)
|
||||
@ -632,7 +596,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
})
|
||||
|
||||
it('should support shortcut keys', () => {
|
||||
component.selectionModel.items = items
|
||||
component.items = items
|
||||
component.icon = 'tag-fill'
|
||||
component.shortcutKey = 't'
|
||||
fixture.detectChanges()
|
||||
@ -642,7 +606,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
})
|
||||
|
||||
it('should support an extra button and not apply changes when clicked', () => {
|
||||
component.selectionModel.items = items
|
||||
component.items = items
|
||||
component.icon = 'tag-fill'
|
||||
component.extraButtonTitle = 'Extra'
|
||||
component.selectionModel = selectionModel
|
||||
|
@ -12,7 +12,6 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { Subject, filter, takeUntil } from 'rxjs'
|
||||
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
|
||||
import { MatchingModel } from 'src/app/data/matching-model'
|
||||
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
||||
import { FilterPipe } from 'src/app/pipes/filter.pipe'
|
||||
@ -62,56 +61,15 @@ export class FilterableDropdownSelectionModel {
|
||||
}
|
||||
|
||||
set items(items: MatchingModel[]) {
|
||||
if (items) {
|
||||
this._items = Array.from(items)
|
||||
this.sortItems()
|
||||
this.setNullItem()
|
||||
}
|
||||
}
|
||||
|
||||
private setNullItem() {
|
||||
if (this.manyToOne && this.logicalOperator === LogicalOperator.Or) {
|
||||
if (this._items[0]?.id === null) {
|
||||
this._items.shift()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const item = {
|
||||
name: $localize`:Filter drop down element to filter for documents with no correspondent/type/tag assigned:Not assigned`,
|
||||
id:
|
||||
this.manyToOne || this.intersection === Intersection.Include
|
||||
? null
|
||||
: NEGATIVE_NULL_FILTER_VALUE,
|
||||
}
|
||||
|
||||
if (
|
||||
this._items[0]?.id === null ||
|
||||
this._items[0]?.id === NEGATIVE_NULL_FILTER_VALUE
|
||||
) {
|
||||
this._items[0] = item
|
||||
} else if (this._items) {
|
||||
this._items.unshift(item)
|
||||
}
|
||||
}
|
||||
|
||||
constructor(manyToOne: boolean = false) {
|
||||
this.manyToOne = manyToOne
|
||||
this._items = items
|
||||
this.sortItems()
|
||||
}
|
||||
|
||||
private sortItems() {
|
||||
this._items.sort((a, b) => {
|
||||
if (
|
||||
(a.id == null && b.id != null) ||
|
||||
(a.id == NEGATIVE_NULL_FILTER_VALUE &&
|
||||
b.id != NEGATIVE_NULL_FILTER_VALUE)
|
||||
) {
|
||||
if (a.id == null && b.id != null) {
|
||||
return -1
|
||||
} else if (
|
||||
(a.id != null && b.id == null) ||
|
||||
(a.id != NEGATIVE_NULL_FILTER_VALUE &&
|
||||
b.id == NEGATIVE_NULL_FILTER_VALUE)
|
||||
) {
|
||||
} else if (a.id != null && b.id == null) {
|
||||
return 1
|
||||
} else if (
|
||||
this.getNonTemporary(a.id) == ToggleableItemState.NotSelected &&
|
||||
@ -272,7 +230,6 @@ export class FilterableDropdownSelectionModel {
|
||||
|
||||
set logicalOperator(operator: LogicalOperator) {
|
||||
this.temporaryLogicalOperator = operator
|
||||
this.setNullItem()
|
||||
}
|
||||
|
||||
toggleOperator() {
|
||||
@ -285,7 +242,6 @@ export class FilterableDropdownSelectionModel {
|
||||
|
||||
set intersection(intersection: Intersection) {
|
||||
this.temporaryIntersection = intersection
|
||||
this.setNullItem()
|
||||
}
|
||||
|
||||
toggleIntersection() {
|
||||
@ -294,20 +250,9 @@ export class FilterableDropdownSelectionModel {
|
||||
this.intersection == Intersection.Include
|
||||
? ToggleableItemState.Selected
|
||||
: ToggleableItemState.Excluded
|
||||
|
||||
this.temporarySelectionStates.forEach((state, key) => {
|
||||
if (key === null && this.intersection === Intersection.Exclude) {
|
||||
this.temporarySelectionStates.set(NEGATIVE_NULL_FILTER_VALUE, newState)
|
||||
} else if (
|
||||
key === NEGATIVE_NULL_FILTER_VALUE &&
|
||||
this.intersection === Intersection.Include
|
||||
) {
|
||||
this.temporarySelectionStates.set(null, newState)
|
||||
} else {
|
||||
this.temporarySelectionStates.set(key, newState)
|
||||
}
|
||||
this.temporarySelectionStates.set(key, newState)
|
||||
})
|
||||
|
||||
this.changed.next(this)
|
||||
}
|
||||
|
||||
@ -329,7 +274,6 @@ export class FilterableDropdownSelectionModel {
|
||||
this.temporarySelectionStates.clear()
|
||||
this.temporaryLogicalOperator = this._logicalOperator = LogicalOperator.And
|
||||
this.temporaryIntersection = this._intersection = Intersection.Include
|
||||
this.setNullItem()
|
||||
if (fireEvent) {
|
||||
this.changed.next(this)
|
||||
}
|
||||
@ -361,10 +305,8 @@ export class FilterableDropdownSelectionModel {
|
||||
|
||||
isNoneSelected() {
|
||||
return (
|
||||
(this.selectionSize() == 1 &&
|
||||
this.get(null) == ToggleableItemState.Selected) ||
|
||||
(this.intersection == Intersection.Exclude &&
|
||||
this.get(NEGATIVE_NULL_FILTER_VALUE) == ToggleableItemState.Excluded)
|
||||
this.selectionSize() == 1 &&
|
||||
this.get(null) == ToggleableItemState.Selected
|
||||
)
|
||||
}
|
||||
|
||||
@ -442,13 +384,25 @@ export class FilterableDropdownComponent
|
||||
|
||||
filterText: string
|
||||
|
||||
_selectionModel: FilterableDropdownSelectionModel
|
||||
@Input()
|
||||
set items(items: MatchingModel[]) {
|
||||
if (items) {
|
||||
this._selectionModel.items = Array.from(items)
|
||||
this._selectionModel.items.unshift({
|
||||
name: $localize`:Filter drop down element to filter for documents with no correspondent/type/tag assigned:Not assigned`,
|
||||
id: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
get items(): MatchingModel[] {
|
||||
return this._selectionModel.items
|
||||
}
|
||||
|
||||
@Input({ required: true })
|
||||
_selectionModel: FilterableDropdownSelectionModel =
|
||||
new FilterableDropdownSelectionModel()
|
||||
|
||||
@Input()
|
||||
set selectionModel(model: FilterableDropdownSelectionModel) {
|
||||
if (this.selectionModel) {
|
||||
this.selectionModel.changed.complete()
|
||||
@ -469,6 +423,11 @@ export class FilterableDropdownComponent
|
||||
@Output()
|
||||
selectionModelChange = new EventEmitter<FilterableDropdownSelectionModel>()
|
||||
|
||||
@Input()
|
||||
set manyToOne(manyToOne: boolean) {
|
||||
this.selectionModel.manyToOne = manyToOne
|
||||
}
|
||||
|
||||
get manyToOne() {
|
||||
return this.selectionModel.manyToOne
|
||||
}
|
||||
@ -525,7 +484,7 @@ export class FilterableDropdownComponent
|
||||
return this.manyToOne
|
||||
? this.selectionModel.selectionSize() > 1 &&
|
||||
this.selectionModel.getExcludedItems().length == 0
|
||||
: true
|
||||
: !this.selectionModel.isNoneSelected()
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
|
@ -34,17 +34,6 @@
|
||||
}
|
||||
|
||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
||||
@if (!settingsService.offerTour() && savedViewService.allViews.length === 0) {
|
||||
<div class="col">
|
||||
<div class="card shadow-sm bg-light opacity-50">
|
||||
<div class="card-body">
|
||||
<div class="text-center">
|
||||
<p class="mb-0 fst-italic"><i-bs name="info-circle" class="me-2"></i-bs><ng-container i18n>Hint: saved views can be created from the <a routerLink="/documents">documents list</a></ng-container></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@for (v of dashboardViews; track v.id) {
|
||||
<div class="col">
|
||||
<pngx-saved-view-widget
|
||||
|
@ -105,7 +105,6 @@ describe('DashboardComponent', () => {
|
||||
results: saved_views,
|
||||
}),
|
||||
dashboardViews: saved_views.filter((v) => v.show_on_dashboard),
|
||||
allViews: saved_views,
|
||||
},
|
||||
},
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
|
@ -6,8 +6,6 @@ import {
|
||||
moveItemInArray,
|
||||
} from '@angular/cdk/drag-drop'
|
||||
import { Component } from '@angular/core'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { SavedView } from 'src/app/data/saved-view'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
@ -37,8 +35,6 @@ import { WelcomeWidgetComponent } from './widgets/welcome-widget/welcome-widget.
|
||||
IfPermissionsDirective,
|
||||
DragDropModule,
|
||||
TourNgBootstrapModule,
|
||||
NgxBootstrapIconsModule,
|
||||
RouterModule,
|
||||
],
|
||||
})
|
||||
export class DashboardComponent extends ComponentWithPermissions {
|
||||
|
@ -55,7 +55,7 @@
|
||||
}
|
||||
@case (DisplayField.TAGS) {
|
||||
@for (tagID of doc.tags; track tagID) {
|
||||
<pngx-tag [tagID]="tagID" class="ms-1 fs-6" (click)="clickTag(tagID, $event)" [clickable]="true" linkTitle="Filter by tag" i18n-title></pngx-tag>
|
||||
<pngx-tag [tagID]="tagID" class="ms-1" (click)="clickTag(tagID, $event)" [clickable]="true" linkTitle="Filter by tag" i18n-title></pngx-tag>
|
||||
}
|
||||
}
|
||||
@case (DisplayField.DOCUMENT_TYPE) {
|
||||
|
@ -824,18 +824,11 @@ export class DocumentDetailComponent
|
||||
},
|
||||
error: (error) => {
|
||||
this.networkActive = false
|
||||
const canEdit =
|
||||
this.permissionsService.currentUserHasObjectPermissions(
|
||||
PermissionAction.Change,
|
||||
this.document
|
||||
)
|
||||
if (!canEdit) {
|
||||
// document was 'given away'
|
||||
this.openDocumentService.setDirty(this.document, false)
|
||||
if (!this.userCanEdit) {
|
||||
this.toastService.showInfo(
|
||||
$localize`Document "${this.document.title}" saved successfully.`
|
||||
)
|
||||
this.close()
|
||||
close && this.close()
|
||||
} else {
|
||||
this.error = error.error
|
||||
this.toastService.showError(
|
||||
|
@ -20,8 +20,10 @@
|
||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
|
||||
<pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
|
||||
filterPlaceholder="Filter tags" i18n-filterPlaceholder
|
||||
[items]="tags"
|
||||
[disabled]="!userCanEditAll || disabled"
|
||||
[editing]="true"
|
||||
[manyToOne]="true"
|
||||
[applyOnClose]="applyOnClose"
|
||||
[createRef]="createTag.bind(this)"
|
||||
(opened)="openTagsDropdown()"
|
||||
@ -34,6 +36,7 @@
|
||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
|
||||
<pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title
|
||||
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
|
||||
[items]="correspondents"
|
||||
[disabled]="!userCanEditAll || disabled"
|
||||
[editing]="true"
|
||||
[applyOnClose]="applyOnClose"
|
||||
@ -48,6 +51,7 @@
|
||||
@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
|
||||
[items]="documentTypes"
|
||||
[disabled]="!userCanEditAll || disabled"
|
||||
[editing]="true"
|
||||
[applyOnClose]="applyOnClose"
|
||||
@ -62,6 +66,7 @@
|
||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
|
||||
<pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title
|
||||
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
|
||||
[items]="storagePaths"
|
||||
[disabled]="!userCanEditAll || disabled"
|
||||
[editing]="true"
|
||||
[applyOnClose]="applyOnClose"
|
||||
@ -76,8 +81,10 @@
|
||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) {
|
||||
<pngx-filterable-dropdown title="Custom fields" icon="ui-radios" i18n-title
|
||||
filterPlaceholder="Filter custom fields" i18n-filterPlaceholder
|
||||
[items]="customFields"
|
||||
[disabled]="!userCanEditAll"
|
||||
[editing]="true"
|
||||
[manyToOne]="true"
|
||||
[applyOnClose]="applyOnClose"
|
||||
[createRef]="createCustomField.bind(this)"
|
||||
(opened)="openCustomFieldsDropdown()"
|
||||
|
@ -1150,10 +1150,10 @@ describe('BulkEditorComponent', () => {
|
||||
|
||||
it('should not attempt to retrieve objects if user does not have permissions', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
expect(component.tagSelectionModel.items.length).toEqual(0)
|
||||
expect(component.correspondentSelectionModel.items.length).toEqual(0)
|
||||
expect(component.documentTypeSelectionModel.items.length).toEqual(0)
|
||||
expect(component.storagePathsSelectionModel.items.length).toEqual(0)
|
||||
expect(component.tags).toBeUndefined()
|
||||
expect(component.correspondents).toBeUndefined()
|
||||
expect(component.documentTypes).toBeUndefined()
|
||||
expect(component.storagePaths).toBeUndefined()
|
||||
httpTestingController.expectNone(`${environment.apiBaseUrl}documents/tags/`)
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/correspondents/`
|
||||
@ -1204,9 +1204,7 @@ describe('BulkEditorComponent', () => {
|
||||
expect(tagListAllSpy).toHaveBeenCalled()
|
||||
|
||||
expect(tagSelectionModelToggleSpy).toHaveBeenCalledWith(newTag.id)
|
||||
expect(component.tagSelectionModel.items).toEqual(
|
||||
[{ id: null, name: 'Not assigned' }].concat(tags.results as any)
|
||||
)
|
||||
expect(component.tags).toEqual(tags.results)
|
||||
})
|
||||
|
||||
it('should support create new correspondent', () => {
|
||||
@ -1253,9 +1251,7 @@ describe('BulkEditorComponent', () => {
|
||||
expect(correspondentSelectionModelToggleSpy).toHaveBeenCalledWith(
|
||||
newCorrespondent.id
|
||||
)
|
||||
expect(component.correspondentSelectionModel.items).toEqual(
|
||||
[{ id: null, name: 'Not assigned' }].concat(correspondents.results as any)
|
||||
)
|
||||
expect(component.correspondents).toEqual(correspondents.results)
|
||||
})
|
||||
|
||||
it('should support create new document type', () => {
|
||||
@ -1299,9 +1295,7 @@ describe('BulkEditorComponent', () => {
|
||||
expect(documentTypeSelectionModelToggleSpy).toHaveBeenCalledWith(
|
||||
newDocumentType.id
|
||||
)
|
||||
expect(component.documentTypeSelectionModel.items).toEqual(
|
||||
[{ id: null, name: 'Not assigned' }].concat(documentTypes.results as any)
|
||||
)
|
||||
expect(component.documentTypes).toEqual(documentTypes.results)
|
||||
})
|
||||
|
||||
it('should support create new storage path', () => {
|
||||
@ -1345,9 +1339,7 @@ describe('BulkEditorComponent', () => {
|
||||
expect(storagePathsSelectionModelToggleSpy).toHaveBeenCalledWith(
|
||||
newStoragePath.id
|
||||
)
|
||||
expect(component.storagePathsSelectionModel.items).toEqual(
|
||||
[{ id: null, name: 'Not assigned' }].concat(storagePaths.results as any)
|
||||
)
|
||||
expect(component.storagePaths).toEqual(storagePaths.results)
|
||||
})
|
||||
|
||||
it('should support create new custom field', () => {
|
||||
@ -1399,9 +1391,7 @@ describe('BulkEditorComponent', () => {
|
||||
expect(customFieldsSelectionModelToggleSpy).toHaveBeenCalledWith(
|
||||
newCustomField.id
|
||||
)
|
||||
expect(component.customFieldsSelectionModel.items).toEqual(
|
||||
[{ id: null, name: 'Not assigned' }].concat(customFields.results as any)
|
||||
)
|
||||
expect(component.customFields).toEqual(customFields.results)
|
||||
})
|
||||
|
||||
it('should open the bulk edit custom field values dialog with correct parameters', () => {
|
||||
@ -1426,17 +1416,17 @@ describe('BulkEditorComponent', () => {
|
||||
const toastServiceShowErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const listReloadSpy = jest.spyOn(documentListViewService, 'reload')
|
||||
|
||||
component.customFieldsSelectionModel.items = [
|
||||
component.customFields = [
|
||||
{ id: 1, name: 'Custom Field 1', data_type: CustomFieldDataType.String },
|
||||
{ id: 2, name: 'Custom Field 2', data_type: CustomFieldDataType.String },
|
||||
] as any
|
||||
]
|
||||
|
||||
component.setCustomFieldValues({
|
||||
itemsToAdd: [{ id: 1 }, { id: 2 }],
|
||||
itemsToRemove: [1],
|
||||
} as any)
|
||||
|
||||
expect(modal.componentInstance.customFields.length).toEqual(2)
|
||||
expect(modal.componentInstance.customFields).toEqual(component.customFields)
|
||||
expect(modal.componentInstance.fieldsToAddIds).toEqual([1, 2])
|
||||
expect(modal.componentInstance.documents).toEqual([3, 4])
|
||||
|
||||
|
@ -14,8 +14,12 @@ import { saveAs } from 'file-saver'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { first, map, Subject, switchMap, takeUntil } from 'rxjs'
|
||||
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
|
||||
import { Correspondent } from 'src/app/data/correspondent'
|
||||
import { CustomField } from 'src/app/data/custom-field'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
import { MatchingModel } from 'src/app/data/matching-model'
|
||||
import { StoragePath } from 'src/app/data/storage-path'
|
||||
import { Tag } from 'src/app/data/tag'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||
@ -71,11 +75,17 @@ export class BulkEditorComponent
|
||||
extends ComponentWithPermissions
|
||||
implements OnInit, OnDestroy
|
||||
{
|
||||
tagSelectionModel = new FilterableDropdownSelectionModel(true)
|
||||
tags: Tag[]
|
||||
correspondents: Correspondent[]
|
||||
documentTypes: DocumentType[]
|
||||
storagePaths: StoragePath[]
|
||||
customFields: CustomField[]
|
||||
|
||||
tagSelectionModel = new FilterableDropdownSelectionModel()
|
||||
correspondentSelectionModel = new FilterableDropdownSelectionModel()
|
||||
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
|
||||
storagePathsSelectionModel = new FilterableDropdownSelectionModel()
|
||||
customFieldsSelectionModel = new FilterableDropdownSelectionModel(true)
|
||||
customFieldsSelectionModel = new FilterableDropdownSelectionModel()
|
||||
tagDocumentCounts: SelectionDataItem[]
|
||||
correspondentDocumentCounts: SelectionDataItem[]
|
||||
documentTypeDocumentCounts: SelectionDataItem[]
|
||||
@ -166,7 +176,7 @@ export class BulkEditorComponent
|
||||
this.tagService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.tagSelectionModel.items = result.results))
|
||||
.subscribe((result) => (this.tags = result.results))
|
||||
}
|
||||
if (
|
||||
this.permissionService.currentUserCan(
|
||||
@ -177,9 +187,7 @@ export class BulkEditorComponent
|
||||
this.correspondentService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe(
|
||||
(result) => (this.correspondentSelectionModel.items = result.results)
|
||||
)
|
||||
.subscribe((result) => (this.correspondents = result.results))
|
||||
}
|
||||
if (
|
||||
this.permissionService.currentUserCan(
|
||||
@ -190,9 +198,7 @@ export class BulkEditorComponent
|
||||
this.documentTypeService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe(
|
||||
(result) => (this.documentTypeSelectionModel.items = result.results)
|
||||
)
|
||||
.subscribe((result) => (this.documentTypes = result.results))
|
||||
}
|
||||
if (
|
||||
this.permissionService.currentUserCan(
|
||||
@ -203,9 +209,7 @@ export class BulkEditorComponent
|
||||
this.storagePathService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe(
|
||||
(result) => (this.storagePathsSelectionModel.items = result.results)
|
||||
)
|
||||
.subscribe((result) => (this.storagePaths = result.results))
|
||||
}
|
||||
if (
|
||||
this.permissionService.currentUserCan(
|
||||
@ -216,9 +220,7 @@ export class BulkEditorComponent
|
||||
this.customFieldService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe(
|
||||
(result) => (this.customFieldsSelectionModel.items = result.results)
|
||||
)
|
||||
.subscribe((result) => (this.customFields = result.results))
|
||||
}
|
||||
|
||||
this.downloadForm
|
||||
@ -649,7 +651,7 @@ export class BulkEditorComponent
|
||||
)
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(({ newTag, tags }) => {
|
||||
this.tagSelectionModel.items = tags.results
|
||||
this.tags = tags.results
|
||||
this.tagSelectionModel.toggle(newTag.id)
|
||||
})
|
||||
}
|
||||
@ -672,7 +674,7 @@ export class BulkEditorComponent
|
||||
)
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(({ newCorrespondent, correspondents }) => {
|
||||
this.correspondentSelectionModel.items = correspondents.results
|
||||
this.correspondents = correspondents.results
|
||||
this.correspondentSelectionModel.toggle(newCorrespondent.id)
|
||||
})
|
||||
}
|
||||
@ -693,7 +695,7 @@ export class BulkEditorComponent
|
||||
)
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(({ newDocumentType, documentTypes }) => {
|
||||
this.documentTypeSelectionModel.items = documentTypes.results
|
||||
this.documentTypes = documentTypes.results
|
||||
this.documentTypeSelectionModel.toggle(newDocumentType.id)
|
||||
})
|
||||
}
|
||||
@ -714,7 +716,7 @@ export class BulkEditorComponent
|
||||
)
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(({ newStoragePath, storagePaths }) => {
|
||||
this.storagePathsSelectionModel.items = storagePaths.results
|
||||
this.storagePaths = storagePaths.results
|
||||
this.storagePathsSelectionModel.toggle(newStoragePath.id)
|
||||
})
|
||||
}
|
||||
@ -735,7 +737,7 @@ export class BulkEditorComponent
|
||||
)
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(({ newCustomField, customFields }) => {
|
||||
this.customFieldsSelectionModel.items = customFields.results
|
||||
this.customFields = customFields.results
|
||||
this.customFieldsSelectionModel.toggle(newCustomField.id)
|
||||
})
|
||||
}
|
||||
@ -873,9 +875,7 @@ export class BulkEditorComponent
|
||||
})
|
||||
const dialog =
|
||||
modal.componentInstance as CustomFieldsBulkEditDialogComponent
|
||||
dialog.customFields = (
|
||||
this.customFieldsSelectionModel.items as CustomField[]
|
||||
).filter((f) => f.id !== null)
|
||||
dialog.customFields = this.customFields
|
||||
dialog.fieldsToAddIds = changedCustomFields.itemsToAdd.map(
|
||||
(item) => item.id
|
||||
)
|
||||
|
@ -310,8 +310,8 @@
|
||||
</div>
|
||||
}
|
||||
@if (activeDisplayFields.includes(DisplayField.TAGS)) {
|
||||
@for (tagID of d.tags; track tagID) {
|
||||
<pngx-tag [tagID]="tagID" class="ms-1 fs-6" clickable="true" linkTitle="Filter by tag" i18n-linkTitle (click)="clickTag(tagID);$event.stopPropagation()"></pngx-tag>
|
||||
@for (tagID of d.tags; track t) {
|
||||
<pngx-tag [tagID]="tagID" class="ms-1" clickable="true" linkTitle="Filter by tag" i18n-linkTitle (click)="clickTag(tagID);$event.stopPropagation()"></pngx-tag>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
|
@ -35,9 +35,11 @@
|
||||
<div class="col-auto">
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag) && tagSelectionModel.items.length > 0) {
|
||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag) && tags.length > 0) {
|
||||
<pngx-filterable-dropdown class="flex-fill fade" [class.show]="show" title="Tags" icon="tag-fill" i18n-title
|
||||
filterPlaceholder="Filter tags" i18n-filterPlaceholder
|
||||
[items]="tags"
|
||||
[manyToOne]="true"
|
||||
[(selectionModel)]="tagSelectionModel"
|
||||
(selectionModelChange)="updateRules()"
|
||||
(opened)="onTagsDropdownOpen()"
|
||||
@ -46,9 +48,10 @@
|
||||
[disabled]="disabled"
|
||||
shortcutKey="t"></pngx-filterable-dropdown>
|
||||
}
|
||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Correspondent) && correspondentSelectionModel.items.length > 0) {
|
||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Correspondent) && correspondents.length > 0) {
|
||||
<pngx-filterable-dropdown class="flex-fill fade" [class.show]="show" title="Correspondent" icon="person-fill" i18n-title
|
||||
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
|
||||
[items]="correspondents"
|
||||
[(selectionModel)]="correspondentSelectionModel"
|
||||
(selectionModelChange)="updateRules()"
|
||||
(opened)="onCorrespondentDropdownOpen()"
|
||||
@ -57,9 +60,10 @@
|
||||
[disabled]="disabled"
|
||||
shortcutKey="y"></pngx-filterable-dropdown>
|
||||
}
|
||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.DocumentType) && documentTypeSelectionModel.items.length > 0) {
|
||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.DocumentType) && documentTypes.length > 0) {
|
||||
<pngx-filterable-dropdown class="flex-fill fade" [class.show]="show" title="Document type" icon="file-earmark-fill" i18n-title
|
||||
filterPlaceholder="Filter document types" i18n-filterPlaceholder
|
||||
[items]="documentTypes"
|
||||
[(selectionModel)]="documentTypeSelectionModel"
|
||||
(selectionModelChange)="updateRules()"
|
||||
(opened)="onDocumentTypeDropdownOpen()"
|
||||
@ -68,9 +72,10 @@
|
||||
[disabled]="disabled"
|
||||
shortcutKey="u"></pngx-filterable-dropdown>
|
||||
}
|
||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.StoragePath) && storagePathSelectionModel.items.length > 0) {
|
||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.StoragePath) && storagePaths.length > 0) {
|
||||
<pngx-filterable-dropdown class="flex-fill fade" [class.show]="show" title="Storage path" icon="folder-fill" i18n-title
|
||||
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
|
||||
[items]="storagePaths"
|
||||
[(selectionModel)]="storagePathSelectionModel"
|
||||
(selectionModelChange)="updateRules()"
|
||||
(opened)="onStoragePathDropdownOpen()"
|
||||
|
@ -69,7 +69,6 @@ import {
|
||||
FILTER_STORAGE_PATH,
|
||||
FILTER_TITLE,
|
||||
FILTER_TITLE_CONTENT,
|
||||
NEGATIVE_NULL_FILTER_VALUE,
|
||||
} from 'src/app/data/filter-rule-type'
|
||||
import { StoragePath } from 'src/app/data/storage-path'
|
||||
import { Tag } from 'src/app/data/tag'
|
||||
@ -672,6 +671,9 @@ describe('FilterEditorComponent', () => {
|
||||
value: '12',
|
||||
},
|
||||
]
|
||||
expect(component.correspondentSelectionModel.logicalOperator).toEqual(
|
||||
LogicalOperator.Or
|
||||
)
|
||||
expect(component.correspondentSelectionModel.intersection).toEqual(
|
||||
Intersection.Include
|
||||
)
|
||||
@ -679,19 +681,6 @@ describe('FilterEditorComponent', () => {
|
||||
correspondents[0],
|
||||
])
|
||||
component.toggleCorrespondent(12) // coverage
|
||||
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_CORRESPONDENT,
|
||||
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
|
||||
},
|
||||
]
|
||||
expect(component.correspondentSelectionModel.intersection).toEqual(
|
||||
Intersection.Exclude
|
||||
)
|
||||
expect(component.correspondentSelectionModel.getExcludedItems()).toEqual([
|
||||
{ id: NEGATIVE_NULL_FILTER_VALUE, name: 'Not assigned' },
|
||||
])
|
||||
}))
|
||||
|
||||
it('should ingest filter rules for has any of correspondents', fakeAsync(() => {
|
||||
@ -765,6 +754,9 @@ describe('FilterEditorComponent', () => {
|
||||
value: '22',
|
||||
},
|
||||
]
|
||||
expect(component.documentTypeSelectionModel.logicalOperator).toEqual(
|
||||
LogicalOperator.Or
|
||||
)
|
||||
expect(component.documentTypeSelectionModel.intersection).toEqual(
|
||||
Intersection.Include
|
||||
)
|
||||
@ -772,19 +764,6 @@ describe('FilterEditorComponent', () => {
|
||||
document_types[0],
|
||||
])
|
||||
component.toggleDocumentType(22) // coverage
|
||||
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_DOCUMENT_TYPE,
|
||||
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
|
||||
},
|
||||
]
|
||||
expect(component.documentTypeSelectionModel.intersection).toEqual(
|
||||
Intersection.Exclude
|
||||
)
|
||||
expect(component.documentTypeSelectionModel.getExcludedItems()).toEqual([
|
||||
{ id: NEGATIVE_NULL_FILTER_VALUE, name: 'Not assigned' },
|
||||
])
|
||||
}))
|
||||
|
||||
it('should ingest filter rules for has any of document types', fakeAsync(() => {
|
||||
@ -801,6 +780,9 @@ describe('FilterEditorComponent', () => {
|
||||
value: '23',
|
||||
},
|
||||
]
|
||||
expect(component.documentTypeSelectionModel.logicalOperator).toEqual(
|
||||
LogicalOperator.Or
|
||||
)
|
||||
expect(component.documentTypeSelectionModel.intersection).toEqual(
|
||||
Intersection.Include
|
||||
)
|
||||
@ -855,6 +837,9 @@ describe('FilterEditorComponent', () => {
|
||||
value: '32',
|
||||
},
|
||||
]
|
||||
expect(component.storagePathSelectionModel.logicalOperator).toEqual(
|
||||
LogicalOperator.Or
|
||||
)
|
||||
expect(component.storagePathSelectionModel.intersection).toEqual(
|
||||
Intersection.Include
|
||||
)
|
||||
@ -862,19 +847,6 @@ describe('FilterEditorComponent', () => {
|
||||
storage_paths[0],
|
||||
])
|
||||
component.toggleStoragePath(32) // coverage
|
||||
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_STORAGE_PATH,
|
||||
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
|
||||
},
|
||||
]
|
||||
expect(component.storagePathSelectionModel.intersection).toEqual(
|
||||
Intersection.Exclude
|
||||
)
|
||||
expect(component.storagePathSelectionModel.getExcludedItems()).toEqual([
|
||||
{ id: NEGATIVE_NULL_FILTER_VALUE, name: 'Not assigned' },
|
||||
])
|
||||
}))
|
||||
|
||||
it('should ingest filter rules for has any of storage paths', fakeAsync(() => {
|
||||
@ -1426,19 +1398,6 @@ describe('FilterEditorComponent', () => {
|
||||
value: null,
|
||||
},
|
||||
])
|
||||
|
||||
const excludeButton = correspondentsFilterableDropdown.queryAll(
|
||||
By.css('input[value=exclude]')
|
||||
)[0]
|
||||
excludeButton.nativeElement.checked = true
|
||||
excludeButton.triggerEventHandler('change')
|
||||
fixture.detectChanges()
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_CORRESPONDENT,
|
||||
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
|
||||
},
|
||||
])
|
||||
}))
|
||||
|
||||
it('should convert user input to correct filter rules on document type selections', fakeAsync(() => {
|
||||
@ -1496,19 +1455,6 @@ describe('FilterEditorComponent', () => {
|
||||
value: null,
|
||||
},
|
||||
])
|
||||
|
||||
const excludeButton = docTypesFilterableDropdown.queryAll(
|
||||
By.css('input[value=exclude]')
|
||||
)[0]
|
||||
excludeButton.nativeElement.checked = true
|
||||
excludeButton.triggerEventHandler('change')
|
||||
fixture.detectChanges()
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_DOCUMENT_TYPE,
|
||||
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
|
||||
},
|
||||
])
|
||||
}))
|
||||
|
||||
it('should convert user input to correct filter rules on storage path selections', fakeAsync(() => {
|
||||
@ -1566,19 +1512,6 @@ describe('FilterEditorComponent', () => {
|
||||
value: null,
|
||||
},
|
||||
])
|
||||
|
||||
const excludeButton = storagePathsFilterableDropdown.queryAll(
|
||||
By.css('input[value=exclude]')
|
||||
)[0]
|
||||
excludeButton.nativeElement.checked = true
|
||||
excludeButton.triggerEventHandler('change')
|
||||
fixture.detectChanges()
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_STORAGE_PATH,
|
||||
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
|
||||
},
|
||||
])
|
||||
}))
|
||||
|
||||
it('should convert user input to correct filter rules on custom field selections', fakeAsync(() => {
|
||||
|
@ -26,12 +26,14 @@ import {
|
||||
switchMap,
|
||||
takeUntil,
|
||||
} from 'rxjs/operators'
|
||||
import { Correspondent } from 'src/app/data/correspondent'
|
||||
import { CustomField } from 'src/app/data/custom-field'
|
||||
import {
|
||||
CustomFieldQueryLogicalOperator,
|
||||
CustomFieldQueryOperator,
|
||||
} from 'src/app/data/custom-field-query'
|
||||
import { Document } from 'src/app/data/document'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
import { FilterRule } from 'src/app/data/filter-rule'
|
||||
import {
|
||||
FILTER_ADDED_AFTER,
|
||||
@ -73,8 +75,9 @@ import {
|
||||
FILTER_STORAGE_PATH,
|
||||
FILTER_TITLE,
|
||||
FILTER_TITLE_CONTENT,
|
||||
NEGATIVE_NULL_FILTER_VALUE,
|
||||
} from 'src/app/data/filter-rule-type'
|
||||
import { StoragePath } from 'src/app/data/storage-path'
|
||||
import { Tag } from 'src/app/data/tag'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionType,
|
||||
@ -248,9 +251,7 @@ export class FilterEditorComponent
|
||||
case FILTER_HAS_CORRESPONDENT_ANY:
|
||||
if (rule.value) {
|
||||
return $localize`Correspondent: ${
|
||||
this.correspondentSelectionModel.items.find(
|
||||
(c) => c.id == +rule.value
|
||||
)?.name
|
||||
this.correspondents.find((c) => c.id == +rule.value)?.name
|
||||
}`
|
||||
} else {
|
||||
return $localize`Without correspondent`
|
||||
@ -260,9 +261,7 @@ export class FilterEditorComponent
|
||||
case FILTER_HAS_DOCUMENT_TYPE_ANY:
|
||||
if (rule.value) {
|
||||
return $localize`Document type: ${
|
||||
this.documentTypeSelectionModel.items.find(
|
||||
(dt) => dt.id == +rule.value
|
||||
)?.name
|
||||
this.documentTypes.find((dt) => dt.id == +rule.value)?.name
|
||||
}`
|
||||
} else {
|
||||
return $localize`Without document type`
|
||||
@ -272,9 +271,7 @@ export class FilterEditorComponent
|
||||
case FILTER_HAS_STORAGE_PATH_ANY:
|
||||
if (rule.value) {
|
||||
return $localize`Storage path: ${
|
||||
this.storagePathSelectionModel.items.find(
|
||||
(sp) => sp.id == +rule.value
|
||||
)?.name
|
||||
this.storagePaths.find((sp) => sp.id == +rule.value)?.name
|
||||
}`
|
||||
} else {
|
||||
return $localize`Without storage path`
|
||||
@ -282,7 +279,7 @@ export class FilterEditorComponent
|
||||
|
||||
case FILTER_HAS_TAGS_ALL:
|
||||
return $localize`Tag: ${
|
||||
this.tagSelectionModel.items.find((t) => t.id == +rule.value)?.name
|
||||
this.tags.find((t) => t.id == +rule.value)?.name
|
||||
}`
|
||||
|
||||
case FILTER_HAS_ANY_TAG:
|
||||
@ -329,6 +326,10 @@ export class FilterEditorComponent
|
||||
@ViewChild('textFilterInput')
|
||||
textFilterInput: ElementRef
|
||||
|
||||
tags: Tag[] = []
|
||||
correspondents: Correspondent[] = []
|
||||
documentTypes: DocumentType[] = []
|
||||
storagePaths: StoragePath[] = []
|
||||
customFields: CustomField[] = []
|
||||
|
||||
tagDocumentCounts: SelectionDataItem[]
|
||||
@ -369,7 +370,7 @@ export class FilterEditorComponent
|
||||
)
|
||||
}
|
||||
|
||||
tagSelectionModel = new FilterableDropdownSelectionModel(true)
|
||||
tagSelectionModel = new FilterableDropdownSelectionModel()
|
||||
correspondentSelectionModel = new FilterableDropdownSelectionModel()
|
||||
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
|
||||
storagePathSelectionModel = new FilterableDropdownSelectionModel()
|
||||
@ -550,19 +551,6 @@ export class FilterEditorComponent
|
||||
)
|
||||
break
|
||||
case FILTER_CORRESPONDENT:
|
||||
this.correspondentSelectionModel.intersection =
|
||||
rule.value == NEGATIVE_NULL_FILTER_VALUE.toString()
|
||||
? Intersection.Exclude
|
||||
: Intersection.Include
|
||||
this.correspondentSelectionModel.set(
|
||||
rule.value ? +rule.value : null,
|
||||
this.correspondentSelectionModel.intersection ==
|
||||
Intersection.Include
|
||||
? ToggleableItemState.Selected
|
||||
: ToggleableItemState.Excluded,
|
||||
false
|
||||
)
|
||||
break
|
||||
case FILTER_HAS_CORRESPONDENT_ANY:
|
||||
this.correspondentSelectionModel.logicalOperator = LogicalOperator.Or
|
||||
this.correspondentSelectionModel.intersection = Intersection.Include
|
||||
@ -581,18 +569,6 @@ export class FilterEditorComponent
|
||||
)
|
||||
break
|
||||
case FILTER_DOCUMENT_TYPE:
|
||||
this.documentTypeSelectionModel.intersection =
|
||||
rule.value == NEGATIVE_NULL_FILTER_VALUE.toString()
|
||||
? Intersection.Exclude
|
||||
: Intersection.Include
|
||||
this.documentTypeSelectionModel.set(
|
||||
rule.value ? +rule.value : null,
|
||||
this.documentTypeSelectionModel.intersection == Intersection.Include
|
||||
? ToggleableItemState.Selected
|
||||
: ToggleableItemState.Excluded,
|
||||
false
|
||||
)
|
||||
break
|
||||
case FILTER_HAS_DOCUMENT_TYPE_ANY:
|
||||
this.documentTypeSelectionModel.logicalOperator = LogicalOperator.Or
|
||||
this.documentTypeSelectionModel.intersection = Intersection.Include
|
||||
@ -611,18 +587,6 @@ export class FilterEditorComponent
|
||||
)
|
||||
break
|
||||
case FILTER_STORAGE_PATH:
|
||||
this.storagePathSelectionModel.intersection =
|
||||
rule.value == NEGATIVE_NULL_FILTER_VALUE.toString()
|
||||
? Intersection.Exclude
|
||||
: Intersection.Include
|
||||
this.storagePathSelectionModel.set(
|
||||
rule.value ? +rule.value : null,
|
||||
this.storagePathSelectionModel.intersection == Intersection.Include
|
||||
? ToggleableItemState.Selected
|
||||
: ToggleableItemState.Excluded,
|
||||
false
|
||||
)
|
||||
break
|
||||
case FILTER_HAS_STORAGE_PATH_ANY:
|
||||
this.storagePathSelectionModel.logicalOperator = LogicalOperator.Or
|
||||
this.storagePathSelectionModel.intersection = Intersection.Include
|
||||
@ -845,21 +809,9 @@ export class FilterEditorComponent
|
||||
})
|
||||
})
|
||||
}
|
||||
if (
|
||||
this.correspondentSelectionModel.isNoneSelected() &&
|
||||
this.correspondentSelectionModel.intersection == Intersection.Include
|
||||
) {
|
||||
if (this.correspondentSelectionModel.isNoneSelected()) {
|
||||
filterRules.push({ rule_type: FILTER_CORRESPONDENT, value: null })
|
||||
} else {
|
||||
if (
|
||||
this.correspondentSelectionModel.isNoneSelected() &&
|
||||
this.correspondentSelectionModel.intersection == Intersection.Exclude
|
||||
) {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_CORRESPONDENT,
|
||||
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
|
||||
})
|
||||
}
|
||||
this.correspondentSelectionModel
|
||||
.getSelectedItems()
|
||||
.forEach((correspondent) => {
|
||||
@ -870,7 +822,6 @@ export class FilterEditorComponent
|
||||
})
|
||||
this.correspondentSelectionModel
|
||||
.getExcludedItems()
|
||||
.filter((correspondent) => correspondent.id > 0)
|
||||
.forEach((correspondent) => {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_DOES_NOT_HAVE_CORRESPONDENT,
|
||||
@ -878,21 +829,9 @@ export class FilterEditorComponent
|
||||
})
|
||||
})
|
||||
}
|
||||
if (
|
||||
this.documentTypeSelectionModel.isNoneSelected() &&
|
||||
this.documentTypeSelectionModel.intersection === Intersection.Include
|
||||
) {
|
||||
if (this.documentTypeSelectionModel.isNoneSelected()) {
|
||||
filterRules.push({ rule_type: FILTER_DOCUMENT_TYPE, value: null })
|
||||
} else {
|
||||
if (
|
||||
this.documentTypeSelectionModel.isNoneSelected() &&
|
||||
this.documentTypeSelectionModel.intersection == Intersection.Exclude
|
||||
) {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_DOCUMENT_TYPE,
|
||||
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
|
||||
})
|
||||
}
|
||||
this.documentTypeSelectionModel
|
||||
.getSelectedItems()
|
||||
.forEach((documentType) => {
|
||||
@ -903,7 +842,6 @@ export class FilterEditorComponent
|
||||
})
|
||||
this.documentTypeSelectionModel
|
||||
.getExcludedItems()
|
||||
.filter((documentType) => documentType.id > 0)
|
||||
.forEach((documentType) => {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE,
|
||||
@ -911,21 +849,9 @@ export class FilterEditorComponent
|
||||
})
|
||||
})
|
||||
}
|
||||
if (
|
||||
this.storagePathSelectionModel.isNoneSelected() &&
|
||||
this.storagePathSelectionModel.intersection == Intersection.Include
|
||||
) {
|
||||
if (this.storagePathSelectionModel.isNoneSelected()) {
|
||||
filterRules.push({ rule_type: FILTER_STORAGE_PATH, value: null })
|
||||
} else {
|
||||
if (
|
||||
this.storagePathSelectionModel.isNoneSelected() &&
|
||||
this.storagePathSelectionModel.intersection == Intersection.Exclude
|
||||
) {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_STORAGE_PATH,
|
||||
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
|
||||
})
|
||||
}
|
||||
this.storagePathSelectionModel
|
||||
.getSelectedItems()
|
||||
.forEach((storagePath) => {
|
||||
@ -936,7 +862,6 @@ export class FilterEditorComponent
|
||||
})
|
||||
this.storagePathSelectionModel
|
||||
.getExcludedItems()
|
||||
.filter((storagePath) => storagePath.id > 0)
|
||||
.forEach((storagePath) => {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_DOES_NOT_HAVE_STORAGE_PATH,
|
||||
@ -947,7 +872,7 @@ export class FilterEditorComponent
|
||||
let queries = this.customFieldQueriesModel.queries.map((query) =>
|
||||
query.serialize()
|
||||
)
|
||||
if (queries.length > 0 && this.customFieldQueriesModel.isValid()) {
|
||||
if (queries.length > 0) {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
|
||||
value: JSON.stringify(queries[0]),
|
||||
@ -1137,7 +1062,7 @@ export class FilterEditorComponent
|
||||
) {
|
||||
this.loadingCountTotal++
|
||||
this.tagService.listAll().subscribe((result) => {
|
||||
this.tagSelectionModel.items = result.results
|
||||
this.tags = result.results
|
||||
this.maybeCompleteLoading()
|
||||
})
|
||||
}
|
||||
@ -1149,7 +1074,7 @@ export class FilterEditorComponent
|
||||
) {
|
||||
this.loadingCountTotal++
|
||||
this.correspondentService.listAll().subscribe((result) => {
|
||||
this.correspondentSelectionModel.items = result.results
|
||||
this.correspondents = result.results
|
||||
this.maybeCompleteLoading()
|
||||
})
|
||||
}
|
||||
@ -1161,7 +1086,7 @@ export class FilterEditorComponent
|
||||
) {
|
||||
this.loadingCountTotal++
|
||||
this.documentTypeService.listAll().subscribe((result) => {
|
||||
this.documentTypeSelectionModel.items = result.results
|
||||
this.documentTypes = result.results
|
||||
this.maybeCompleteLoading()
|
||||
})
|
||||
}
|
||||
@ -1173,7 +1098,7 @@ export class FilterEditorComponent
|
||||
) {
|
||||
this.loadingCountTotal++
|
||||
this.storagePathService.listAll().subscribe((result) => {
|
||||
this.storagePathSelectionModel.items = result.results
|
||||
this.storagePaths = result.results
|
||||
this.maybeCompleteLoading()
|
||||
})
|
||||
}
|
||||
|
@ -1,7 +1,5 @@
|
||||
import { DataType } from './datatype'
|
||||
|
||||
export const NEGATIVE_NULL_FILTER_VALUE = -1
|
||||
|
||||
// These correspond to src/documents/models.py and changes here require a DB migration (and vice versa)
|
||||
export const FILTER_TITLE = 0
|
||||
export const FILTER_CONTENT = 1
|
||||
|
@ -45,8 +45,7 @@ describe('CustomDatePipe', () => {
|
||||
if (now.getMonth() === 0) {
|
||||
notNow.setFullYear(now.getFullYear() - 1)
|
||||
}
|
||||
// weird options are for february...
|
||||
expect(['Last month', '4 weeks ago', '3 weeks ago']).toContain(
|
||||
expect(['Last month', '4 weeks ago']).toContain(
|
||||
datePipe.transform(notNow, 'relative')
|
||||
)
|
||||
expect(datePipe.transform(now, 'relative')).toEqual('Just now')
|
||||
|
@ -29,7 +29,7 @@ describe('ComponentRouterService', () => {
|
||||
eventsSubject.next(
|
||||
new ActivationStart({
|
||||
url: 'test-url',
|
||||
data: { componentName: 'TestComponent' },
|
||||
component: { name: 'TestComponent' },
|
||||
} as any)
|
||||
)
|
||||
|
||||
@ -41,13 +41,13 @@ describe('ComponentRouterService', () => {
|
||||
eventsSubject.next(
|
||||
new ActivationStart({
|
||||
url: 'test-url-1',
|
||||
data: { componentName: 'TestComponent' },
|
||||
component: { name: 'TestComponent' },
|
||||
} as any)
|
||||
)
|
||||
eventsSubject.next(
|
||||
new ActivationStart({
|
||||
url: 'test-url-2',
|
||||
data: { componentName: 'TestComponent' },
|
||||
component: { name: 'TestComponent' },
|
||||
} as any)
|
||||
)
|
||||
|
||||
@ -59,13 +59,13 @@ describe('ComponentRouterService', () => {
|
||||
eventsSubject.next(
|
||||
new ActivationStart({
|
||||
url: 'test-url-1',
|
||||
data: { componentName: 'TestComponent1' },
|
||||
component: { name: 'TestComponent1' },
|
||||
} as any)
|
||||
)
|
||||
eventsSubject.next(
|
||||
new ActivationStart({
|
||||
url: 'test-url-2',
|
||||
data: { componentName: 'TestComponent2' },
|
||||
component: { name: 'TestComponent2' },
|
||||
} as any)
|
||||
)
|
||||
|
||||
@ -76,13 +76,13 @@ describe('ComponentRouterService', () => {
|
||||
eventsSubject.next(
|
||||
new ActivationStart({
|
||||
url: 'test-url-1',
|
||||
data: { componentName: 'TestComponent' },
|
||||
component: { name: 'TestComponent' },
|
||||
} as any)
|
||||
)
|
||||
eventsSubject.next(
|
||||
new ActivationStart({
|
||||
url: 'test-url-2',
|
||||
data: { componentName: 'TestComponent' },
|
||||
component: { name: 'TestComponent' },
|
||||
} as any)
|
||||
)
|
||||
|
||||
@ -93,7 +93,7 @@ describe('ComponentRouterService', () => {
|
||||
eventsSubject.next(
|
||||
new ActivationStart({
|
||||
url: 'test-url',
|
||||
data: { componentName: 'TestComponent' },
|
||||
component: { name: 'TestComponent' },
|
||||
} as any)
|
||||
)
|
||||
|
||||
|
@ -17,11 +17,11 @@ export class ComponentRouterService {
|
||||
.subscribe((event: ActivationStart) => {
|
||||
if (
|
||||
this.componentHistory[this.componentHistory.length - 1] !==
|
||||
event.snapshot.data.componentName &&
|
||||
!EXCLUDE_COMPONENTS.includes(event.snapshot.data.componentName)
|
||||
event.snapshot.component.name &&
|
||||
!EXCLUDE_COMPONENTS.includes(event.snapshot.component.name)
|
||||
) {
|
||||
this.history.push(event.snapshot.url.toString())
|
||||
this.componentHistory.push(event.snapshot.data.componentName)
|
||||
this.componentHistory.push(event.snapshot.component.name)
|
||||
} else {
|
||||
// Update the URL of the current component in case the same component was loaded via a different URL
|
||||
this.history[this.history.length - 1] = event.snapshot.url.toString()
|
||||
|
@ -602,6 +602,7 @@ export class SettingsService {
|
||||
)
|
||||
} catch (error) {
|
||||
this.toastService.showError(errorMessage)
|
||||
console.log(error)
|
||||
}
|
||||
|
||||
this.storeSettings()
|
||||
@ -613,6 +614,7 @@ export class SettingsService {
|
||||
},
|
||||
error: (e) => {
|
||||
this.toastService.showError(errorMessage)
|
||||
console.log(e)
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -634,6 +636,7 @@ export class SettingsService {
|
||||
this.toastService.showError(
|
||||
'Error migrating update checking setting'
|
||||
)
|
||||
console.log(e)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
@ -8,7 +8,6 @@ import {
|
||||
FILTER_HAS_CUSTOM_FIELDS_ALL,
|
||||
FILTER_HAS_CUSTOM_FIELDS_ANY,
|
||||
FILTER_HAS_TAGS_ALL,
|
||||
NEGATIVE_NULL_FILTER_VALUE,
|
||||
} from '../data/filter-rule-type'
|
||||
import {
|
||||
filterRulesFromQueryParams,
|
||||
@ -98,16 +97,6 @@ describe('QueryParams Utils', () => {
|
||||
correspondent__isnull: 1,
|
||||
})
|
||||
|
||||
params = queryParamsFromFilterRules([
|
||||
{
|
||||
rule_type: FILTER_CORRESPONDENT,
|
||||
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
|
||||
},
|
||||
])
|
||||
expect(params).toEqual({
|
||||
correspondent__isnull: 0,
|
||||
})
|
||||
|
||||
params = queryParamsFromFilterRules([
|
||||
{
|
||||
rule_type: FILTER_HAS_ANY_TAG,
|
||||
|
@ -10,7 +10,6 @@ import {
|
||||
FILTER_HAS_CUSTOM_FIELDS_ANY,
|
||||
FILTER_RULE_TYPES,
|
||||
FilterRuleType,
|
||||
NEGATIVE_NULL_FILTER_VALUE,
|
||||
} from '../data/filter-rule-type'
|
||||
import { ListViewState } from '../services/document-list-view.service'
|
||||
|
||||
@ -114,10 +113,6 @@ export function filterRulesFromQueryParams(
|
||||
rt.isnull_filtervar == filterQueryParamName
|
||||
)
|
||||
const isNullRuleType = rule_type.isnull_filtervar == filterQueryParamName
|
||||
const nullRuleValue =
|
||||
queryParams.get(filterQueryParamName) == '1'
|
||||
? null
|
||||
: NEGATIVE_NULL_FILTER_VALUE.toString()
|
||||
const valueURIComponent: string = queryParams.get(filterQueryParamName)
|
||||
const filterQueryParamValues: string[] = rule_type.multi
|
||||
? valueURIComponent.split(',')
|
||||
@ -130,7 +125,7 @@ export function filterRulesFromQueryParams(
|
||||
val = val.replace('1', 'true').replace('0', 'false')
|
||||
return {
|
||||
rule_type: rule_type.id,
|
||||
value: isNullRuleType ? nullRuleValue : val,
|
||||
value: isNullRuleType ? null : val,
|
||||
}
|
||||
})
|
||||
)
|
||||
@ -148,11 +143,6 @@ export function queryParamsFromFilterRules(filterRules: FilterRule[]): Params {
|
||||
let ruleType = FILTER_RULE_TYPES.find((t) => t.id == rule.rule_type)
|
||||
if (ruleType.isnull_filtervar && rule.value == null) {
|
||||
params[ruleType.isnull_filtervar] = 1
|
||||
} else if (
|
||||
ruleType.isnull_filtervar &&
|
||||
rule.value == NEGATIVE_NULL_FILTER_VALUE.toString()
|
||||
) {
|
||||
params[ruleType.isnull_filtervar] = 0
|
||||
} else if (ruleType.multi) {
|
||||
params[ruleType.filtervar] = params[ruleType.filtervar]
|
||||
? params[ruleType.filtervar] + ',' + rule.value
|
||||
|
@ -3,7 +3,7 @@ const base_url = new URL(document.baseURI)
|
||||
export const environment = {
|
||||
production: true,
|
||||
apiBaseUrl: document.baseURI + 'api/',
|
||||
apiVersion: '7',
|
||||
apiVersion: '8',
|
||||
appTitle: 'Paperless-ngx',
|
||||
version: '2.14.7',
|
||||
webSocketHost: window.location.host,
|
||||
|
@ -5,7 +5,7 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
apiBaseUrl: 'http://localhost:8000/api/',
|
||||
apiVersion: '7',
|
||||
apiVersion: '8',
|
||||
appTitle: 'Paperless-ngx',
|
||||
version: 'DEVELOPMENT',
|
||||
webSocketHost: 'localhost:8000',
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -24,7 +24,7 @@
|
||||
--pngx-bg-alt2: var(--bs-gray-200); // #e9ecef
|
||||
--pngx-bg-disabled: #f7f7f7;
|
||||
--pngx-focus-alpha: 0.3;
|
||||
--pngx-toast-max-width: 340px;
|
||||
--pngx-toast-max-width: 360px;
|
||||
--bs-info: var(--pngx-bg-alt2);
|
||||
--bs-info-rgb: 233, 236, 239;
|
||||
@media screen and (min-width: 1024px) {
|
||||
|
@ -294,9 +294,9 @@ class Command(BaseCommand):
|
||||
inotify = INotify()
|
||||
inotify_flags = flags.CLOSE_WRITE | flags.MOVED_TO | flags.MODIFY
|
||||
if recursive:
|
||||
inotify.add_watch_recursive(directory, inotify_flags)
|
||||
descriptor = inotify.add_watch_recursive(directory, inotify_flags)
|
||||
else:
|
||||
inotify.add_watch(directory, inotify_flags)
|
||||
descriptor = inotify.add_watch(directory, inotify_flags)
|
||||
|
||||
inotify_debounce_secs: Final[float] = settings.CONSUMER_INOTIFY_DELAY
|
||||
inotify_debounce_ms: Final[int] = inotify_debounce_secs * 1000
|
||||
@ -305,55 +305,55 @@ class Command(BaseCommand):
|
||||
|
||||
notified_files = {}
|
||||
|
||||
try:
|
||||
while not finished:
|
||||
try:
|
||||
for event in inotify.read(timeout=timeout_ms):
|
||||
path = inotify.get_path(event.wd) if recursive else directory
|
||||
filepath = os.path.join(path, event.name)
|
||||
if flags.MODIFY in flags.from_mask(event.mask):
|
||||
notified_files.pop(filepath, None)
|
||||
else:
|
||||
notified_files[filepath] = monotonic()
|
||||
|
||||
# Check the files against the timeout
|
||||
still_waiting = {}
|
||||
# last_event_time is time of the last inotify event for this file
|
||||
for filepath, last_event_time in notified_files.items():
|
||||
# Current time - last time over the configured timeout
|
||||
waited_long_enough = (
|
||||
monotonic() - last_event_time
|
||||
) > inotify_debounce_secs
|
||||
|
||||
# Also make sure the file exists still, some scanners might write a
|
||||
# temporary file first
|
||||
file_still_exists = os.path.exists(filepath) and os.path.isfile(
|
||||
filepath,
|
||||
)
|
||||
|
||||
if waited_long_enough and file_still_exists:
|
||||
_consume(filepath)
|
||||
elif file_still_exists:
|
||||
still_waiting[filepath] = last_event_time
|
||||
|
||||
# These files are still waiting to hit the timeout
|
||||
notified_files = still_waiting
|
||||
|
||||
# If files are waiting, need to exit read() to check them
|
||||
# Otherwise, go back to infinite sleep time, but only if not testing
|
||||
if len(notified_files) > 0:
|
||||
timeout_ms = inotify_debounce_ms
|
||||
elif is_testing:
|
||||
timeout_ms = self.testing_timeout_ms
|
||||
while not finished:
|
||||
try:
|
||||
for event in inotify.read(timeout=timeout_ms):
|
||||
path = inotify.get_path(event.wd) if recursive else directory
|
||||
filepath = os.path.join(path, event.name)
|
||||
if flags.MODIFY in flags.from_mask(event.mask):
|
||||
notified_files.pop(filepath, None)
|
||||
else:
|
||||
timeout_ms = None
|
||||
notified_files[filepath] = monotonic()
|
||||
|
||||
if self.stop_flag.is_set():
|
||||
logger.debug("Finishing because event is set")
|
||||
finished = True
|
||||
# Check the files against the timeout
|
||||
still_waiting = {}
|
||||
# last_event_time is time of the last inotify event for this file
|
||||
for filepath, last_event_time in notified_files.items():
|
||||
# Current time - last time over the configured timeout
|
||||
waited_long_enough = (
|
||||
monotonic() - last_event_time
|
||||
) > inotify_debounce_secs
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Received SIGINT, stopping inotify")
|
||||
# Also make sure the file exists still, some scanners might write a
|
||||
# temporary file first
|
||||
file_still_exists = os.path.exists(filepath) and os.path.isfile(
|
||||
filepath,
|
||||
)
|
||||
|
||||
if waited_long_enough and file_still_exists:
|
||||
_consume(filepath)
|
||||
elif file_still_exists:
|
||||
still_waiting[filepath] = last_event_time
|
||||
|
||||
# These files are still waiting to hit the timeout
|
||||
notified_files = still_waiting
|
||||
|
||||
# If files are waiting, need to exit read() to check them
|
||||
# Otherwise, go back to infinite sleep time, but only if not testing
|
||||
if len(notified_files) > 0:
|
||||
timeout_ms = inotify_debounce_ms
|
||||
elif is_testing:
|
||||
timeout_ms = self.testing_timeout_ms
|
||||
else:
|
||||
timeout_ms = None
|
||||
|
||||
if self.stop_flag.is_set():
|
||||
logger.debug("Finishing because event is set")
|
||||
finished = True
|
||||
finally:
|
||||
inotify.close()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Received SIGINT, stopping inotify")
|
||||
finished = True
|
||||
|
||||
inotify.rm_watch(descriptor)
|
||||
inotify.close()
|
||||
|
@ -70,59 +70,57 @@ def set_permissions_for_object(permissions: list[str], object, *, merge: bool =
|
||||
|
||||
for action in permissions:
|
||||
permission = f"{action}_{object.__class__.__name__.lower()}"
|
||||
if "users" in permissions[action]:
|
||||
# users
|
||||
users_to_add = User.objects.filter(id__in=permissions[action]["users"])
|
||||
users_to_remove = (
|
||||
get_users_with_perms(
|
||||
object,
|
||||
only_with_perms_in=[permission],
|
||||
with_group_users=False,
|
||||
)
|
||||
if not merge
|
||||
else User.objects.none()
|
||||
# users
|
||||
users_to_add = User.objects.filter(id__in=permissions[action]["users"])
|
||||
users_to_remove = (
|
||||
get_users_with_perms(
|
||||
object,
|
||||
only_with_perms_in=[permission],
|
||||
with_group_users=False,
|
||||
)
|
||||
if len(users_to_add) > 0 and len(users_to_remove) > 0:
|
||||
users_to_remove = users_to_remove.exclude(id__in=users_to_add)
|
||||
if len(users_to_remove) > 0:
|
||||
for user in users_to_remove:
|
||||
remove_perm(permission, user, object)
|
||||
if len(users_to_add) > 0:
|
||||
for user in users_to_add:
|
||||
assign_perm(permission, user, object)
|
||||
if action == "change":
|
||||
# change gives view too
|
||||
assign_perm(
|
||||
f"view_{object.__class__.__name__.lower()}",
|
||||
user,
|
||||
object,
|
||||
)
|
||||
if "groups" in permissions[action]:
|
||||
# groups
|
||||
groups_to_add = Group.objects.filter(id__in=permissions[action]["groups"])
|
||||
groups_to_remove = (
|
||||
get_groups_with_only_permission(
|
||||
object,
|
||||
permission,
|
||||
)
|
||||
if not merge
|
||||
else Group.objects.none()
|
||||
if not merge
|
||||
else User.objects.none()
|
||||
)
|
||||
if len(users_to_add) > 0 and len(users_to_remove) > 0:
|
||||
users_to_remove = users_to_remove.exclude(id__in=users_to_add)
|
||||
if len(users_to_remove) > 0:
|
||||
for user in users_to_remove:
|
||||
remove_perm(permission, user, object)
|
||||
if len(users_to_add) > 0:
|
||||
for user in users_to_add:
|
||||
assign_perm(permission, user, object)
|
||||
if action == "change":
|
||||
# change gives view too
|
||||
assign_perm(
|
||||
f"view_{object.__class__.__name__.lower()}",
|
||||
user,
|
||||
object,
|
||||
)
|
||||
# groups
|
||||
groups_to_add = Group.objects.filter(id__in=permissions[action]["groups"])
|
||||
groups_to_remove = (
|
||||
get_groups_with_only_permission(
|
||||
object,
|
||||
permission,
|
||||
)
|
||||
if len(groups_to_add) > 0 and len(groups_to_remove) > 0:
|
||||
groups_to_remove = groups_to_remove.exclude(id__in=groups_to_add)
|
||||
if len(groups_to_remove) > 0:
|
||||
for group in groups_to_remove:
|
||||
remove_perm(permission, group, object)
|
||||
if len(groups_to_add) > 0:
|
||||
for group in groups_to_add:
|
||||
assign_perm(permission, group, object)
|
||||
if action == "change":
|
||||
# change gives view too
|
||||
assign_perm(
|
||||
f"view_{object.__class__.__name__.lower()}",
|
||||
group,
|
||||
object,
|
||||
)
|
||||
if not merge
|
||||
else Group.objects.none()
|
||||
)
|
||||
if len(groups_to_add) > 0 and len(groups_to_remove) > 0:
|
||||
groups_to_remove = groups_to_remove.exclude(id__in=groups_to_add)
|
||||
if len(groups_to_remove) > 0:
|
||||
for group in groups_to_remove:
|
||||
remove_perm(permission, group, object)
|
||||
if len(groups_to_add) > 0:
|
||||
for group in groups_to_add:
|
||||
assign_perm(permission, group, object)
|
||||
if action == "change":
|
||||
# change gives view too
|
||||
assign_perm(
|
||||
f"view_{object.__class__.__name__.lower()}",
|
||||
group,
|
||||
object,
|
||||
)
|
||||
|
||||
|
||||
def get_objects_for_user_owner_aware(user, perms, Model) -> QuerySet:
|
||||
|
@ -43,7 +43,6 @@ from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import MatchingModel
|
||||
from documents.models import Note
|
||||
from documents.models import PaperlessTask
|
||||
from documents.models import SavedView
|
||||
from documents.models import SavedViewFilterRule
|
||||
@ -160,24 +159,24 @@ class SetPermissionsMixin:
|
||||
|
||||
def validate_set_permissions(self, set_permissions=None):
|
||||
permissions_dict = {
|
||||
"view": {},
|
||||
"change": {},
|
||||
"view": {
|
||||
"users": User.objects.none(),
|
||||
"groups": Group.objects.none(),
|
||||
},
|
||||
"change": {
|
||||
"users": User.objects.none(),
|
||||
"groups": Group.objects.none(),
|
||||
},
|
||||
}
|
||||
if set_permissions is not None:
|
||||
for action in ["view", "change"]:
|
||||
for action, _ in permissions_dict.items():
|
||||
if action in set_permissions:
|
||||
if "users" in set_permissions[action]:
|
||||
users = set_permissions[action]["users"]
|
||||
permissions_dict[action]["users"] = self._validate_user_ids(
|
||||
users,
|
||||
)
|
||||
if "groups" in set_permissions[action]:
|
||||
groups = set_permissions[action]["groups"]
|
||||
permissions_dict[action]["groups"] = self._validate_group_ids(
|
||||
groups,
|
||||
)
|
||||
else:
|
||||
del permissions_dict[action]
|
||||
users = set_permissions[action]["users"]
|
||||
permissions_dict[action]["users"] = self._validate_user_ids(users)
|
||||
groups = set_permissions[action]["groups"]
|
||||
permissions_dict[action]["groups"] = self._validate_group_ids(
|
||||
groups,
|
||||
)
|
||||
return permissions_dict
|
||||
|
||||
def _set_permissions(self, permissions, object):
|
||||
@ -422,6 +421,15 @@ class OwnedObjectListSerializer(serializers.ListSerializer):
|
||||
return super().to_representation(documents)
|
||||
|
||||
|
||||
class GetAPIVersionMixin:
|
||||
def get_api_version(self):
|
||||
return int(
|
||||
self.context.get("request").version
|
||||
if self.context.get("request")
|
||||
else settings.REST_FRAMEWORK["DEFAULT_VERSION"],
|
||||
)
|
||||
|
||||
|
||||
class CorrespondentSerializer(MatchingModelSerializer, OwnedObjectSerializer):
|
||||
last_correspondence = serializers.DateTimeField(read_only=True, required=False)
|
||||
|
||||
@ -718,7 +726,7 @@ class ReadWriteSerializerMethodField(serializers.SerializerMethodField):
|
||||
return {self.field_name: data}
|
||||
|
||||
|
||||
class CustomFieldInstanceSerializer(serializers.ModelSerializer):
|
||||
class CustomFieldInstanceSerializer(serializers.ModelSerializer, GetAPIVersionMixin):
|
||||
field = serializers.PrimaryKeyRelatedField(queryset=CustomField.objects.all())
|
||||
value = ReadWriteSerializerMethodField(allow_null=True)
|
||||
|
||||
@ -810,13 +818,6 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer):
|
||||
|
||||
return data
|
||||
|
||||
def get_api_version(self):
|
||||
return int(
|
||||
self.context.get("request").version
|
||||
if self.context.get("request")
|
||||
else settings.REST_FRAMEWORK["DEFAULT_VERSION"],
|
||||
)
|
||||
|
||||
def to_internal_value(self, data):
|
||||
ret = super().to_internal_value(data)
|
||||
|
||||
@ -862,22 +863,6 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class BasicUserSerializer(serializers.ModelSerializer):
|
||||
# Different than paperless.serializers.UserSerializer
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["id", "username", "first_name", "last_name"]
|
||||
|
||||
|
||||
class NotesSerializer(serializers.ModelSerializer):
|
||||
user = BasicUserSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Note
|
||||
fields = ["id", "note", "created", "user"]
|
||||
ordering = ["-created"]
|
||||
|
||||
|
||||
class DocumentSerializer(
|
||||
OwnedObjectSerializer,
|
||||
NestedUpdateMixin,
|
||||
@ -893,8 +878,6 @@ class DocumentSerializer(
|
||||
created_date = serializers.DateField(required=False)
|
||||
page_count = SerializerMethodField()
|
||||
|
||||
notes = NotesSerializer(many=True, required=False, read_only=True)
|
||||
|
||||
custom_fields = CustomFieldInstanceSerializer(
|
||||
many=True,
|
||||
allow_null=False,
|
||||
@ -1728,7 +1711,7 @@ class UiSettingsViewSerializer(serializers.ModelSerializer):
|
||||
return ui_settings
|
||||
|
||||
|
||||
class TasksViewSerializer(OwnedObjectSerializer):
|
||||
class TasksViewSerializer(OwnedObjectSerializer, GetAPIVersionMixin):
|
||||
class Meta:
|
||||
model = PaperlessTask
|
||||
fields = (
|
||||
@ -1771,6 +1754,13 @@ class TasksViewSerializer(OwnedObjectSerializer):
|
||||
|
||||
return result
|
||||
|
||||
def to_representation(self, instance: PaperlessTask):
|
||||
result = super().to_representation(instance)
|
||||
if self.get_api_version() < 8:
|
||||
# Older versions only returned file tasks (filtering handled in view) and had different naming scheme
|
||||
result["type"] = "file"
|
||||
return result
|
||||
|
||||
|
||||
class RunTaskViewSerializer(serializers.Serializer):
|
||||
task_name = serializers.ChoiceField(
|
||||
|
@ -1162,7 +1162,7 @@ def run_workflows(
|
||||
) as f:
|
||||
files = {
|
||||
"file": (
|
||||
filename,
|
||||
document.original_filename,
|
||||
f.read(),
|
||||
document.mime_type,
|
||||
),
|
||||
|
@ -6,7 +6,7 @@
|
||||
{% endblock head_title %}
|
||||
|
||||
{% block form_top_content %}
|
||||
<h4>{% translate "Account inactive." %}</h4>
|
||||
<h4>{% translate "Account inactve." %}</h4>
|
||||
{% endblock form_top_content %}
|
||||
|
||||
{% block form_content %}
|
||||
|
@ -2170,10 +2170,8 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
GIVEN:
|
||||
- A document with a single note
|
||||
WHEN:
|
||||
- API request for document
|
||||
- API request for document notes is made
|
||||
THEN:
|
||||
- Note is included in the document response
|
||||
- The associated note is returned
|
||||
"""
|
||||
doc = Document.objects.create(
|
||||
@ -2187,18 +2185,6 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
user=self.user,
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
f"/api/documents/{doc.pk}/",
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
resp_data = response.json()
|
||||
self.assertEqual(len(resp_data["notes"]), 1)
|
||||
self.assertEqual(resp_data["notes"][0]["note"], note.note)
|
||||
self.assertEqual(resp_data["notes"][0]["user"]["username"], self.user.username)
|
||||
|
||||
response = self.client.get(
|
||||
f"/api/documents/{doc.pk}/notes/",
|
||||
format="json",
|
||||
|
@ -395,52 +395,6 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
||||
self.assertTrue(checker.has_perm("view_document", doc))
|
||||
self.assertIn("view_document", get_perms(group1, doc))
|
||||
|
||||
def test_patch_doesnt_remove_permissions(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- existing document with permissions set
|
||||
WHEN:
|
||||
- PATCH API request to update doc that is not json
|
||||
THEN:
|
||||
- Object permissions are not removed
|
||||
"""
|
||||
doc = Document.objects.create(
|
||||
title="test",
|
||||
mime_type="application/pdf",
|
||||
content="this is a document",
|
||||
)
|
||||
user1 = User.objects.create_superuser(username="user1")
|
||||
user2 = User.objects.create(username="user2")
|
||||
group1 = Group.objects.create(name="group1")
|
||||
doc.owner = user1
|
||||
doc.save()
|
||||
|
||||
assign_perm("view_document", user2, doc)
|
||||
assign_perm("change_document", user2, doc)
|
||||
assign_perm("view_document", group1, doc)
|
||||
assign_perm("change_document", group1, doc)
|
||||
|
||||
self.client.force_authenticate(user1)
|
||||
|
||||
response = self.client.patch(
|
||||
f"/api/documents/{doc.id}/",
|
||||
{
|
||||
"archive_serial_number": "123",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
doc = Document.objects.get(pk=doc.id)
|
||||
|
||||
self.assertEqual(doc.owner, user1)
|
||||
from guardian.core import ObjectPermissionChecker
|
||||
|
||||
checker = ObjectPermissionChecker(user2)
|
||||
self.assertTrue(checker.has_perm("view_document", doc))
|
||||
self.assertIn("view_document", get_perms(group1, doc))
|
||||
self.assertTrue(checker.has_perm("change_document", doc))
|
||||
self.assertIn("change_document", get_perms(group1, doc))
|
||||
|
||||
def test_dynamic_permissions_fields(self):
|
||||
user1 = User.objects.create_user(username="user1")
|
||||
user1.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
||||
|
@ -370,3 +370,45 @@ class TestTasks(DirectoriesMixin, APITestCase):
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
mock_check_sanity.assert_not_called()
|
||||
|
||||
def test_handle_older_api_version(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- A request from the API with version < 8
|
||||
WHEN:
|
||||
- Tasks are requested
|
||||
THEN:
|
||||
- Only consume file tasks are returned and the type is 'file'
|
||||
"""
|
||||
|
||||
task1 = PaperlessTask.objects.create(
|
||||
task_id=str(uuid.uuid4()),
|
||||
task_file_name="task_one.pdf",
|
||||
task_name=PaperlessTask.TaskName.CONSUME_FILE,
|
||||
)
|
||||
|
||||
task2 = PaperlessTask.objects.create(
|
||||
task_id=str(uuid.uuid4()),
|
||||
task_name=PaperlessTask.TaskName.CHECK_SANITY,
|
||||
type=PaperlessTask.TaskType.AUTO,
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
self.ENDPOINT,
|
||||
headers={"Accept": "application/json; version=7"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
self.assertEqual(response.data[0]["task_id"], task1.task_id)
|
||||
self.assertEqual(response.data[0]["type"], "file")
|
||||
|
||||
response = self.client.get(
|
||||
self.ENDPOINT,
|
||||
headers={"Accept": "application/json; version=8"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 2)
|
||||
self.assertEqual(response.data[0]["task_id"], task2.task_id)
|
||||
self.assertEqual(response.data[0]["type"], PaperlessTask.TaskType.AUTO)
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user