mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-08-01 18:37:42 -05:00
Compare commits
84 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
9f045f4494 | ||
![]() |
4fdb28c8d6 | ||
![]() |
f1049cf889 | ||
![]() |
8d664fad56 | ||
![]() |
f6ddcfa839 | ||
![]() |
ce59f2ad5e | ||
![]() |
0de00a4ac1 | ||
![]() |
bec72dffeb | ||
![]() |
463e95367c | ||
![]() |
1739de2694 | ||
![]() |
fd8db27a88 | ||
![]() |
9ddf14bebe | ||
![]() |
1cf8ea3aba | ||
![]() |
134993fce6 | ||
![]() |
ff1955e014 | ||
![]() |
d83bbdc50b | ||
![]() |
907b6d1294 | ||
![]() |
4e7bb1c8da | ||
![]() |
09ab694d05 | ||
![]() |
03ced65d5f | ||
![]() |
01d919cf31 | ||
![]() |
8d5f331e63 | ||
![]() |
ed556ead6f | ||
![]() |
e10a904f33 | ||
![]() |
f2a05b61da | ||
![]() |
21f96f0679 | ||
![]() |
2ffabd54e5 | ||
![]() |
1197437750 | ||
![]() |
d1339374d0 | ||
![]() |
5ba4b9d6b2 | ||
![]() |
b386ea9426 | ||
![]() |
197174f400 | ||
![]() |
97dceba783 | ||
![]() |
2ed4400827 | ||
![]() |
58cbcbd6ef | ||
![]() |
4aeb2e1a74 | ||
![]() |
45c5f81b34 | ||
![]() |
13201dbfff | ||
![]() |
0b1523f4e5 | ||
![]() |
cd3b1a221e | ||
![]() |
4855f4b8b1 | ||
![]() |
6587470033 | ||
![]() |
6487dab132 | ||
![]() |
b643a68fa3 | ||
![]() |
b60e16fe33 | ||
![]() |
c508be6ecd | ||
![]() |
3b2d4fe876 | ||
![]() |
b47f301831 | ||
![]() |
a79b9de1a2 | ||
![]() |
e98da2e72c | ||
![]() |
718171a125 | ||
![]() |
aaa130e20d | ||
![]() |
4606caeaa8 | ||
![]() |
4813a7bc70 | ||
![]() |
fb82aa0ee1 | ||
![]() |
c7e0c32226 | ||
![]() |
607adf44f3 | ||
![]() |
625780899d | ||
![]() |
25542c56b9 | ||
![]() |
45e2b7f814 | ||
![]() |
6b34f592df | ||
![]() |
6cf732e6ec | ||
![]() |
dfd959839f | ||
![]() |
d165e89ac3 | ||
![]() |
421a87c94b | ||
![]() |
b55529b913 | ||
![]() |
c62d892969 | ||
![]() |
9e6aa55230 | ||
![]() |
6090305b77 | ||
![]() |
d1b516a089 | ||
![]() |
89aff63e52 | ||
![]() |
11e9c4d8cc | ||
![]() |
9d84e95771 | ||
![]() |
2aced1c305 | ||
![]() |
454098630b | ||
![]() |
8f3ab2791b | ||
![]() |
61209b1057 | ||
![]() |
38a817e887 | ||
![]() |
5e3d1b26e7 | ||
![]() |
4996b7e5f7 | ||
![]() |
2c8fddb554 | ||
![]() |
ae05011062 | ||
![]() |
50a6b7e154 | ||
![]() |
d0ce4113e0 |
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
@@ -16,7 +16,7 @@ on:
|
||||
env:
|
||||
# This is the version of pipenv all the steps will use
|
||||
# If changing this, change Dockerfile
|
||||
DEFAULT_PIP_ENV_VERSION: "2023.11.15"
|
||||
DEFAULT_PIP_ENV_VERSION: "2023.12.1"
|
||||
# This is the default version of Python to use in most steps which aren't specific
|
||||
DEFAULT_PYTHON_VERSION: "3.10"
|
||||
|
||||
@@ -184,7 +184,7 @@ jobs:
|
||||
cache-dependency-path: 'src-ui/package-lock.json'
|
||||
- name: Cache frontend dependencies
|
||||
id: cache-frontend-deps
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.npm
|
||||
@@ -221,7 +221,7 @@ jobs:
|
||||
cache-dependency-path: 'src-ui/package-lock.json'
|
||||
- name: Cache frontend dependencies
|
||||
id: cache-frontend-deps
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.npm
|
||||
@@ -283,7 +283,7 @@ jobs:
|
||||
merge-multiple: true
|
||||
-
|
||||
name: Upload frontend coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
# not required for public repos, but intermittently fails otherwise
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
@@ -299,7 +299,7 @@ jobs:
|
||||
path: src/
|
||||
-
|
||||
name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
# not required for public repos, but intermittently fails otherwise
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
@@ -47,11 +47,11 @@ repos:
|
||||
exclude: "(^Pipfile\\.lock$)"
|
||||
# Python hooks
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: 'v0.1.11'
|
||||
rev: 'v0.2.1'
|
||||
hooks:
|
||||
- id: ruff
|
||||
- repo: https://github.com/psf/black-pre-commit-mirror
|
||||
rev: 23.12.1
|
||||
rev: 24.1.1
|
||||
hooks:
|
||||
- id: black
|
||||
# Dockerfile hooks
|
||||
|
42
.ruff.toml
42
.ruff.toml
@@ -1,8 +1,3 @@
|
||||
# https://beta.ruff.rs/docs/settings/
|
||||
# https://beta.ruff.rs/docs/rules/
|
||||
extend-select = ["I", "W", "UP", "COM", "DJ", "EXE", "ISC", "ICN", "G201", "INP", "PIE", "RSE", "SIM", "TID", "PLC", "PLE", "RUF"]
|
||||
# TODO PTH
|
||||
ignore = ["DJ001", "SIM105", "RUF012"]
|
||||
fix = true
|
||||
line-length = 88
|
||||
respect-gitignore = true
|
||||
@@ -11,13 +6,42 @@ target-version = "py39"
|
||||
output-format = "grouped"
|
||||
show-fixes = true
|
||||
|
||||
[per-file-ignores]
|
||||
# https://docs.astral.sh/ruff/settings/
|
||||
# https://docs.astral.sh/ruff/rules/
|
||||
[lint]
|
||||
extend-select = [
|
||||
"W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
|
||||
"I", # https://docs.astral.sh/ruff/rules/#isort-i
|
||||
"UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
|
||||
"COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com
|
||||
"DJ", # https://docs.astral.sh/ruff/rules/#flake8-django-dj
|
||||
"EXE", # https://docs.astral.sh/ruff/rules/#flake8-executable-exe
|
||||
"ISC", # https://docs.astral.sh/ruff/rules/#flake8-implicit-str-concat-isc
|
||||
"ICN", # https://docs.astral.sh/ruff/rules/#flake8-import-conventions-icn
|
||||
"G201", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g
|
||||
"INP", # https://docs.astral.sh/ruff/rules/#flake8-no-pep420-inp
|
||||
"PIE", # https://docs.astral.sh/ruff/rules/#flake8-pie-pie
|
||||
"Q", # https://docs.astral.sh/ruff/rules/#flake8-quotes-q
|
||||
"RSE", # https://docs.astral.sh/ruff/rules/#flake8-raise-rse
|
||||
"T20", # https://docs.astral.sh/ruff/rules/#flake8-print-t20
|
||||
"SIM", # https://docs.astral.sh/ruff/rules/#flake8-simplify-sim
|
||||
"TID", # https://docs.astral.sh/ruff/rules/#flake8-tidy-imports-tid
|
||||
"TCH", # https://docs.astral.sh/ruff/rules/#flake8-type-checking-tch
|
||||
"PLC", # https://docs.astral.sh/ruff/rules/#pylint-pl
|
||||
"PLE", # https://docs.astral.sh/ruff/rules/#pylint-pl
|
||||
"RUF", # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf
|
||||
"FLY", # https://docs.astral.sh/ruff/rules/#flynt-fly
|
||||
]
|
||||
# TODO PTH https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth
|
||||
ignore = ["DJ001", "SIM105", "RUF012"]
|
||||
|
||||
[lint.per-file-ignores]
|
||||
".github/scripts/*.py" = ["E501", "INP001", "SIM117"]
|
||||
"docker/wait-for-redis.py" = ["INP001"]
|
||||
"docker/wait-for-redis.py" = ["INP001", "T201"]
|
||||
"*/tests/*.py" = ["E501", "SIM117"]
|
||||
"*/migrations/*.py" = ["E501", "SIM"]
|
||||
"*/migrations/*.py" = ["E501", "SIM", "T201"]
|
||||
"src/paperless_tesseract/tests/test_parser.py" = ["RUF001"]
|
||||
"src/documents/models.py" = ["SIM115"]
|
||||
|
||||
[isort]
|
||||
[lint.isort]
|
||||
force-single-line = true
|
||||
|
10
Dockerfile
10
Dockerfile
@@ -29,7 +29,7 @@ COPY Pipfile* ./
|
||||
|
||||
RUN set -eux \
|
||||
&& echo "Installing pipenv" \
|
||||
&& python3 -m pip install --no-cache-dir --upgrade pipenv==2023.11.15 \
|
||||
&& python3 -m pip install --no-cache-dir --upgrade pipenv==2023.12.1 \
|
||||
&& echo "Generating requirement.txt" \
|
||||
&& pipenv requirements > requirements.txt
|
||||
|
||||
@@ -39,8 +39,6 @@ RUN set -eux \
|
||||
# - Don't leave anything extra in here
|
||||
FROM docker.io/python:3.11-slim-bookworm as main-app
|
||||
|
||||
ENV PYTHONWARNINGS="ignore:::django.http.response:517"
|
||||
|
||||
LABEL org.opencontainers.image.authors="paperless-ngx team <hello@paperless-ngx.com>"
|
||||
LABEL org.opencontainers.image.documentation="https://docs.paperless-ngx.com/"
|
||||
LABEL org.opencontainers.image.source="https://github.com/paperless-ngx/paperless-ngx"
|
||||
@@ -57,6 +55,12 @@ ARG JBIG2ENC_VERSION=0.29
|
||||
ARG QPDF_VERSION=11.6.4
|
||||
ARG GS_VERSION=10.02.1
|
||||
|
||||
# Set Python environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
# Ignore warning from Whitenoise
|
||||
PYTHONWARNINGS="ignore:::django.http.response:517"
|
||||
|
||||
#
|
||||
# Begin installation and configuration
|
||||
# Order the steps below from least often changed to most
|
||||
|
9
Pipfile
9
Pipfile
@@ -7,7 +7,8 @@ name = "pypi"
|
||||
dateparser = "~=1.2"
|
||||
# WARNING: django does not use semver.
|
||||
# Only patch versions are guaranteed to not introduce breaking changes.
|
||||
django = "~=4.2.9"
|
||||
django = "~=4.2.10"
|
||||
django-allauth = "*"
|
||||
django-auditlog = "*"
|
||||
django-celery-results = "*"
|
||||
django-compression-middleware = "*"
|
||||
@@ -45,12 +46,12 @@ python-magic = "*"
|
||||
pyzbar = "*"
|
||||
rapidfuzz = "*"
|
||||
redis = {extras = ["hiredis"], version = "*"}
|
||||
scikit-learn = "~=1.3"
|
||||
scikit-learn = "~=1.4"
|
||||
setproctitle = "*"
|
||||
tika-client = "*"
|
||||
tqdm = "*"
|
||||
uvicorn = {extras = ["standard"], version = "*"}
|
||||
watchdog = "~=3.0"
|
||||
uvicorn = {extras = ["standard"], version = "==0.25.0"}
|
||||
watchdog = "~=4.0"
|
||||
whitenoise = "~=6.6"
|
||||
whoosh="~=2.7"
|
||||
zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"}
|
||||
|
1253
Pipfile.lock
generated
1253
Pipfile.lock
generated
File diff suppressed because it is too large
Load Diff
13
README.md
13
README.md
@@ -3,6 +3,7 @@
|
||||
[](https://docs.paperless-ngx.com)
|
||||
[](https://codecov.io/gh/paperless-ngx/paperless-ngx)
|
||||
[](https://matrix.to/#/%23paperlessngx%3Amatrix.org)
|
||||
[](https://demo.paperless-ngx.com)
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
@@ -20,6 +21,8 @@ Paperless-ngx is a document management system that transforms your physical docu
|
||||
|
||||
Paperless-ngx is the official successor to the original [Paperless](https://github.com/the-paperless-project/paperless) & [Paperless-ng](https://github.com/jonaswinkler/paperless-ng) projects and is designed to distribute the responsibility of advancing and supporting the project among a team of people. [Consider joining us!](#community-support)
|
||||
|
||||
Thanks to the generous folks at [DigitalOcean](https://m.do.co/c/8d70b916d462), a demo is available at [demo.paperless-ngx.com](https://demo.paperless-ngx.com) using login `demo` / `demo`. _Note: demo content is reset frequently and confidential information should not be uploaded._
|
||||
|
||||
- [Features](#features)
|
||||
- [Getting started](#getting-started)
|
||||
- [Contributing](#contributing)
|
||||
@@ -30,6 +33,16 @@ Paperless-ngx is the official successor to the original [Paperless](https://gith
|
||||
- [Affiliated Projects](#affiliated-projects)
|
||||
- [Important Note](#important-note)
|
||||
|
||||
<p align="right">This project is supported by:<br/>
|
||||
<a href="https://m.do.co/c/8d70b916d462" style="padding-top: 4px; display: block;">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_white.svg" width="140px">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_black_.svg" width="140px">
|
||||
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_black_.svg" width="140px">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# Features
|
||||
|
||||
<picture>
|
||||
|
@@ -1,14 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
SUPERVISORD_WORKING_DIR="${PAPERLESS_SUPERVISORD_WORKING_DIR:-$PWD}"
|
||||
rootless_args=()
|
||||
if [ "$(id -u)" == "$(id -u paperless)" ]; then
|
||||
rootless_args=(
|
||||
--user
|
||||
paperless
|
||||
--logfile
|
||||
supervisord.log
|
||||
"${SUPERVISORD_WORKING_DIR}/supervisord.log"
|
||||
--pidfile
|
||||
supervisord.pid
|
||||
"${SUPERVISORD_WORKING_DIR}/supervisord.pid"
|
||||
)
|
||||
fi
|
||||
|
||||
|
@@ -517,6 +517,18 @@ existing tables) with:
|
||||
an older system may fix issues that can arise while setting up Paperless-ngx but
|
||||
`utf8mb3` can cause issues with consumption (where `utf8mb4` does not).
|
||||
|
||||
### Missing timezones
|
||||
|
||||
MySQL as well as MariaDB do not have any timezone information by default (though some
|
||||
docker images such as the official MariaDB image take care of this for you) which will
|
||||
cause unexpected behavior with date-based queries.
|
||||
|
||||
To fix this, execute one of the following commands:
|
||||
|
||||
MySQL: `mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root mysql -p`
|
||||
|
||||
MariaDB: `mariadb-tzinfo-to-sql /usr/share/zoneinfo | mariadb -u root mysql -p`
|
||||
|
||||
## Barcodes {#barcodes}
|
||||
|
||||
Paperless is able to utilize barcodes for automatically performing some tasks.
|
||||
@@ -557,6 +569,14 @@ barcode is located. However, differing from the splitting, the page with the
|
||||
barcode _will_ be retained. This allows application of a barcode to any page, including
|
||||
one which holds data to keep in the document.
|
||||
|
||||
### Tag Assignment
|
||||
|
||||
When enabled, Paperless will parse barcodes and attempt to interpret and assign tags.
|
||||
|
||||
See the relevant settings [`PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE`](configuration.md#PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE)
|
||||
and [`PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING`](configuration.md#PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING)
|
||||
for more information.
|
||||
|
||||
## Automatic collation of double-sided documents {#collate}
|
||||
|
||||
!!! note
|
||||
@@ -628,3 +648,47 @@ single-sided split marker page, the split document(s) will have an empty page at
|
||||
whatever else was on the backside of the split marker page.) You can work around that by having
|
||||
a split marker page that has the split barcode on _both_ sides. This way, the extra page will
|
||||
get automatically removed.
|
||||
|
||||
## SSO and third party authentication with Paperless-ngx
|
||||
|
||||
Paperless-ngx has a built-in authentication system from Django but you can easily integrate an
|
||||
external authentication solution using one of the following methods:
|
||||
|
||||
### Remote User authentication
|
||||
|
||||
This is a simple option that uses remote user authentication made available by certain SSO
|
||||
applications. See the relevant configuration options for more information:
|
||||
[PAPERLESS_ENABLE_HTTP_REMOTE_USER](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER) and
|
||||
[PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME](configuration.md#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME)
|
||||
|
||||
### OpenID Connect and social authentication
|
||||
|
||||
Version 2.5.0 of Paperless-ngx added support for integrating other authentication systems via
|
||||
the [django-allauth](https://github.com/pennersr/django-allauth) package. Once set up, users
|
||||
can either log in or (optionally) sign up using any third party systems you integrate. See the
|
||||
relevant [configuration settings](configuration.md#PAPERLESS_SOCIALACCOUNT_PROVIDERS) and
|
||||
[django-allauth docs](https://docs.allauth.org/en/latest/socialaccount/configuration.html)
|
||||
for more information.
|
||||
|
||||
To associate an existing Paperless-ngx account with a social account, first login with your
|
||||
regular credentials and then choose "My Profile" from the user dropdown in the app and you
|
||||
will see options to connect social account(s). If enabled, signup options will be available
|
||||
on the login page.
|
||||
|
||||
As an example, to set up login via Github, the following environment variables would need to be
|
||||
set:
|
||||
|
||||
```conf
|
||||
PAPERLESS_APPS="allauth.socialaccount.providers.github"
|
||||
PAPERLESS_SOCIALACCOUNT_PROVIDERS='{"github": {"APPS": [{"provider_id": "github","name": "Github","client_id": "<CLIENT_ID>","secret": "<CLIENT_SECRET>"}]}}'
|
||||
```
|
||||
|
||||
Or, to use OpenID Connect ("OIDC"), via Keycloak in this example:
|
||||
|
||||
```conf
|
||||
PAPERLESS_APPS="allauth.socialaccount.providers.openid_connect"
|
||||
PAPERLESS_SOCIALACCOUNT_PROVIDERS='
|
||||
{"openid_connect": {"APPS": [{"provider_id": "keycloak","name": "Keycloak","client_id": "paperless","secret": "<CLIENT_SECRET>","settings": { "server_url": "https://<KEYCLOAK_SERVER>/realms/<REALM>/.well-known/openid-configuration"}}]}}'
|
||||
```
|
||||
|
||||
More details about configuration option for various providers can be found in the [allauth documentation](https://docs.allauth.org/en/latest/socialaccount/providers/index.html#provider-specifics).
|
||||
|
79
docs/api.md
79
docs/api.md
@@ -139,7 +139,7 @@ document. Paperless only reports PDF metadata at this point.
|
||||
|
||||
## Authorization
|
||||
|
||||
The REST api provides three different forms of authentication.
|
||||
The REST api provides four different forms of authentication.
|
||||
|
||||
1. Basic authentication
|
||||
|
||||
@@ -177,6 +177,12 @@ The REST api provides three different forms of authentication.
|
||||
|
||||
Tokens can also be managed in the Django admin.
|
||||
|
||||
4. Remote User authentication
|
||||
|
||||
If enabled (see
|
||||
[configuration](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER_API)),
|
||||
you can authenticate against the API using Remote User auth.
|
||||
|
||||
## Searching for documents
|
||||
|
||||
Full text searching is available on the `/api/documents/` endpoint. Two
|
||||
@@ -185,7 +191,7 @@ results:
|
||||
|
||||
- `/api/documents/?query=your%20search%20query`: Search for a document
|
||||
using a full text query. For details on the syntax, see [Basic Usage - Searching](usage.md#basic-usage_searching).
|
||||
- `/api/documents/?more_like=1234`: Search for documents similar to
|
||||
- `/api/documents/?more_like_id=1234`: Search for documents similar to
|
||||
the document with id 1234.
|
||||
|
||||
Pagination works exactly the same as it does for normal requests on this
|
||||
@@ -324,6 +330,65 @@ granted). You can pass the parameter `full_perms=true` to API calls to view the
|
||||
full permissions of objects in a format that mirrors the `set_permissions`
|
||||
parameter above.
|
||||
|
||||
## Bulk Editing
|
||||
|
||||
The API supports various bulk-editing operations which are executed asynchronously.
|
||||
|
||||
### Documents
|
||||
|
||||
For bulk operations on documents, use the endpoint `/api/bulk_edit/` which accepts
|
||||
a json payload of the format:
|
||||
|
||||
```json
|
||||
{
|
||||
"documents": [LIST_OF_DOCUMENT_IDS],
|
||||
"method": METHOD, // see below
|
||||
"parameters": args // see below
|
||||
}
|
||||
```
|
||||
|
||||
The following methods are supported:
|
||||
|
||||
- `set_correspondent`
|
||||
- Requires `parameters`: `{ "correspondent": CORRESPONDENT_ID }`
|
||||
- `set_document_type`
|
||||
- Requires `parameters`: `{ "document_type": DOCUMENT_TYPE_ID }`
|
||||
- `set_storage_path`
|
||||
- Requires `parameters`: `{ "storage_path": STORAGE_PATH_ID }`
|
||||
- `add_tag`
|
||||
- Requires `parameters`: `{ "tag": TAG_ID }`
|
||||
- `remove_tag`
|
||||
- Requires `parameters`: `{ "tag": TAG_ID }`
|
||||
- `modify_tags`
|
||||
- Requires `parameters`: `{ "add_tags": [LIST_OF_TAG_IDS] }` and / or `{ "remove_tags": [LIST_OF_TAG_IDS] }`
|
||||
- `delete`
|
||||
- No `parameters` required
|
||||
- `redo_ocr`
|
||||
- No `parameters` required
|
||||
- `set_permissions`
|
||||
- Requires `parameters`:
|
||||
- `"permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or
|
||||
- `"owner": OWNER_ID or null`
|
||||
- `"merge": true or false` (defaults to false)
|
||||
- The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including
|
||||
removing them) or be merged with existing permissions.
|
||||
|
||||
### Objects
|
||||
|
||||
Bulk editing for objects (tags, document types etc.) currently supports set permissions or delete
|
||||
operations, using the endpoint: `/api/bulk_edit_objects/`, which requires a json payload of the format:
|
||||
|
||||
```json
|
||||
{
|
||||
"objects": [LIST_OF_OBJECT_IDS],
|
||||
"object_type": "tags", "correspondents", "document_types" or "storage_paths",
|
||||
"operation": "set_permissions" or "delete",
|
||||
"owner": OWNER_ID, // optional
|
||||
"permissions": { "view": { "users": [] ... }, "change": { ... } }, // (see 'set_permissions' format above)
|
||||
"merge": true / false // defaults to false, see above
|
||||
}
|
||||
```
|
||||
|
||||
## API Versioning
|
||||
|
||||
The REST API is versioned since Paperless-ngx 1.3.0.
|
||||
@@ -380,3 +445,13 @@ Initial API version.
|
||||
color to use for a specific tag, which is either black or white
|
||||
depending on the brightness of `Tag.color`.
|
||||
- Removed field `Tag.colour`.
|
||||
|
||||
#### Version 3
|
||||
|
||||
- Permissions endpoints have been added.
|
||||
- The format of the `/api/ui_settings/` has changed.
|
||||
|
||||
#### Version 4
|
||||
|
||||
- Consumption templates were refactored to workflows and API endpoints
|
||||
changed as such.
|
||||
|
@@ -1,5 +1,155 @@
|
||||
# Changelog
|
||||
|
||||
## paperless-ngx 2.5.2
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: Generated secret key may include single or double quotes [@schmidtnz](https://github.com/schmidtnz) ([#5767](https://github.com/paperless-ngx/paperless-ngx/pull/5767))
|
||||
- Fix: consumer status alerts container blocks elements [@shamoon](https://github.com/shamoon) ([#5762](https://github.com/paperless-ngx/paperless-ngx/pull/5762))
|
||||
- Fix: handle document notes user format api change [@shamoon](https://github.com/shamoon) ([#5751](https://github.com/paperless-ngx/paperless-ngx/pull/5751))
|
||||
- Fix: Assign ASN from barcode only after any splitting [@stumpylog](https://github.com/stumpylog) ([#5745](https://github.com/paperless-ngx/paperless-ngx/pull/5745))
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Chore(deps): Bump the major-versions group with 1 update [@dependabot](https://github.com/dependabot) ([#5741](https://github.com/paperless-ngx/paperless-ngx/pull/5741))
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>4 changes</summary>
|
||||
|
||||
- Fix: consumer status alerts container blocks elements [@shamoon](https://github.com/shamoon) ([#5762](https://github.com/paperless-ngx/paperless-ngx/pull/5762))
|
||||
- Fix: handle document notes user format api change [@shamoon](https://github.com/shamoon) ([#5751](https://github.com/paperless-ngx/paperless-ngx/pull/5751))
|
||||
- Fix: Assign ASN from barcode only after any splitting [@stumpylog](https://github.com/stumpylog) ([#5745](https://github.com/paperless-ngx/paperless-ngx/pull/5745))
|
||||
- Chore(deps): Bump the major-versions group with 1 update [@dependabot](https://github.com/dependabot) ([#5741](https://github.com/paperless-ngx/paperless-ngx/pull/5741))
|
||||
</details>
|
||||
|
||||
## paperless-ngx 2.5.1
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: Splitting on ASN barcodes even if not enabled [@stumpylog](https://github.com/stumpylog) ([#5740](https://github.com/paperless-ngx/paperless-ngx/pull/5740))
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Chore(deps-dev): Bump the development group with 2 updates [@dependabot](https://github.com/dependabot) ([#5737](https://github.com/paperless-ngx/paperless-ngx/pull/5737))
|
||||
- Chore(deps): Bump the django group with 1 update [@dependabot](https://github.com/dependabot) ([#5739](https://github.com/paperless-ngx/paperless-ngx/pull/5739))
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>3 changes</summary>
|
||||
|
||||
- Chore(deps-dev): Bump the development group with 2 updates [@dependabot](https://github.com/dependabot) ([#5737](https://github.com/paperless-ngx/paperless-ngx/pull/5737))
|
||||
- Chore(deps): Bump the django group with 1 update [@dependabot](https://github.com/dependabot) ([#5739](https://github.com/paperless-ngx/paperless-ngx/pull/5739))
|
||||
- Fix: Splitting on ASN barcodes even if not enabled [@stumpylog](https://github.com/stumpylog) ([#5740](https://github.com/paperless-ngx/paperless-ngx/pull/5740))
|
||||
</details>
|
||||
|
||||
## paperless-ngx 2.5.0
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Enhancement: bulk delete objects [@shamoon](https://github.com/shamoon) ([#5688](https://github.com/paperless-ngx/paperless-ngx/pull/5688))
|
||||
|
||||
### Notable Changes
|
||||
|
||||
- Feature: OIDC \& social authentication [@mpflanzer](https://github.com/mpflanzer) ([#5190](https://github.com/paperless-ngx/paperless-ngx/pull/5190))
|
||||
|
||||
### Features
|
||||
|
||||
- Enhancement: confirm buttons [@shamoon](https://github.com/shamoon) ([#5680](https://github.com/paperless-ngx/paperless-ngx/pull/5680))
|
||||
- Enhancement: bulk delete objects [@shamoon](https://github.com/shamoon) ([#5688](https://github.com/paperless-ngx/paperless-ngx/pull/5688))
|
||||
- Feature: allow create objects from bulk edit [@shamoon](https://github.com/shamoon) ([#5667](https://github.com/paperless-ngx/paperless-ngx/pull/5667))
|
||||
- Feature: Allow tagging by putting barcodes on documents [@pkrahmer](https://github.com/pkrahmer) ([#5580](https://github.com/paperless-ngx/paperless-ngx/pull/5580))
|
||||
- Feature: Cache metadata and suggestions in Redis [@stumpylog](https://github.com/stumpylog) ([#5638](https://github.com/paperless-ngx/paperless-ngx/pull/5638))
|
||||
- Feature: Japanese translation [@shamoon](https://github.com/shamoon) ([#5641](https://github.com/paperless-ngx/paperless-ngx/pull/5641))
|
||||
- Feature: option for auto-remove inbox tags on save [@shamoon](https://github.com/shamoon) ([#5562](https://github.com/paperless-ngx/paperless-ngx/pull/5562))
|
||||
- Enhancement: allow paperless to run in read-only filesystem [@hegerdes](https://github.com/hegerdes) ([#5596](https://github.com/paperless-ngx/paperless-ngx/pull/5596))
|
||||
- Enhancement: mergeable bulk edit permissions [@shamoon](https://github.com/shamoon) ([#5508](https://github.com/paperless-ngx/paperless-ngx/pull/5508))
|
||||
- Enhancement: re-implement remote user auth for unsafe API requests as opt-in [@shamoon](https://github.com/shamoon) ([#5561](https://github.com/paperless-ngx/paperless-ngx/pull/5561))
|
||||
- Enhancement: Respect PDF cropbox for thumbnail generation [@henningBunk](https://github.com/henningBunk) ([#5531](https://github.com/paperless-ngx/paperless-ngx/pull/5531))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: Test metadata items for Unicode issues [@stumpylog](https://github.com/stumpylog) ([#5707](https://github.com/paperless-ngx/paperless-ngx/pull/5707))
|
||||
- Change: try to show preview even if metadata fails [@shamoon](https://github.com/shamoon) ([#5706](https://github.com/paperless-ngx/paperless-ngx/pull/5706))
|
||||
- Fix: only check workflow trigger source if not empty [@shamoon](https://github.com/shamoon) ([#5701](https://github.com/paperless-ngx/paperless-ngx/pull/5701))
|
||||
- Fix: frontend validation of number fields fails upon save [@shamoon](https://github.com/shamoon) ([#5646](https://github.com/paperless-ngx/paperless-ngx/pull/5646))
|
||||
- Fix: Explicit validation of custom field name unique constraint [@shamoon](https://github.com/shamoon) ([#5647](https://github.com/paperless-ngx/paperless-ngx/pull/5647))
|
||||
- Fix: Don't attempt to retrieve object types user doesn't have permissions to [@shamoon](https://github.com/shamoon) ([#5612](https://github.com/paperless-ngx/paperless-ngx/pull/5612))
|
||||
|
||||
### Documentation
|
||||
|
||||
- Documentation: add detail about consumer polling behavior [@silmaril42](https://github.com/silmaril42) ([#5674](https://github.com/paperless-ngx/paperless-ngx/pull/5674))
|
||||
- Paperless-ngx Demo: new and improved [@shamoon](https://github.com/shamoon) ([#5639](https://github.com/paperless-ngx/paperless-ngx/pull/5639))
|
||||
- Documentation: Add docs about missing timezones in MySQL/MariaDB [@Programie](https://github.com/Programie) ([#5583](https://github.com/paperless-ngx/paperless-ngx/pull/5583))
|
||||
|
||||
### Maintenance
|
||||
|
||||
- Chore(deps): Bump the actions group with 1 update [@dependabot](https://github.com/dependabot) ([#5629](https://github.com/paperless-ngx/paperless-ngx/pull/5629))
|
||||
- Chore(deps): Bump the actions group with 1 update [@dependabot](https://github.com/dependabot) ([#5597](https://github.com/paperless-ngx/paperless-ngx/pull/5597))
|
||||
|
||||
### Dependencies
|
||||
|
||||
<details>
|
||||
<summary>9 changes</summary>
|
||||
|
||||
- Chore: Backend dependencies update [@stumpylog](https://github.com/stumpylog) ([#5676](https://github.com/paperless-ngx/paperless-ngx/pull/5676))
|
||||
- Chore(deps-dev): Bump [@<!---->playwright/test from 1.40.1 to 1.41.2 in /src-ui @dependabot](https://github.com/<!---->playwright/test from 1.40.1 to 1.41.2 in /src-ui @dependabot) ([#5634](https://github.com/paperless-ngx/paperless-ngx/pull/5634))
|
||||
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 19 updates [@dependabot](https://github.com/dependabot) ([#5630](https://github.com/paperless-ngx/paperless-ngx/pull/5630))
|
||||
- Chore(deps-dev): Bump the frontend-jest-dependencies group in /src-ui with 2 updates [@dependabot](https://github.com/dependabot) ([#5631](https://github.com/paperless-ngx/paperless-ngx/pull/5631))
|
||||
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 2 updates [@dependabot](https://github.com/dependabot) ([#5632](https://github.com/paperless-ngx/paperless-ngx/pull/5632))
|
||||
- Chore(deps): Bump zone.js from 0.14.2 to 0.14.3 in /src-ui [@dependabot](https://github.com/dependabot) ([#5633](https://github.com/paperless-ngx/paperless-ngx/pull/5633))
|
||||
- Chore(deps-dev): Bump [@<!---->types/node from 20.10.6 to 20.11.16 in /src-ui @dependabot](https://github.com/<!---->types/node from 20.10.6 to 20.11.16 in /src-ui @dependabot) ([#5635](https://github.com/paperless-ngx/paperless-ngx/pull/5635))
|
||||
- Chore(deps): Bump the actions group with 1 update [@dependabot](https://github.com/dependabot) ([#5629](https://github.com/paperless-ngx/paperless-ngx/pull/5629))
|
||||
- Chore(deps): Bump the actions group with 1 update [@dependabot](https://github.com/dependabot) ([#5597](https://github.com/paperless-ngx/paperless-ngx/pull/5597))
|
||||
</details>
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>28 changes</summary>
|
||||
|
||||
- Chore: Ensure all creations of directories create the parents too [@stumpylog](https://github.com/stumpylog) ([#5711](https://github.com/paperless-ngx/paperless-ngx/pull/5711))
|
||||
- Fix: Test metadata items for Unicode issues [@stumpylog](https://github.com/stumpylog) ([#5707](https://github.com/paperless-ngx/paperless-ngx/pull/5707))
|
||||
- Change: try to show preview even if metadata fails [@shamoon](https://github.com/shamoon) ([#5706](https://github.com/paperless-ngx/paperless-ngx/pull/5706))
|
||||
- Fix: only check workflow trigger source if not empty [@shamoon](https://github.com/shamoon) ([#5701](https://github.com/paperless-ngx/paperless-ngx/pull/5701))
|
||||
- Enhancement: confirm buttons [@shamoon](https://github.com/shamoon) ([#5680](https://github.com/paperless-ngx/paperless-ngx/pull/5680))
|
||||
- Enhancement: bulk delete objects [@shamoon](https://github.com/shamoon) ([#5688](https://github.com/paperless-ngx/paperless-ngx/pull/5688))
|
||||
- Chore: Backend dependencies update [@stumpylog](https://github.com/stumpylog) ([#5676](https://github.com/paperless-ngx/paperless-ngx/pull/5676))
|
||||
- Feature: OIDC \& social authentication [@mpflanzer](https://github.com/mpflanzer) ([#5190](https://github.com/paperless-ngx/paperless-ngx/pull/5190))
|
||||
- Chore: Don't write Python bytecode in the Docker image [@stumpylog](https://github.com/stumpylog) ([#5677](https://github.com/paperless-ngx/paperless-ngx/pull/5677))
|
||||
- Feature: allow create objects from bulk edit [@shamoon](https://github.com/shamoon) ([#5667](https://github.com/paperless-ngx/paperless-ngx/pull/5667))
|
||||
- Chore: Use memory cache backend in debug mode [@shamoon](https://github.com/shamoon) ([#5666](https://github.com/paperless-ngx/paperless-ngx/pull/5666))
|
||||
- Chore: Adds additional rules for Ruff linter [@stumpylog](https://github.com/stumpylog) ([#5660](https://github.com/paperless-ngx/paperless-ngx/pull/5660))
|
||||
- Feature: Allow tagging by putting barcodes on documents [@pkrahmer](https://github.com/pkrahmer) ([#5580](https://github.com/paperless-ngx/paperless-ngx/pull/5580))
|
||||
- Feature: Cache metadata and suggestions in Redis [@stumpylog](https://github.com/stumpylog) ([#5638](https://github.com/paperless-ngx/paperless-ngx/pull/5638))
|
||||
- Fix: frontend validation of number fields fails upon save [@shamoon](https://github.com/shamoon) ([#5646](https://github.com/paperless-ngx/paperless-ngx/pull/5646))
|
||||
- Fix: Explicit validation of custom field name unique constraint [@shamoon](https://github.com/shamoon) ([#5647](https://github.com/paperless-ngx/paperless-ngx/pull/5647))
|
||||
- Feature: Japanese translation [@shamoon](https://github.com/shamoon) ([#5641](https://github.com/paperless-ngx/paperless-ngx/pull/5641))
|
||||
- Chore(deps-dev): Bump [@<!---->playwright/test from 1.40.1 to 1.41.2 in /src-ui @dependabot](https://github.com/<!---->playwright/test from 1.40.1 to 1.41.2 in /src-ui @dependabot) ([#5634](https://github.com/paperless-ngx/paperless-ngx/pull/5634))
|
||||
- Feature: option for auto-remove inbox tags on save [@shamoon](https://github.com/shamoon) ([#5562](https://github.com/paperless-ngx/paperless-ngx/pull/5562))
|
||||
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 19 updates [@dependabot](https://github.com/dependabot) ([#5630](https://github.com/paperless-ngx/paperless-ngx/pull/5630))
|
||||
- Chore(deps-dev): Bump the frontend-jest-dependencies group in /src-ui with 2 updates [@dependabot](https://github.com/dependabot) ([#5631](https://github.com/paperless-ngx/paperless-ngx/pull/5631))
|
||||
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 2 updates [@dependabot](https://github.com/dependabot) ([#5632](https://github.com/paperless-ngx/paperless-ngx/pull/5632))
|
||||
- Chore(deps): Bump zone.js from 0.14.2 to 0.14.3 in /src-ui [@dependabot](https://github.com/dependabot) ([#5633](https://github.com/paperless-ngx/paperless-ngx/pull/5633))
|
||||
- Chore(deps-dev): Bump [@<!---->types/node from 20.10.6 to 20.11.16 in /src-ui @dependabot](https://github.com/<!---->types/node from 20.10.6 to 20.11.16 in /src-ui @dependabot) ([#5635](https://github.com/paperless-ngx/paperless-ngx/pull/5635))
|
||||
- Enhancement: mergeable bulk edit permissions [@shamoon](https://github.com/shamoon) ([#5508](https://github.com/paperless-ngx/paperless-ngx/pull/5508))
|
||||
- Enhancement: re-implement remote user auth for unsafe API requests as opt-in [@shamoon](https://github.com/shamoon) ([#5561](https://github.com/paperless-ngx/paperless-ngx/pull/5561))
|
||||
- Enhancement: Respect PDF cropbox for thumbnail generation [@henningBunk](https://github.com/henningBunk) ([#5531](https://github.com/paperless-ngx/paperless-ngx/pull/5531))
|
||||
- Fix: Don't attempt to retrieve object types user doesn't have permissions to [@shamoon](https://github.com/shamoon) ([#5612](https://github.com/paperless-ngx/paperless-ngx/pull/5612))
|
||||
</details>
|
||||
|
||||
## paperless-ngx 2.4.3
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: Ensure the scratch directory exists before consuming via the folder [@stumpylog](https://github.com/stumpylog) ([#5579](https://github.com/paperless-ngx/paperless-ngx/pull/5579))
|
||||
|
||||
### All App Changes
|
||||
|
||||
- Fix: Ensure the scratch directory exists before consuming via the folder [@stumpylog](https://github.com/stumpylog) ([#5579](https://github.com/paperless-ngx/paperless-ngx/pull/5579))
|
||||
|
||||
## paperless-ngx 2.4.2
|
||||
|
||||
### Bug Fixes
|
||||
|
@@ -34,6 +34,8 @@ matcher.
|
||||
`redis://<username>:<password>@<host>:<port>`
|
||||
- With the requirepass option PAPERLESS_REDIS =
|
||||
`redis://:<password>@<host>:<port>`
|
||||
- To include the redis database index PAPERLESS_REDIS =
|
||||
`redis://<username>:<password>@<host>:<port>/<DBIndex>`
|
||||
|
||||
[More information on securing your Redis
|
||||
Instance](https://redis.io/docs/getting-started/#securing-redis).
|
||||
@@ -452,19 +454,32 @@ applications.
|
||||
|
||||
This will allow authentication by simply adding a
|
||||
`Remote-User: <username>` header to a request. Use with care! You
|
||||
especially *must: ensure that any such header is not passed from
|
||||
your proxy server to paperless.
|
||||
especially *must* ensure that any such header is not passed from
|
||||
external requests to your reverse-proxy to paperless (that would
|
||||
effectively bypass all authentication).
|
||||
|
||||
If you're exposing paperless to the internet directly, do not use
|
||||
this.
|
||||
If you're exposing paperless to the internet directly (i.e.
|
||||
without a reverse proxy), do not use this.
|
||||
|
||||
Also see the warning [in the official documentation](https://docs.djangoproject.com/en/4.1/howto/auth-remote-user/#configuration).
|
||||
|
||||
Defaults to "false" which disables this feature.
|
||||
|
||||
#### [`PAPERLESS_ENABLE_HTTP_REMOTE_USER_API=<bool>`](#PAPERLESS_ENABLE_HTTP_REMOTE_USER_API) {#PAPERLESS_ENABLE_HTTP_REMOTE_USER_API}
|
||||
|
||||
: Allows authentication via HTTP_REMOTE_USER directly against the API
|
||||
|
||||
!!! warning
|
||||
|
||||
See the warning above about securing your installation when using remote user header authentication. This setting is separate from
|
||||
`PAPERLESS_ENABLE_HTTP_REMOTE_USER` to avoid introducing a security vulnerability to existing reverse proxy setups. As above,
|
||||
ensure that your reverse proxy does not simply pass the `Remote-User` header from the internet to paperless.
|
||||
|
||||
Defaults to "false" which disables this feature.
|
||||
|
||||
#### [`PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME=<str>`](#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME) {#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME}
|
||||
|
||||
: If "PAPERLESS_ENABLE_HTTP_REMOTE_USER" is enabled, this
|
||||
: If "PAPERLESS_ENABLE_HTTP_REMOTE_USER" or `PAPERLESS_ENABLE_HTTP_REMOTE_USER_API` are enabled, this
|
||||
property allows to customize the name of the HTTP header from which
|
||||
the authenticated username is extracted. Values are in terms of
|
||||
[HttpRequest.META](https://docs.djangoproject.com/en/4.1/ref/request-response/#django.http.HttpRequest.META).
|
||||
@@ -521,6 +536,42 @@ This is for use with self-signed certificates against local IMAP servers.
|
||||
Settings this value has security implications for the security of your email.
|
||||
Understand what it does and be sure you need to before setting.
|
||||
|
||||
#### [`PAPERLESS_SOCIALACCOUNT_PROVIDERS=<json>`](#PAPERLESS_SOCIALACCOUNT_PROVIDERS) {#PAPERLESS_SOCIALACCOUNT_PROVIDERS}
|
||||
|
||||
: This variable is used to setup login and signup via social account providers which are compatible with django-allauth.
|
||||
See the corresponding [django-allauth documentation](https://docs.allauth.org/en/0.60.0/socialaccount/providers/index.html)
|
||||
for a list of provider configurations. You will also need to include the relevant Django 'application' inside the
|
||||
[PAPERLESS_APPS](#PAPERLESS_APPS) setting to activate that specific authentication provider (e.g. `allauth.socialaccount.providers.openid_connect` for the [OIDC Connect provider](https://docs.allauth.org/en/latest/socialaccount/providers/openid_connect.html)).
|
||||
|
||||
Defaults to None, which does not enable any third party authentication systems.
|
||||
|
||||
#### [`PAPERLESS_SOCIAL_AUTO_SIGNUP=<bool>`](#PAPERLESS_SOCIAL_AUTO_SIGNUP) {#PAPERLESS_SOCIAL_AUTO_SIGNUP}
|
||||
|
||||
: Attempt to signup the user using retrieved email, username etc from the third party authentication
|
||||
system. See the corresponding
|
||||
[django-allauth documentation](https://docs.allauth.org/en/0.60.0/socialaccount/configuration.html)
|
||||
|
||||
Defaults to False
|
||||
|
||||
#### [`PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS=<bool>`](#PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS) {#PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS}
|
||||
|
||||
: Allow users to signup for a new Paperless-ngx account using any setup third party authentication systems.
|
||||
|
||||
Defaults to True
|
||||
|
||||
#### [`PAPERLESS_ACCOUNT_ALLOW_SIGNUPS=<bool>`](#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS) {#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS}
|
||||
|
||||
: Allow users to signup for a new Paperless-ngx account.
|
||||
|
||||
Defaults to False
|
||||
|
||||
#### [`PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL=<string>`](#PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL) {#PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL}
|
||||
|
||||
: The protocol used when generating URLs, e.g. login callback URLs. See the corresponding
|
||||
[django-allauth documentation](https://docs.allauth.org/en/latest/account/configuration.html)
|
||||
|
||||
Defaults to 'https'
|
||||
|
||||
## OCR settings {#ocr}
|
||||
|
||||
Paperless uses [OCRmyPDF](https://ocrmypdf.readthedocs.io/en/latest/)
|
||||
@@ -891,6 +942,14 @@ documents.
|
||||
|
||||
Default is none, which disables the temporary directory.
|
||||
|
||||
#### [`PAPERLESS_APPS=<string>`](#PAPERLESS_APPS) {#PAPERLESS_APPS}
|
||||
|
||||
: A comma-separated list of Django apps to be included in Django's
|
||||
[`INSTALLED_APPS`](https://docs.djangoproject.com/en/5.0/ref/applications/). This setting should
|
||||
be used with caution!
|
||||
|
||||
Defaults to None, which does not add any additional apps.
|
||||
|
||||
## Document Consumption {#consume_config}
|
||||
|
||||
#### [`PAPERLESS_CONSUMER_DELETE_DUPLICATES=<bool>`](#PAPERLESS_CONSUMER_DELETE_DUPLICATES) {#PAPERLESS_CONSUMER_DELETE_DUPLICATES}
|
||||
@@ -1049,8 +1108,10 @@ system changes with `inotify`.
|
||||
|
||||
#### [`PAPERLESS_CONSUMER_POLLING_RETRY_COUNT=<num>`](#PAPERLESS_CONSUMER_POLLING_RETRY_COUNT) {#PAPERLESS_CONSUMER_POLLING_RETRY_COUNT}
|
||||
|
||||
: If consumer polling is enabled, sets the number of times paperless
|
||||
will check for a file to remain unmodified.
|
||||
: If consumer polling is enabled, sets the maximum number of times
|
||||
paperless will check for a file to remain unmodified. If a file's
|
||||
modification time and size are identical for two consecutive checks, it
|
||||
will be consumed.
|
||||
|
||||
Defaults to 5.
|
||||
|
||||
@@ -1159,6 +1220,55 @@ combination with PAPERLESS_CONSUMER_BARCODE_UPSCALE bigger than 1.0.
|
||||
|
||||
Defaults to "300"
|
||||
|
||||
#### [`PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE=<bool>`](#PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE) {#PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE}
|
||||
|
||||
: Enables the detection of barcodes in the scanned document and
|
||||
assigns or creates tags if a properly formatted barcode is detected.
|
||||
|
||||
The barcode must match one of the (configurable) regular expressions.
|
||||
If the barcode text contains ',' (comma), it is split into multiple
|
||||
barcodes which are individually processed for tagging.
|
||||
|
||||
Matching is case insensitive.
|
||||
|
||||
Defaults to false.
|
||||
|
||||
#### [`PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING=<json dict>`](#PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING) {#PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING}
|
||||
|
||||
: Defines a dictionary of filter regex and substitute expressions.
|
||||
|
||||
Syntax: {"<regex>": "<substitute>" [,...]]}
|
||||
|
||||
A barcode is considered for tagging if the barcode text matches
|
||||
at least one of the provided <regex> pattern.
|
||||
|
||||
If a match is found, the <substitute> rule is applied. This allows very
|
||||
versatile reformatting and mapping of barcode pattern to tag values.
|
||||
|
||||
If a tag is not found it will be created.
|
||||
|
||||
Defaults to:
|
||||
|
||||
{"TAG:(.*)": "\\g<1>"} which defines
|
||||
- a regex TAG:(.*) which includes barcodes beginning with TAG:
|
||||
followed by any text that gets stored into match group #1 and
|
||||
- a substitute \\g<1> that replaces the original barcode text
|
||||
by the content in match group #1.
|
||||
Consequently, the tag is the barcode text without its TAG: prefix.
|
||||
|
||||
More examples:
|
||||
|
||||
{"ASN12.*": "JOHN", "ASN13.*": "SMITH"} for example maps
|
||||
- ASN12nnnn barcodes to the tag JOHN and
|
||||
- ASN13nnnn barcodes to the tag SMITH.
|
||||
|
||||
{"T-J": "JOHN", "T-S": "SMITH", "T-D": "DOE"} directly maps
|
||||
- T-J barcodes to the tag JOHN,
|
||||
- T-S barcodes to the tag SMITH and
|
||||
- T-D barcodes to the tag DOE.
|
||||
|
||||
Please refer to the Python regex documentation for more information.
|
||||
|
||||
## Audit Trail
|
||||
|
||||
#### [`PAPERLESS_AUDIT_LOG_ENABLED=<bool>`](#PAPERLESS_AUDIT_LOG_ENABLED) {#PAPERLESS_AUDIT_LOG_ENABLED}
|
||||
@@ -1329,6 +1439,12 @@ started by the container.
|
||||
|
||||
You can read more about this in the [advanced documentation](advanced_usage.md#celery-monitoring).
|
||||
|
||||
#### [`PAPERLESS_SUPERVISORD_WORKING_DIR=<defined>`](#PAPERLESS_SUPERVISORD_WORKING_DIR) {#PAPERLESS_SUPERVISORD_WORKING_DIR}
|
||||
|
||||
: If this environment variable is defined, the `supervisord.log` and `supervisord.pid` file will be created under the specified path in `PAPERLESS_SUPERVISORD_WORKING_DIR`. Setting `PAPERLESS_SUPERVISORD_WORKING_DIR=/tmp` and `PYTHONPYCACHEPREFIX=/tmp/pycache` would allow paperless to work on a read-only filesystem.
|
||||
|
||||
Please take note that the `PAPERLESS_DATA_DIR` and `PAPERLESS_MEDIA_ROOT` paths still have to be writable, just like the `PAPERLESS_SUPERVISORD_WORKING_DIR`. The can be archived by using bind or volume mounts. Only works in the container is run as user *paperless*
|
||||
|
||||
## Frontend Settings
|
||||
|
||||
#### [`PAPERLESS_APP_TITLE=<bool>`](#PAPERLESS_APP_TITLE) {#PAPERLESS_APP_TITLE}
|
||||
|
@@ -6,6 +6,14 @@
|
||||
physical documents into a searchable online archive so you can keep, well, _less paper_.
|
||||
|
||||
[Get started](setup.md){ .md-button .md-button--primary .index-callout }
|
||||
[Demo](https://demo.paperless-ngx.com){ .md-button .md-button--secondary target=\_blank }
|
||||
|
||||
<div style="display: flex; justify-content: end; margin-top: -1.5rem;">
|
||||
<a href="https://m.do.co/c/8d70b916d462" target="_blank">
|
||||
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_white.svg#only-dark" class="no-lightbox" width="150px">
|
||||
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_black.svg#only-light" class="no-lightbox" width="150px">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="grid-right" markdown>
|
||||
|
@@ -331,7 +331,7 @@ Workflows allow you to filter by:
|
||||
There is currently one type of workflow action, "Assignment", which can assign:
|
||||
|
||||
- Title, see [title placeholders](usage.md#title-placeholders) below
|
||||
- Tags, correspondent, document types
|
||||
- Tags, correspondent, document type and storage path
|
||||
- Document owner
|
||||
- View and / or edit permissions to users or groups
|
||||
- Custom fields. Note that no value for the field will be set
|
||||
|
@@ -315,7 +315,7 @@ fi
|
||||
wget "https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/docker/compose/docker-compose.$DOCKER_COMPOSE_VERSION.yml" -O docker-compose.yml
|
||||
wget "https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/docker/compose/.env" -O .env
|
||||
|
||||
SECRET_KEY=$(LC_ALL=C tr -dc 'a-zA-Z0-9!"#$%&'\''()*+,-./:;<=>?@[\]^_`{|}~' < /dev/urandom | dd bs=1 count=64 2>/dev/null)
|
||||
SECRET_KEY=$(LC_ALL=C tr -dc 'a-zA-Z0-9!#$%&()*+,-./:;<=>?@[\]^_`{|}~' < /dev/urandom | dd bs=1 count=64 2>/dev/null)
|
||||
|
||||
|
||||
DEFAULT_LANGUAGES=("deu eng fra ita spa")
|
||||
|
@@ -73,4 +73,6 @@ extra:
|
||||
link: https://matrix.to/#/#paperless:matrix.org
|
||||
plugins:
|
||||
- search
|
||||
- glightbox
|
||||
- glightbox:
|
||||
skip_classes:
|
||||
- no-lightbox
|
||||
|
@@ -68,6 +68,8 @@
|
||||
#PAPERLESS_CONSUMER_BARCODE_STRING=PATCHT
|
||||
#PAPERLESS_CONSUMER_BARCODE_UPSCALE=0.0
|
||||
#PAPERLESS_CONSUMER_BARCODE_DPI=300
|
||||
#PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE=false
|
||||
#PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING={"TAG:(.*)": "\\g<1>"}
|
||||
#PAPERLESS_CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED=false
|
||||
#PAPERLESS_CONSUMER_COLLATE_DOUBLE_SIDED_SUBDIR_NAME=double-sided
|
||||
#PAPERLESS_CONSUMER_COLLATE_DOUBLE_SIDED_TIFF_SUPPORT=false
|
||||
|
@@ -31,6 +31,7 @@
|
||||
"fr-FR": "src/locale/messages.fr_FR.xlf",
|
||||
"hu-HU": "src/locale/messages.hu_HU.xlf",
|
||||
"it-IT": "src/locale/messages.it_IT.xlf",
|
||||
"ja-JP": "src/locale/messages.ja_JP.xlf",
|
||||
"lb-LU": "src/locale/messages.lb_LU.xlf",
|
||||
"nl-NL": "src/locale/messages.nl_NL.xlf",
|
||||
"no-NO": "src/locale/messages.no_NO.xlf",
|
||||
|
@@ -2700,7 +2700,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2734,7 +2734,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2768,7 +2768,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2802,7 +2802,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2836,7 +2836,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2870,7 +2870,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2904,7 +2904,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2938,7 +2938,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2972,7 +2972,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -3006,7 +3006,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -3040,7 +3040,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -3074,7 +3074,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -3108,7 +3108,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -3142,7 +3142,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -3176,7 +3176,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -3210,7 +3210,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -3244,7 +3244,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -3278,7 +3278,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -3312,7 +3312,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
|
@@ -425,7 +425,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -470,7 +470,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -645,7 +645,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -685,7 +685,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -729,7 +729,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
|
@@ -843,7 +843,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -994,7 +994,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
|
@@ -996,7 +996,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1301,7 +1301,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1484,7 +1484,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1518,7 +1518,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1552,7 +1552,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1586,7 +1586,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1620,7 +1620,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1654,7 +1654,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1688,7 +1688,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1722,7 +1722,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1756,7 +1756,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1790,7 +1790,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1824,7 +1824,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1858,7 +1858,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1892,7 +1892,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1926,7 +1926,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1960,7 +1960,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1994,7 +1994,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2028,7 +2028,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2062,7 +2062,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2096,7 +2096,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2130,7 +2130,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2164,7 +2164,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2198,7 +2198,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2232,7 +2232,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2266,7 +2266,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2300,7 +2300,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2334,7 +2334,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2368,7 +2368,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2402,7 +2402,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2436,7 +2436,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2470,7 +2470,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": -1,
|
||||
"status": 200,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
|
File diff suppressed because it is too large
Load Diff
4036
src-ui/package-lock.json
generated
4036
src-ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,17 +11,17 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^17.0.4",
|
||||
"@angular/common": "~17.0.8",
|
||||
"@angular/compiler": "~17.0.8",
|
||||
"@angular/core": "~17.0.8",
|
||||
"@angular/forms": "~17.0.8",
|
||||
"@angular/localize": "~17.0.8",
|
||||
"@angular/platform-browser": "~17.0.8",
|
||||
"@angular/platform-browser-dynamic": "~17.0.8",
|
||||
"@angular/router": "~17.0.8",
|
||||
"@angular/cdk": "^17.1.2",
|
||||
"@angular/common": "~17.1.2",
|
||||
"@angular/compiler": "~17.1.2",
|
||||
"@angular/core": "~17.1.2",
|
||||
"@angular/forms": "~17.1.2",
|
||||
"@angular/localize": "~17.1.2",
|
||||
"@angular/platform-browser": "~17.1.2",
|
||||
"@angular/platform-browser-dynamic": "~17.1.2",
|
||||
"@angular/router": "~17.1.2",
|
||||
"@ng-bootstrap/ng-bootstrap": "^16.0.0",
|
||||
"@ng-select/ng-select": "^12.0.4",
|
||||
"@ng-select/ng-select": "^12.0.6",
|
||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"bootstrap": "^5.3.2",
|
||||
@@ -31,33 +31,33 @@
|
||||
"ngx-color": "^9.0.0",
|
||||
"ngx-cookie-service": "^17.0.1",
|
||||
"ngx-file-drop": "^16.0.0",
|
||||
"ngx-ui-tour-ng-bootstrap": "^14.0.1",
|
||||
"ngx-ui-tour-ng-bootstrap": "^14.0.2",
|
||||
"pdfjs-dist": "^3.11.174",
|
||||
"rxjs": "^7.8.1",
|
||||
"tslib": "^2.6.2",
|
||||
"uuid": "^9.0.1",
|
||||
"zone.js": "^0.14.2"
|
||||
"zone.js": "^0.14.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-builders/jest": "17.0.0",
|
||||
"@angular-devkit/build-angular": "~17.0.8",
|
||||
"@angular-eslint/builder": "17.1.1",
|
||||
"@angular-eslint/eslint-plugin": "17.1.1",
|
||||
"@angular-eslint/eslint-plugin-template": "17.1.1",
|
||||
"@angular-eslint/schematics": "17.1.1",
|
||||
"@angular-eslint/template-parser": "17.1.1",
|
||||
"@angular/cli": "~17.0.8",
|
||||
"@angular/compiler-cli": "~17.0.7",
|
||||
"@playwright/test": "^1.40.1",
|
||||
"@types/jest": "^29.5.10",
|
||||
"@types/node": "^20.10.6",
|
||||
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
||||
"@typescript-eslint/parser": "^6.17.0",
|
||||
"@angular-devkit/build-angular": "~17.1.2",
|
||||
"@angular-eslint/builder": "17.2.1",
|
||||
"@angular-eslint/eslint-plugin": "17.2.1",
|
||||
"@angular-eslint/eslint-plugin-template": "17.2.1",
|
||||
"@angular-eslint/schematics": "17.2.1",
|
||||
"@angular-eslint/template-parser": "17.2.1",
|
||||
"@angular/cli": "~17.1.2",
|
||||
"@angular/compiler-cli": "~17.1.2",
|
||||
"@playwright/test": "^1.41.2",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^20.11.16",
|
||||
"@typescript-eslint/eslint-plugin": "^6.20.0",
|
||||
"@typescript-eslint/parser": "^6.20.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"eslint": "^8.56.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-preset-angular": "^13.1.4",
|
||||
"jest-preset-angular": "^14.0.0",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"patch-package": "^8.0.0",
|
||||
"ts-node": "~10.9.1",
|
||||
|
@@ -23,6 +23,7 @@ import localeFi from '@angular/common/locales/fi'
|
||||
import localeFr from '@angular/common/locales/fr'
|
||||
import localeHu from '@angular/common/locales/hu'
|
||||
import localeIt from '@angular/common/locales/it'
|
||||
import localeJa from '@angular/common/locales/ja'
|
||||
import localeLb from '@angular/common/locales/lb'
|
||||
import localeNl from '@angular/common/locales/nl'
|
||||
import localeNo from '@angular/common/locales/no'
|
||||
@@ -53,6 +54,7 @@ registerLocaleData(localeFi)
|
||||
registerLocaleData(localeFr)
|
||||
registerLocaleData(localeHu)
|
||||
registerLocaleData(localeIt)
|
||||
registerLocaleData(localeJa)
|
||||
registerLocaleData(localeLb)
|
||||
registerLocaleData(localeNl)
|
||||
registerLocaleData(localeNo)
|
||||
|
@@ -112,6 +112,7 @@ import { SwitchComponent } from './components/common/input/switch/switch.compone
|
||||
import { ConfigComponent } from './components/admin/config/config.component'
|
||||
import { FileComponent } from './components/common/input/file/file.component'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { ConfirmButtonComponent } from './components/common/confirm-button/confirm-button.component'
|
||||
import {
|
||||
archive,
|
||||
arrowCounterclockwise,
|
||||
@@ -137,6 +138,7 @@ import {
|
||||
chevronDoubleLeft,
|
||||
chevronDoubleRight,
|
||||
clipboard,
|
||||
clipboardCheck,
|
||||
clipboardCheckFill,
|
||||
clipboardFill,
|
||||
dash,
|
||||
@@ -221,6 +223,7 @@ const icons = {
|
||||
chevronDoubleLeft,
|
||||
chevronDoubleRight,
|
||||
clipboard,
|
||||
clipboardCheck,
|
||||
clipboardCheckFill,
|
||||
clipboardFill,
|
||||
dash,
|
||||
@@ -295,6 +298,7 @@ import localeFi from '@angular/common/locales/fi'
|
||||
import localeFr from '@angular/common/locales/fr'
|
||||
import localeHu from '@angular/common/locales/hu'
|
||||
import localeIt from '@angular/common/locales/it'
|
||||
import localeJa from '@angular/common/locales/ja'
|
||||
import localeLb from '@angular/common/locales/lb'
|
||||
import localeNl from '@angular/common/locales/nl'
|
||||
import localeNo from '@angular/common/locales/no'
|
||||
@@ -325,6 +329,7 @@ registerLocaleData(localeFi)
|
||||
registerLocaleData(localeFr)
|
||||
registerLocaleData(localeHu)
|
||||
registerLocaleData(localeIt)
|
||||
registerLocaleData(localeJa)
|
||||
registerLocaleData(localeLb)
|
||||
registerLocaleData(localeNl)
|
||||
registerLocaleData(localeNo)
|
||||
@@ -437,6 +442,7 @@ function initializeApp(settings: SettingsService) {
|
||||
SwitchComponent,
|
||||
ConfigComponent,
|
||||
FileComponent,
|
||||
ConfirmButtonComponent,
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
|
@@ -158,6 +158,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="mt-4" i18n>Document editing</h4>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="offset-md-3 col">
|
||||
<pngx-input-check i18n-title title="Automatically remove inbox tag(s) on save" formControlName="documentEditingRemoveInboxTags"></pngx-input-check>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="mt-4" i18n>Bulk editing</h4>
|
||||
|
||||
<div class="row mb-3">
|
||||
@@ -311,7 +319,15 @@
|
||||
</div>
|
||||
<div class="mb-2 col-auto">
|
||||
<label class="form-label" for="name_{{view.id}}" i18n>Actions</label>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger form-control" (click)="deleteSavedView(view)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.SavedView }" i18n>Delete</button>
|
||||
|
||||
<pngx-confirm-button
|
||||
label="Delete"
|
||||
i18n-label
|
||||
(confirm)="deleteSavedView(view)"
|
||||
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.SavedView }"
|
||||
buttonClasses="btn-sm btn-outline-danger form-control"
|
||||
iconName="trash">
|
||||
</pngx-confirm-button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
@@ -38,6 +38,7 @@ import { PageHeaderComponent } from '../../common/page-header/page-header.compon
|
||||
import { SettingsComponent } from './settings.component'
|
||||
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
|
||||
|
||||
const savedViews = [
|
||||
{ id: 1, name: 'view1', show_in_sidebar: true, show_on_dashboard: true },
|
||||
@@ -83,6 +84,7 @@ describe('SettingsComponent', () => {
|
||||
PermissionsUserComponent,
|
||||
PermissionsGroupComponent,
|
||||
IfOwnerDirective,
|
||||
ConfirmButtonComponent,
|
||||
],
|
||||
providers: [CustomDatePipe, DatePipe, PermissionsGuard],
|
||||
imports: [
|
||||
@@ -289,7 +291,7 @@ describe('SettingsComponent', () => {
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(storeSpy).toHaveBeenCalled()
|
||||
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
||||
expect(setSpy).toHaveBeenCalledTimes(24)
|
||||
expect(setSpy).toHaveBeenCalledTimes(25)
|
||||
|
||||
// succeed
|
||||
storeSpy.mockReturnValueOnce(of(true))
|
||||
|
@@ -88,6 +88,7 @@ export class SettingsComponent
|
||||
defaultPermsViewGroups: new FormControl(null),
|
||||
defaultPermsEditUsers: new FormControl(null),
|
||||
defaultPermsEditGroups: new FormControl(null),
|
||||
documentEditingRemoveInboxTags: new FormControl(null),
|
||||
|
||||
notificationsConsumerNewDocument: new FormControl(null),
|
||||
notificationsConsumerSuccess: new FormControl(null),
|
||||
@@ -271,6 +272,9 @@ export class SettingsComponent
|
||||
defaultPermsEditGroups: this.settings.get(
|
||||
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS
|
||||
),
|
||||
documentEditingRemoveInboxTags: this.settings.get(
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS
|
||||
),
|
||||
savedViews: {},
|
||||
}
|
||||
}
|
||||
@@ -484,6 +488,10 @@ export class SettingsComponent
|
||||
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS,
|
||||
this.settingsForm.value.defaultPermsEditGroups
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS,
|
||||
this.settingsForm.value.documentEditingRemoveInboxTags
|
||||
)
|
||||
this.settings.setLanguage(this.settingsForm.value.displayLanguage)
|
||||
this.settings
|
||||
.storeSettings()
|
||||
|
@@ -33,64 +33,64 @@
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="editUser(user)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.User }">
|
||||
<i-bs width="1em" height="1em" name="pencil"></i-bs> <ng-container i18n>Edit</ng-container>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteUser(user)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.User }">
|
||||
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@if (groups) {
|
||||
<h4 class="mt-4 d-flex">
|
||||
<ng-container i18n>Groups</ng-container>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editGroup()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Group }">
|
||||
<i-bs name="plus-circle"></i-bs> <ng-container i18n>Add Group</ng-container>
|
||||
</button>
|
||||
</h4>
|
||||
@if (groups.length > 0) {
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col" i18n>Name</div>
|
||||
<div class="col"></div>
|
||||
<div class="col"></div>
|
||||
<div class="col" i18n>Actions</div>
|
||||
</div>
|
||||
</li>
|
||||
@for (group of groups; track group) {
|
||||
<li class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editGroup(group)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Group)">{{group.name}}</button></div>
|
||||
<div class="col"></div>
|
||||
<div class="col"></div>
|
||||
<div class="col">
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="editGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Group }">
|
||||
<i-bs width="1em" height="1em" name="pencil"></i-bs> <ng-container i18n>Edit</ng-container>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteUser(user)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.User }">
|
||||
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Group }">
|
||||
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@if (groups) {
|
||||
<h4 class="mt-4 d-flex">
|
||||
<ng-container i18n>Groups</ng-container>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editGroup()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Group }">
|
||||
<i-bs name="plus-circle"></i-bs> <ng-container i18n>Add Group</ng-container>
|
||||
</button>
|
||||
</h4>
|
||||
@if (groups.length > 0) {
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col" i18n>Name</div>
|
||||
<div class="col"></div>
|
||||
<div class="col"></div>
|
||||
<div class="col" i18n>Actions</div>
|
||||
</div>
|
||||
</li>
|
||||
@for (group of groups; track group) {
|
||||
<li class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editGroup(group)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Group)">{{group.name}}</button></div>
|
||||
<div class="col"></div>
|
||||
<div class="col"></div>
|
||||
<div class="col">
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="editGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Group }">
|
||||
<i-bs width="1em" height="1em" name="pencil"></i-bs> <ng-container i18n>Edit</ng-container>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Group }">
|
||||
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
@if (groups.length === 0) {
|
||||
<li class="list-group-item" i18n>No groups defined</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
|
||||
@if (!users || !groups) {
|
||||
<div>
|
||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||
<div class="visually-hidden" i18n>Loading...</div>
|
||||
</div>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
@if (groups.length === 0) {
|
||||
<li class="list-group-item" i18n>No groups defined</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
|
||||
@if (!users || !groups) {
|
||||
<div>
|
||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||
<div class="visually-hidden" i18n>Loading...</div>
|
||||
</div>
|
||||
}
|
||||
|
@@ -21,6 +21,10 @@ import { IfPermissionsDirective } from 'src/app/directives/if-permissions.direct
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import {
|
||||
DjangoMessageLevel,
|
||||
DjangoMessagesService,
|
||||
} from 'src/app/services/django-messages.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
@@ -83,6 +87,7 @@ describe('AppFrameComponent', () => {
|
||||
let permissionsService: PermissionsService
|
||||
let remoteVersionService: RemoteVersionService
|
||||
let toastService: ToastService
|
||||
let messagesService: DjangoMessagesService
|
||||
let openDocumentsService: OpenDocumentsService
|
||||
let searchService: SearchService
|
||||
let documentListViewService: DocumentListViewService
|
||||
@@ -123,6 +128,7 @@ describe('AppFrameComponent', () => {
|
||||
RemoteVersionService,
|
||||
IfPermissionsDirective,
|
||||
ToastService,
|
||||
DjangoMessagesService,
|
||||
OpenDocumentsService,
|
||||
SearchService,
|
||||
NgbModal,
|
||||
@@ -151,6 +157,7 @@ describe('AppFrameComponent', () => {
|
||||
permissionsService = TestBed.inject(PermissionsService)
|
||||
remoteVersionService = TestBed.inject(RemoteVersionService)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
messagesService = TestBed.inject(DjangoMessagesService)
|
||||
openDocumentsService = TestBed.inject(OpenDocumentsService)
|
||||
searchService = TestBed.inject(SearchService)
|
||||
documentListViewService = TestBed.inject(DocumentListViewService)
|
||||
@@ -393,4 +400,19 @@ describe('AppFrameComponent', () => {
|
||||
backdrop: 'static',
|
||||
})
|
||||
})
|
||||
|
||||
it('should show toasts for django messages', () => {
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
jest.spyOn(messagesService, 'get').mockReturnValue([
|
||||
{ level: DjangoMessageLevel.WARNING, message: 'Test warning' },
|
||||
{ level: DjangoMessageLevel.ERROR, message: 'Test error' },
|
||||
{ level: DjangoMessageLevel.SUCCESS, message: 'Test success' },
|
||||
{ level: DjangoMessageLevel.INFO, message: 'Test info' },
|
||||
{ level: DjangoMessageLevel.DEBUG, message: 'Test debug' },
|
||||
])
|
||||
component.ngOnInit()
|
||||
expect(toastErrorSpy).toHaveBeenCalledTimes(2)
|
||||
expect(toastInfoSpy).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
@@ -12,6 +12,10 @@ import {
|
||||
} from 'rxjs/operators'
|
||||
import { Document } from 'src/app/data/document'
|
||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
||||
import {
|
||||
DjangoMessageLevel,
|
||||
DjangoMessagesService,
|
||||
} from 'src/app/services/django-messages.service'
|
||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||
import { SearchService } from 'src/app/services/rest/search.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
@@ -73,7 +77,8 @@ export class AppFrameComponent
|
||||
public tasksService: TasksService,
|
||||
private readonly toastService: ToastService,
|
||||
private modalService: NgbModal,
|
||||
permissionsService: PermissionsService
|
||||
public permissionsService: PermissionsService,
|
||||
private djangoMessagesService: DjangoMessagesService
|
||||
) {
|
||||
super()
|
||||
|
||||
@@ -92,6 +97,20 @@ export class AppFrameComponent
|
||||
this.checkForUpdates()
|
||||
}
|
||||
this.tasksService.reload()
|
||||
|
||||
this.djangoMessagesService.get().forEach((message) => {
|
||||
switch (message.level) {
|
||||
case DjangoMessageLevel.ERROR:
|
||||
case DjangoMessageLevel.WARNING:
|
||||
this.toastService.showError(message.message)
|
||||
break
|
||||
case DjangoMessageLevel.SUCCESS:
|
||||
case DjangoMessageLevel.INFO:
|
||||
case DjangoMessageLevel.DEBUG:
|
||||
this.toastService.showInfo(message.message)
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
toggleSlimSidebar(): void {
|
||||
|
@@ -0,0 +1,22 @@
|
||||
<button
|
||||
type="button"
|
||||
class="btn {{buttonClasses}}"
|
||||
(click)="onClick($event)"
|
||||
[disabled]="disabled"
|
||||
[ngbPopover]="popoverContent"
|
||||
[autoClose]="true"
|
||||
(hidden)="confirming = false"
|
||||
#popover="ngbPopover"
|
||||
popoverClass="popover-slim"
|
||||
>
|
||||
@if (iconName) {
|
||||
<i-bs [class.me-1]="label" name="{{iconName}}"></i-bs>
|
||||
}
|
||||
<ng-container>{{label}}</ng-container>
|
||||
</button>
|
||||
|
||||
<ng-template #popoverContent>
|
||||
<div>
|
||||
{{confirmMessage}} <button class="btn btn-link btn-sm text-danger p-0 m-0 lh-1" type="button" (click)="onConfirm($event)">Yes</button>
|
||||
</div>
|
||||
</ng-template>
|
@@ -0,0 +1,12 @@
|
||||
// Taken from bootstrap rules, obv
|
||||
::ng-deep .input-group > pngx-confirm-button:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback) > button,
|
||||
::ng-deep .btn-group > pngx-confirm-button:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback) > button {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
::ng-deep .input-group:not(.has-validation) > pngx-confirm-button:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating) > button,
|
||||
::ng-deep .btn-group:not(.has-validation) > pngx-confirm-button:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating) > button {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
@@ -0,0 +1,37 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
|
||||
import { ConfirmButtonComponent } from './confirm-button.component'
|
||||
import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
|
||||
describe('ConfirmButtonComponent', () => {
|
||||
let component: ConfirmButtonComponent
|
||||
let fixture: ComponentFixture<ConfirmButtonComponent>
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ConfirmButtonComponent],
|
||||
imports: [NgbPopoverModule, NgxBootstrapIconsModule.pick(allIcons)],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(ConfirmButtonComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should show confirm on click', () => {
|
||||
expect(component.popover.isOpen()).toBeFalsy()
|
||||
expect(component.confirming).toBeFalsy()
|
||||
component.onClick(new MouseEvent('click'))
|
||||
expect(component.popover.isOpen()).toBeTruthy()
|
||||
expect(component.confirming).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should emit confirm on confirm', () => {
|
||||
const confirmSpy = jest.spyOn(component.confirm, 'emit')
|
||||
component.onConfirm(new MouseEvent('click'))
|
||||
expect(confirmSpy).toHaveBeenCalled()
|
||||
expect(component.popover.isOpen()).toBeFalsy()
|
||||
expect(component.confirming).toBeFalsy()
|
||||
})
|
||||
})
|
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
Output,
|
||||
ViewChild,
|
||||
} from '@angular/core'
|
||||
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-confirm-button',
|
||||
templateUrl: './confirm-button.component.html',
|
||||
styleUrl: './confirm-button.component.scss',
|
||||
})
|
||||
export class ConfirmButtonComponent {
|
||||
@Input()
|
||||
label: string
|
||||
|
||||
@Input()
|
||||
confirmMessage: string = $localize`Are you sure?`
|
||||
|
||||
@Input()
|
||||
buttonClasses: string = 'btn-primary'
|
||||
|
||||
@Input()
|
||||
iconName: string
|
||||
|
||||
@Input()
|
||||
disabled: boolean = false
|
||||
|
||||
@Output()
|
||||
confirm: EventEmitter<void> = new EventEmitter<void>()
|
||||
|
||||
@ViewChild('popover') popover: NgbPopover
|
||||
|
||||
public confirming: boolean = false
|
||||
|
||||
public onClick(event: MouseEvent) {
|
||||
if (!this.confirming) {
|
||||
this.confirming = true
|
||||
this.popover.open()
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
|
||||
public onConfirm(event: MouseEvent) {
|
||||
this.confirm.emit()
|
||||
this.confirming = false
|
||||
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
}
|
@@ -38,9 +38,13 @@
|
||||
@if(trigger.id > -1) {
|
||||
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{trigger.id}}</span>
|
||||
}
|
||||
<button type="button" class="btn btn-link text-danger ms-2" (click)="removeTrigger(i)">
|
||||
<i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
<pngx-confirm-button
|
||||
label="Delete"
|
||||
i18n-label
|
||||
(confirm)="removeTrigger(i)"
|
||||
buttonClasses="btn-link text-danger ms-2"
|
||||
iconName="trash">
|
||||
</pngx-confirm-button>
|
||||
</button>
|
||||
</div>
|
||||
<div ngbAccordionCollapse>
|
||||
@@ -76,9 +80,13 @@
|
||||
@if(action.id > -1) {
|
||||
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{action.id}}</span>
|
||||
}
|
||||
<button type="button" class="btn btn-link text-danger ms-2" (click)="removeAction(i)">
|
||||
<i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
<pngx-confirm-button
|
||||
label="Delete"
|
||||
i18n-label
|
||||
(confirm)="removeAction(i)"
|
||||
buttonClasses="btn-link text-danger ms-2"
|
||||
iconName="trash">
|
||||
</pngx-confirm-button>
|
||||
</button>
|
||||
</div>
|
||||
<div ngbAccordionCollapse>
|
||||
|
@@ -38,6 +38,7 @@ import {
|
||||
WorkflowActionType,
|
||||
} from 'src/app/data/workflow-action'
|
||||
import { MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/matching-model'
|
||||
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
|
||||
|
||||
const workflow: Workflow = {
|
||||
name: 'Workflow 1',
|
||||
@@ -85,6 +86,7 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
PermissionsUserComponent,
|
||||
PermissionsGroupComponent,
|
||||
SafeHtmlPipe,
|
||||
ConfirmButtonComponent,
|
||||
],
|
||||
providers: [
|
||||
NgbActiveModal,
|
||||
|
@@ -45,10 +45,18 @@
|
||||
</div>
|
||||
}
|
||||
@if (editing) {
|
||||
<button class="list-group-item list-group-item-action bg-light" (click)="applyClicked()" [disabled]="!modelIsDirty || disabled">
|
||||
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty}" i18n>Apply</small>
|
||||
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
|
||||
</button>
|
||||
@if ((selectionModel.itemsSorted | filter: filterText).length === 0 && createRef !== undefined) {
|
||||
<button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled">
|
||||
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small>
|
||||
<i-bs width="1.5em" height="1em" name="plus"></i-bs>
|
||||
</button>
|
||||
}
|
||||
@if ((selectionModel.itemsSorted | filter: filterText).length > 0) {
|
||||
<button class="list-group-item list-group-item-action bg-light" (click)="applyClicked()" [disabled]="!modelIsDirty || disabled">
|
||||
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty}" i18n>Apply</small>
|
||||
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
@if (!editing && manyToOne) {
|
||||
<div class="list-group-item list-group-item-note pt-1 pb-2">
|
||||
|
@@ -500,4 +500,46 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
selectionModel.apply()
|
||||
expect(selectionModel.itemsSorted).toEqual([nullItem, items[1], items[0]])
|
||||
})
|
||||
|
||||
it('should set support create, keep open model and call createRef method', fakeAsync(() => {
|
||||
component.items = items
|
||||
component.icon = 'tag-fill'
|
||||
component.selectionModel = selectionModel
|
||||
fixture.nativeElement
|
||||
.querySelector('button')
|
||||
.dispatchEvent(new MouseEvent('click')) // open
|
||||
fixture.detectChanges()
|
||||
tick(100)
|
||||
|
||||
component.filterText = 'Test Filter Text'
|
||||
component.createRef = jest.fn()
|
||||
component.createClicked()
|
||||
expect(component.creating).toBeTruthy()
|
||||
expect(component.createRef).toHaveBeenCalledWith('Test Filter Text')
|
||||
const openSpy = jest.spyOn(component.dropdown, 'open')
|
||||
component.dropdownOpenChange(false)
|
||||
expect(openSpy).toHaveBeenCalled() // should keep open
|
||||
}))
|
||||
|
||||
it('should call create on enter inside filter field if 0 items remain while editing', fakeAsync(() => {
|
||||
component.items = items
|
||||
component.icon = 'tag-fill'
|
||||
component.editing = true
|
||||
component.createRef = jest.fn()
|
||||
const createSpy = jest.spyOn(component, 'createClicked')
|
||||
expect(component.selectionModel.getSelectedItems()).toEqual([])
|
||||
fixture.nativeElement
|
||||
.querySelector('button')
|
||||
.dispatchEvent(new MouseEvent('click')) // open
|
||||
fixture.detectChanges()
|
||||
tick(100)
|
||||
component.filterText = 'FooBar'
|
||||
fixture.detectChanges()
|
||||
component.listFilterTextInput.nativeElement.dispatchEvent(
|
||||
new KeyboardEvent('keyup', { key: 'Enter' })
|
||||
)
|
||||
expect(component.selectionModel.getSelectedItems()).toEqual([])
|
||||
tick(300)
|
||||
expect(createSpy).toHaveBeenCalled()
|
||||
}))
|
||||
})
|
||||
|
@@ -398,6 +398,11 @@ export class FilterableDropdownComponent {
|
||||
@Input()
|
||||
disabled = false
|
||||
|
||||
@Input()
|
||||
createRef: (name) => void
|
||||
|
||||
creating: boolean = false
|
||||
|
||||
@Output()
|
||||
apply = new EventEmitter<ChangedItems>()
|
||||
|
||||
@@ -437,6 +442,11 @@ export class FilterableDropdownComponent {
|
||||
}
|
||||
}
|
||||
|
||||
createClicked() {
|
||||
this.creating = true
|
||||
this.createRef(this.filterText)
|
||||
}
|
||||
|
||||
dropdownOpenChange(open: boolean): void {
|
||||
if (open) {
|
||||
setTimeout(() => {
|
||||
@@ -448,9 +458,14 @@ export class FilterableDropdownComponent {
|
||||
}
|
||||
this.opened.next(this)
|
||||
} else {
|
||||
this.filterText = ''
|
||||
if (this.applyOnClose && this.selectionModel.isDirty()) {
|
||||
this.apply.emit(this.selectionModel.diff())
|
||||
if (this.creating) {
|
||||
this.dropdown.open()
|
||||
this.creating = false
|
||||
} else {
|
||||
this.filterText = ''
|
||||
if (this.applyOnClose && this.selectionModel.isDirty()) {
|
||||
this.apply.emit(this.selectionModel.diff())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -466,6 +481,8 @@ export class FilterableDropdownComponent {
|
||||
this.dropdown.close()
|
||||
}
|
||||
}, 200)
|
||||
} else if (filtered.length == 0 && this.createRef) {
|
||||
this.createClicked()
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -47,22 +47,25 @@ describe('NumberComponent', () => {
|
||||
expect(component.value).toEqual(1002)
|
||||
})
|
||||
|
||||
it('should support float & monetary values', () => {
|
||||
component.writeValue(11.13)
|
||||
expect(component.value).toEqual(11)
|
||||
it('should support float, monetary values & scientific notation', () => {
|
||||
const mockFn = jest.fn()
|
||||
component.registerOnChange(mockFn)
|
||||
|
||||
component.step = 1
|
||||
component.onChange(11.13)
|
||||
expect(mockFn).toHaveBeenCalledWith(11)
|
||||
|
||||
component.onChange(1.23456789e8)
|
||||
expect(mockFn).toHaveBeenCalledWith(123456789)
|
||||
|
||||
component.step = 0.01
|
||||
component.onChange(11.1)
|
||||
expect(mockFn).toHaveBeenCalledWith('11.10')
|
||||
})
|
||||
|
||||
it('should display monetary values fixed to 2 decimals', () => {
|
||||
component.step = 0.01
|
||||
component.writeValue(11.1)
|
||||
expect(component.value).toEqual('11.10')
|
||||
component.step = 0.1
|
||||
component.writeValue(12.3456)
|
||||
expect(component.value).toEqual(12.3456)
|
||||
// float (step = .1) doesn't force 2 decimals
|
||||
component.writeValue(11.1)
|
||||
expect(component.value).toEqual(11.1)
|
||||
})
|
||||
|
||||
it('should support scientific notation', () => {
|
||||
component.writeValue(1.23456789e8)
|
||||
expect(component.value).toEqual(123456789)
|
||||
})
|
||||
})
|
||||
|
@@ -36,9 +36,18 @@ export class NumberComponent extends AbstractInputComponent<number> {
|
||||
})
|
||||
}
|
||||
|
||||
registerOnChange(fn: any): void {
|
||||
this.onChange = (newValue: any) => {
|
||||
// number validation
|
||||
if (this.step === 1 && newValue?.toString().indexOf('e') === -1)
|
||||
newValue = parseInt(newValue, 10)
|
||||
if (this.step === 0.01) newValue = parseFloat(newValue).toFixed(2)
|
||||
fn(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
writeValue(newValue: any): void {
|
||||
if (this.step === 1 && newValue?.toString().indexOf('e') === -1)
|
||||
newValue = parseInt(newValue, 10)
|
||||
// Allow monetary values to be displayed with 2 decimals
|
||||
if (this.step === 0.01) newValue = parseFloat(newValue).toFixed(2)
|
||||
super.writeValue(newValue)
|
||||
}
|
||||
|
@@ -15,7 +15,7 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div [ngClass]="{'col-md-9': horizontal, 'align-items-center': horizontal, 'd-flex': horizontal}">
|
||||
<div [ngClass]="{'align-items-center': horizontal, 'd-flex': horizontal}">
|
||||
<div class="form-check form-switch">
|
||||
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
|
||||
@if (horizontal) {
|
||||
|
@@ -5,12 +5,15 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
@if (!object && message) {
|
||||
<p class="mb-3" [innerHTML]="message | safeHtml"></p>
|
||||
}
|
||||
|
||||
<form [formGroup]="form">
|
||||
<pngx-permissions-form [users]="users" formControlName="permissions_form"></pngx-permissions-form>
|
||||
<div class="form-group">
|
||||
<pngx-permissions-form [users]="users" formControlName="permissions_form"></pngx-permissions-form>
|
||||
</div>
|
||||
<div class="form-group mt-4">
|
||||
<div class="offset-lg-3 row">
|
||||
<pngx-input-switch i18n-title title="Merge with existing permissions" [horizontal]="true" [hint]="hint" formControlName="merge"></pngx-input-switch>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
@@ -20,5 +23,5 @@
|
||||
<span class="visually-hidden" i18n>Loading...</span>
|
||||
}
|
||||
<button type="button" class="btn btn-outline-primary" (click)="cancelClicked()" [disabled]="!buttonsEnabled" i18n>Cancel</button>
|
||||
<button type="button" class="btn btn-primary" (click)="confirmClicked.emit(permissions)" [disabled]="!buttonsEnabled" i18n>Confirm</button>
|
||||
<button type="button" class="btn btn-primary" (click)="confirm()" [disabled]="!buttonsEnabled" i18n>Confirm</button>
|
||||
</div>
|
||||
|
@@ -11,6 +11,7 @@ import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { PermissionsUserComponent } from '../input/permissions/permissions-user/permissions-user.component'
|
||||
import { PermissionsGroupComponent } from '../input/permissions/permissions-group/permissions-group.component'
|
||||
import { SwitchComponent } from '../input/switch/switch.component'
|
||||
|
||||
const set_permissions = {
|
||||
owner: 10,
|
||||
@@ -37,6 +38,7 @@ describe('PermissionsDialogComponent', () => {
|
||||
PermissionsDialogComponent,
|
||||
SafeHtmlPipe,
|
||||
SelectComponent,
|
||||
SwitchComponent,
|
||||
PermissionsFormComponent,
|
||||
PermissionsUserComponent,
|
||||
PermissionsGroupComponent,
|
||||
@@ -112,4 +114,23 @@ describe('PermissionsDialogComponent', () => {
|
||||
expect(component.title).toEqual(`Edit permissions for ${obj.name}`)
|
||||
expect(component.permissions).toEqual(set_permissions)
|
||||
})
|
||||
|
||||
it('should toggle hint based on object existence (if editing) or merge flag', () => {
|
||||
component.form.get('merge').setValue(true)
|
||||
expect(component.hint.includes('Existing')).toBeTruthy()
|
||||
component.form.get('merge').setValue(false)
|
||||
expect(component.hint.includes('will be replaced')).toBeTruthy()
|
||||
component.object = {}
|
||||
expect(component.hint).toBeNull()
|
||||
})
|
||||
|
||||
it('should emit permissions and merge flag on confirm', () => {
|
||||
const confirmSpy = jest.spyOn(component.confirmClicked, 'emit')
|
||||
component.form.get('permissions_form').setValue(set_permissions)
|
||||
component.confirm()
|
||||
expect(confirmSpy).toHaveBeenCalledWith({
|
||||
permissions: set_permissions,
|
||||
merge: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@@ -32,6 +32,7 @@ export class PermissionsDialogComponent {
|
||||
this.o = o
|
||||
this.title = $localize`Edit permissions for ` + o['name']
|
||||
this.form.patchValue({
|
||||
merge: true,
|
||||
permissions_form: {
|
||||
owner: o.owner,
|
||||
set_permissions: o.permissions,
|
||||
@@ -43,8 +44,9 @@ export class PermissionsDialogComponent {
|
||||
return this.o
|
||||
}
|
||||
|
||||
form = new FormGroup({
|
||||
public form = new FormGroup({
|
||||
permissions_form: new FormControl(),
|
||||
merge: new FormControl(true),
|
||||
})
|
||||
|
||||
buttonsEnabled: boolean = true
|
||||
@@ -66,11 +68,21 @@ export class PermissionsDialogComponent {
|
||||
}
|
||||
}
|
||||
|
||||
@Input()
|
||||
message =
|
||||
$localize`Note that permissions set here will override any existing permissions`
|
||||
get hint(): string {
|
||||
if (this.object) return null
|
||||
return this.form.get('merge').value
|
||||
? $localize`Existing owner, user and group permissions will be merged with these settings.`
|
||||
: $localize`Any and all existing owner, user and group permissions will be replaced.`
|
||||
}
|
||||
|
||||
cancelClicked() {
|
||||
this.activeModal.close()
|
||||
}
|
||||
|
||||
confirm() {
|
||||
this.confirmClicked.emit({
|
||||
permissions: this.permissions,
|
||||
merge: this.form.get('merge').value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@@ -62,22 +62,24 @@
|
||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||
}
|
||||
</div>
|
||||
<div class="me-1 w-100">
|
||||
<ng-select
|
||||
name="user"
|
||||
class="user-select small"
|
||||
[(ngModel)]="selectionModel.includeUsers"
|
||||
[disabled]="disabled"
|
||||
[clearable]="false"
|
||||
[items]="users"
|
||||
bindLabel="username"
|
||||
multiple="true"
|
||||
bindValue="id"
|
||||
placeholder="Users"
|
||||
i18n-placeholder
|
||||
(change)="onUserSelect()">
|
||||
</ng-select>
|
||||
</div>
|
||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.User)) {
|
||||
<div class="me-1 w-100">
|
||||
<ng-select
|
||||
name="user"
|
||||
class="user-select small"
|
||||
[(ngModel)]="selectionModel.includeUsers"
|
||||
[disabled]="disabled"
|
||||
[clearable]="false"
|
||||
[items]="users"
|
||||
bindLabel="username"
|
||||
multiple="true"
|
||||
bindValue="id"
|
||||
placeholder="Users"
|
||||
i18n-placeholder
|
||||
(change)="onUserSelect()">
|
||||
</ng-select>
|
||||
</div>
|
||||
}
|
||||
</button>
|
||||
@if (selectionModel.ownerFilter === OwnerFilterType.NONE || selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
|
||||
<div class="list-group-item list-group-item-action d-flex align-items-center p-2 ps-3 border-bottom-0 border-start-0 border-end-0">
|
||||
|
@@ -67,7 +67,7 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
||||
}
|
||||
|
||||
constructor(
|
||||
permissionsService: PermissionsService,
|
||||
public permissionsService: PermissionsService,
|
||||
userService: UserService,
|
||||
private settingsService: SettingsService
|
||||
) {
|
||||
|
@@ -41,14 +41,58 @@
|
||||
}
|
||||
<span class="visually-hidden" i18n>Copy</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="generateAuthToken()" i18n-title title="Regenerate auth token">
|
||||
<i-bs width="1.2em" height="1.2em" name="arrow-repeat"></i-bs>
|
||||
</button>
|
||||
<pngx-confirm-button
|
||||
title="Regenerate auth token"
|
||||
i18n-title
|
||||
buttonClasses=" btn-outline-secondary"
|
||||
iconName="arrow-repeat"
|
||||
[disabled]="!hasUsablePassword"
|
||||
(confirm)="generateAuthToken()">
|
||||
</pngx-confirm-button>
|
||||
</div>
|
||||
<span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied" i18n>Copied!</span>
|
||||
</div>
|
||||
<div class="form-text text-muted text-end fst-italic" i18n>Warning: changing the token cannot be undone</div>
|
||||
</div>
|
||||
@if (socialAccounts?.length > 0) {
|
||||
<div class="mb-3">
|
||||
<p i18n>Connected social accounts</p>
|
||||
<ul class="list-group">
|
||||
@for (account of socialAccounts; track account.id) {
|
||||
<li class="list-group-item"
|
||||
ngbPopover="Set a password before disconnecting social account."
|
||||
i18n-ngbPopover
|
||||
[disablePopover]="hasUsablePassword"
|
||||
triggers="mouseenter:mouseleave">
|
||||
{{account.name}} ({{account.provider}})
|
||||
<pngx-confirm-button
|
||||
label="Disconnect"
|
||||
i18n-label
|
||||
title="Disconnect {{ account.name }} social account"
|
||||
i18n-title
|
||||
buttonClasses="btn-outline-danger btn-sm ms-2 align-baseline"
|
||||
iconName="trash"
|
||||
[disabled]="!hasUsablePassword"
|
||||
(confirm)="disconnectSocialAccount(account.id)">
|
||||
</pngx-confirm-button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<div class="form-text text-muted text-end fst-italic" i18n>Warning: disconnecting social accounts cannot be undone</div>
|
||||
</div>
|
||||
}
|
||||
@if (socialAccountProviders?.length > 0) {
|
||||
<div class="mb-3">
|
||||
<p i18n>Connect new social account</p>
|
||||
<div class="list-group">
|
||||
@for (provider of socialAccountProviders; track provider.name) {
|
||||
<a class="list-group-item list-group-item-action text-primary d-flex align-items-center" href="{{ provider.login_url }}" rel="noopener noreferrer">
|
||||
{{provider.name}} <i-bs class="pb-1 ps-1" name="box-arrow-up-right"></i-bs>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
|
||||
|
@@ -12,6 +12,7 @@ import {
|
||||
NgbAccordionModule,
|
||||
NgbActiveModal,
|
||||
NgbModalModule,
|
||||
NgbPopoverModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { HttpClientModule } from '@angular/common/http'
|
||||
import { TextComponent } from '../input/text/text.component'
|
||||
@@ -20,14 +21,24 @@ import { of, throwError } from 'rxjs'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { Clipboard } from '@angular/cdk/clipboard'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { ConfirmButtonComponent } from '../confirm-button/confirm-button.component'
|
||||
|
||||
const socialAccount = {
|
||||
id: 1,
|
||||
provider: 'test_provider',
|
||||
name: 'Test Provider',
|
||||
}
|
||||
const profile = {
|
||||
email: 'foo@bar.com',
|
||||
password: '*********',
|
||||
first_name: 'foo',
|
||||
last_name: 'bar',
|
||||
auth_token: '123456789abcdef',
|
||||
social_accounts: [socialAccount],
|
||||
}
|
||||
const socialAccountProviders = [
|
||||
{ name: 'Test Provider', login_url: 'https://example.com' },
|
||||
]
|
||||
|
||||
describe('ProfileEditDialogComponent', () => {
|
||||
let component: ProfileEditDialogComponent
|
||||
@@ -42,6 +53,7 @@ describe('ProfileEditDialogComponent', () => {
|
||||
ProfileEditDialogComponent,
|
||||
TextComponent,
|
||||
PasswordComponent,
|
||||
ConfirmButtonComponent,
|
||||
],
|
||||
providers: [NgbActiveModal],
|
||||
imports: [
|
||||
@@ -51,6 +63,7 @@ describe('ProfileEditDialogComponent', () => {
|
||||
NgbModalModule,
|
||||
NgbAccordionModule,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
NgbPopoverModule,
|
||||
],
|
||||
})
|
||||
profileService = TestBed.inject(ProfileService)
|
||||
@@ -64,6 +77,11 @@ describe('ProfileEditDialogComponent', () => {
|
||||
it('should get profile on init, display in form', () => {
|
||||
const getSpy = jest.spyOn(profileService, 'get')
|
||||
getSpy.mockReturnValue(of(profile))
|
||||
const getProvidersSpy = jest.spyOn(
|
||||
profileService,
|
||||
'getSocialAccountProviders'
|
||||
)
|
||||
getProvidersSpy.mockReturnValue(of(socialAccountProviders))
|
||||
component.ngOnInit()
|
||||
expect(getSpy).toHaveBeenCalled()
|
||||
fixture.detectChanges()
|
||||
@@ -103,6 +121,11 @@ describe('ProfileEditDialogComponent', () => {
|
||||
expect(component.form.get('email_confirm').enabled).toBeFalsy()
|
||||
const getSpy = jest.spyOn(profileService, 'get')
|
||||
getSpy.mockReturnValue(of(profile))
|
||||
const getProvidersSpy = jest.spyOn(
|
||||
profileService,
|
||||
'getSocialAccountProviders'
|
||||
)
|
||||
getProvidersSpy.mockReturnValue(of(socialAccountProviders))
|
||||
component.ngOnInit()
|
||||
component.form.get('email').patchValue('foo@bar2.com')
|
||||
component.onEmailKeyUp({ target: { value: 'foo@bar2.com' } } as any)
|
||||
@@ -134,6 +157,12 @@ describe('ProfileEditDialogComponent', () => {
|
||||
expect(component.form.get('password_confirm').enabled).toBeFalsy()
|
||||
const getSpy = jest.spyOn(profileService, 'get')
|
||||
getSpy.mockReturnValue(of(profile))
|
||||
const getProvidersSpy = jest.spyOn(
|
||||
profileService,
|
||||
'getSocialAccountProviders'
|
||||
)
|
||||
getProvidersSpy.mockReturnValue(of(socialAccountProviders))
|
||||
component.hasUsablePassword = true
|
||||
component.ngOnInit()
|
||||
component.form.get('password').patchValue('new*pass')
|
||||
component.onPasswordKeyUp({
|
||||
@@ -167,6 +196,11 @@ describe('ProfileEditDialogComponent', () => {
|
||||
it('should logout on save if password changed', fakeAsync(() => {
|
||||
const getSpy = jest.spyOn(profileService, 'get')
|
||||
getSpy.mockReturnValue(of(profile))
|
||||
const getProvidersSpy = jest.spyOn(
|
||||
profileService,
|
||||
'getSocialAccountProviders'
|
||||
)
|
||||
getProvidersSpy.mockReturnValue(of(socialAccountProviders))
|
||||
component.ngOnInit()
|
||||
component['newPassword'] = 'new*pass'
|
||||
component.form.get('password').patchValue('new*pass')
|
||||
@@ -189,6 +223,11 @@ describe('ProfileEditDialogComponent', () => {
|
||||
it('should support auth token copy', fakeAsync(() => {
|
||||
const getSpy = jest.spyOn(profileService, 'get')
|
||||
getSpy.mockReturnValue(of(profile))
|
||||
const getProvidersSpy = jest.spyOn(
|
||||
profileService,
|
||||
'getSocialAccountProviders'
|
||||
)
|
||||
getProvidersSpy.mockReturnValue(of(socialAccountProviders))
|
||||
component.ngOnInit()
|
||||
const copySpy = jest.spyOn(clipboard, 'copy')
|
||||
component.copyAuthToken()
|
||||
@@ -220,4 +259,40 @@ describe('ProfileEditDialogComponent', () => {
|
||||
)
|
||||
expect(component.form.get('auth_token').value).toEqual(newToken)
|
||||
})
|
||||
|
||||
it('should get social account providers on init', () => {
|
||||
const getSpy = jest.spyOn(profileService, 'get')
|
||||
getSpy.mockReturnValue(of(profile))
|
||||
const getProvidersSpy = jest.spyOn(
|
||||
profileService,
|
||||
'getSocialAccountProviders'
|
||||
)
|
||||
getProvidersSpy.mockReturnValue(of(socialAccountProviders))
|
||||
component.ngOnInit()
|
||||
expect(getProvidersSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should remove disconnected social account from component, show error if needed', () => {
|
||||
const disconnectSpy = jest.spyOn(profileService, 'disconnectSocialAccount')
|
||||
const getSpy = jest.spyOn(profileService, 'get')
|
||||
getSpy.mockImplementation(() => of(profile))
|
||||
component.ngOnInit()
|
||||
|
||||
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||
|
||||
expect(component.socialAccounts).toContainEqual(socialAccount)
|
||||
|
||||
// fail first
|
||||
disconnectSpy.mockReturnValueOnce(
|
||||
throwError(() => new Error('unable to disconnect'))
|
||||
)
|
||||
component.disconnectSocialAccount(socialAccount.id)
|
||||
expect(errorSpy).toHaveBeenCalled()
|
||||
|
||||
// succeed
|
||||
disconnectSpy.mockReturnValue(of(socialAccount.id))
|
||||
component.disconnectSocialAccount(socialAccount.id)
|
||||
expect(disconnectSpy).toHaveBeenCalled()
|
||||
expect(component.socialAccounts).not.toContainEqual(socialAccount)
|
||||
})
|
||||
})
|
||||
|
@@ -2,6 +2,7 @@ import { Component, OnDestroy, OnInit } from '@angular/core'
|
||||
import { FormControl, FormGroup } from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { ProfileService } from 'src/app/services/profile.service'
|
||||
import { SocialAccount, SocialAccountProvider } from 'src/app/data/user-profile'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { Subject, takeUntil } from 'rxjs'
|
||||
import { Clipboard } from '@angular/cdk/clipboard'
|
||||
@@ -30,6 +31,7 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
|
||||
private newPassword: string
|
||||
private passwordConfirm: string
|
||||
public showPasswordConfirm: boolean = false
|
||||
public hasUsablePassword: boolean = false
|
||||
|
||||
private currentEmail: string
|
||||
private newEmail: string
|
||||
@@ -38,6 +40,9 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
|
||||
|
||||
public copied: boolean = false
|
||||
|
||||
public socialAccounts: SocialAccount[] = []
|
||||
public socialAccountProviders: SocialAccountProvider[] = []
|
||||
|
||||
constructor(
|
||||
private profileService: ProfileService,
|
||||
public activeModal: NgbActiveModal,
|
||||
@@ -59,10 +64,19 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
|
||||
this.onEmailChange()
|
||||
})
|
||||
this.currentPassword = profile.password
|
||||
this.hasUsablePassword = profile.has_usable_password
|
||||
this.form.get('password').valueChanges.subscribe((newPassword) => {
|
||||
this.newPassword = newPassword
|
||||
this.onPasswordChange()
|
||||
})
|
||||
this.socialAccounts = profile.social_accounts
|
||||
})
|
||||
|
||||
this.profileService
|
||||
.getSocialAccountProviders()
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((providers) => {
|
||||
this.socialAccountProviders = providers
|
||||
})
|
||||
}
|
||||
|
||||
@@ -182,4 +196,21 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
|
||||
this.copied = false
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
disconnectSocialAccount(id: number): void {
|
||||
this.profileService
|
||||
.disconnectSocialAccount(id)
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: (id: number) => {
|
||||
this.socialAccounts = this.socialAccounts.filter((a) => a.id != id)
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error disconnecting social account`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@@ -15,8 +15,14 @@
|
||||
<tr>
|
||||
<th scope="col" i18n>Created</th>
|
||||
<th scope="col" i18n>Title</th>
|
||||
<th scope="col" class="d-none d-md-table-cell" i18n>Tags</th>
|
||||
<th scope="col" class="d-none d-md-table-cell" i18n>Correspondent</th>
|
||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
|
||||
<th scope="col" class="d-none d-md-table-cell" i18n>Tags</th>
|
||||
}
|
||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
|
||||
<th scope="col" class="d-none d-md-table-cell" i18n>Correspondent</th>
|
||||
} @else {
|
||||
<th scope="col" class="d-none d-md-table-cell"></th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -26,13 +32,15 @@
|
||||
<td class="py-2 py-md-3">
|
||||
<a routerLink="/documents/{{doc.id}}" title="Edit" i18n-title class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.title | documentTitle}}</a>
|
||||
</td>
|
||||
<td class="py-2 py-md-3 d-none d-md-table-cell">
|
||||
@for (t of doc.tags$ | async; track t) {
|
||||
<pngx-tag [tag]="t" class="ms-1" (click)="clickTag(t, $event)"></pngx-tag>
|
||||
}
|
||||
</td>
|
||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
|
||||
<td class="py-2 py-md-3 d-none d-md-table-cell">
|
||||
@for (t of doc.tags$ | async; track t) {
|
||||
<pngx-tag [tag]="t" class="ms-1" (click)="clickTag(t, $event)"></pngx-tag>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
<td class="position-relative py-2 py-md-3 d-none d-md-table-cell">
|
||||
@if (doc.correspondent !== null) {
|
||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Correspondent) && doc.correspondent !== null) {
|
||||
<a class="btn-link text-dark text-decoration-none py-2 py-md-3" routerLink="/documents" [queryParams]="getCorrespondentQueryParams(doc.correspondent)">{{(doc.correspondent$ | async)?.name}}</a>
|
||||
}
|
||||
<div class="btn-group position-absolute top-50 end-0 translate-middle-y">
|
||||
|
@@ -22,6 +22,7 @@ import { DocumentListViewService } from 'src/app/services/document-list-view.ser
|
||||
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
|
||||
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-saved-view-widget',
|
||||
@@ -40,7 +41,8 @@ export class SavedViewWidgetComponent
|
||||
private list: DocumentListViewService,
|
||||
private consumerStatusService: ConsumerStatusService,
|
||||
public openDocumentsService: OpenDocumentsService,
|
||||
public documentListViewService: DocumentListViewService
|
||||
public documentListViewService: DocumentListViewService,
|
||||
public permissionsService: PermissionsService
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
@@ -6,8 +6,8 @@
|
||||
<input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload>
|
||||
</form>
|
||||
@if (getStatus().length > 0) {
|
||||
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-md-3 offset-lg-2'">
|
||||
<div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0">
|
||||
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-md-3 offset-lg-2'">
|
||||
<div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto">
|
||||
<div class="card shadow-sm consumer-status-card">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
|
@@ -1,5 +1,8 @@
|
||||
import { DatePipe } from '@angular/common'
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||
import {
|
||||
HttpClientTestingModule,
|
||||
HttpTestingController,
|
||||
} from '@angular/common/http/testing'
|
||||
import {
|
||||
ComponentFixture,
|
||||
TestBed,
|
||||
@@ -71,6 +74,7 @@ import { CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { PdfViewerComponent } from '../common/pdf-viewer/pdf-viewer.component'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { environment } from 'src/environments/environment'
|
||||
|
||||
const doc: Document = {
|
||||
id: 3,
|
||||
@@ -91,12 +95,12 @@ const doc: Document = {
|
||||
{
|
||||
created: new Date(),
|
||||
note: 'note 1',
|
||||
user: 1,
|
||||
user: { id: 1, username: 'user1' },
|
||||
},
|
||||
{
|
||||
created: new Date(),
|
||||
note: 'note 2',
|
||||
user: 2,
|
||||
user: { id: 2, username: 'user2' },
|
||||
},
|
||||
],
|
||||
custom_fields: [
|
||||
@@ -136,6 +140,7 @@ describe('DocumentDetailComponent', () => {
|
||||
let documentListViewService: DocumentListViewService
|
||||
let settingsService: SettingsService
|
||||
let customFieldsService: CustomFieldsService
|
||||
let httpTestingController: HttpTestingController
|
||||
|
||||
let currentUserCan = true
|
||||
let currentUserHasObjectPermissions = true
|
||||
@@ -266,6 +271,7 @@ describe('DocumentDetailComponent', () => {
|
||||
settingsService.currentUser = { id: 1 }
|
||||
customFieldsService = TestBed.inject(CustomFieldsService)
|
||||
fixture = TestBed.createComponent(DocumentDetailComponent)
|
||||
httpTestingController = TestBed.inject(HttpTestingController)
|
||||
component = fixture.componentInstance
|
||||
})
|
||||
|
||||
@@ -350,6 +356,26 @@ describe('DocumentDetailComponent', () => {
|
||||
expect(component.documentForm.disabled).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should not attempt to retrieve objects if user does not have permissions', () => {
|
||||
currentUserCan = false
|
||||
initNormally()
|
||||
expect(component.correspondents).toBeUndefined()
|
||||
expect(component.documentTypes).toBeUndefined()
|
||||
expect(component.storagePaths).toBeUndefined()
|
||||
expect(component.users).toBeUndefined()
|
||||
httpTestingController.expectNone(`${environment.apiBaseUrl}documents/tags/`)
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/correspondents/`
|
||||
)
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/document_types/`
|
||||
)
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/storage_paths/`
|
||||
)
|
||||
currentUserCan = true
|
||||
})
|
||||
|
||||
it('should support creating document type', () => {
|
||||
initNormally()
|
||||
let openModal: NgbModalRef
|
||||
|
@@ -250,25 +250,50 @@ export class DocumentDetailComponent
|
||||
Object.assign(this.document, docValues)
|
||||
})
|
||||
|
||||
this.correspondentService
|
||||
.listAll()
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((result) => (this.correspondents = result.results))
|
||||
|
||||
this.documentTypeService
|
||||
.listAll()
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((result) => (this.documentTypes = result.results))
|
||||
|
||||
this.storagePathService
|
||||
.listAll()
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((result) => (this.storagePaths = result.results))
|
||||
|
||||
this.userService
|
||||
.listAll()
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((result) => (this.users = result.results))
|
||||
if (
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.Correspondent
|
||||
)
|
||||
) {
|
||||
this.correspondentService
|
||||
.listAll()
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((result) => (this.correspondents = result.results))
|
||||
}
|
||||
if (
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.DocumentType
|
||||
)
|
||||
) {
|
||||
this.documentTypeService
|
||||
.listAll()
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((result) => (this.documentTypes = result.results))
|
||||
}
|
||||
if (
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.StoragePath
|
||||
)
|
||||
) {
|
||||
this.storagePathService
|
||||
.listAll()
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((result) => (this.storagePaths = result.results))
|
||||
}
|
||||
if (
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.User
|
||||
)
|
||||
) {
|
||||
this.userService
|
||||
.listAll()
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((result) => (this.users = result.results))
|
||||
}
|
||||
|
||||
this.getCustomFields()
|
||||
|
||||
@@ -462,7 +487,7 @@ export class DocumentDetailComponent
|
||||
this.metadata = result
|
||||
},
|
||||
error: (error) => {
|
||||
this.metadata = null
|
||||
this.metadata = {} // allow display to fallback to <object> tag
|
||||
this.toastService.showError(
|
||||
$localize`Error retrieving metadata`,
|
||||
error
|
||||
@@ -605,7 +630,9 @@ export class DocumentDetailComponent
|
||||
.update(this.document)
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: () => {
|
||||
next: (docValues) => {
|
||||
// in case data changed while saving eg removing inbox_tags
|
||||
this.documentForm.patchValue(docValues)
|
||||
this.store.next(this.documentForm.value)
|
||||
this.toastService.showInfo($localize`Document saved successfully.`)
|
||||
close && this.close()
|
||||
|
@@ -17,51 +17,63 @@
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
||||
<label class="me-2" i18n>Edit:</label>
|
||||
<pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
|
||||
filterPlaceholder="Filter tags" i18n-filterPlaceholder
|
||||
[items]="tags"
|
||||
[disabled]="!userCanEditAll"
|
||||
[editing]="true"
|
||||
[manyToOne]="true"
|
||||
[applyOnClose]="applyOnClose"
|
||||
(opened)="openTagsDropdown()"
|
||||
[(selectionModel)]="tagSelectionModel"
|
||||
[documentCounts]="tagDocumentCounts"
|
||||
(apply)="setTags($event)">
|
||||
</pngx-filterable-dropdown>
|
||||
<pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title
|
||||
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
|
||||
[items]="correspondents"
|
||||
[disabled]="!userCanEditAll"
|
||||
[editing]="true"
|
||||
[applyOnClose]="applyOnClose"
|
||||
(opened)="openCorrespondentDropdown()"
|
||||
[(selectionModel)]="correspondentSelectionModel"
|
||||
[documentCounts]="correspondentDocumentCounts"
|
||||
(apply)="setCorrespondents($event)">
|
||||
</pngx-filterable-dropdown>
|
||||
<pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title
|
||||
filterPlaceholder="Filter document types" i18n-filterPlaceholder
|
||||
[items]="documentTypes"
|
||||
[disabled]="!userCanEditAll"
|
||||
[editing]="true"
|
||||
[applyOnClose]="applyOnClose"
|
||||
(opened)="openDocumentTypeDropdown()"
|
||||
[(selectionModel)]="documentTypeSelectionModel"
|
||||
[documentCounts]="documentTypeDocumentCounts"
|
||||
(apply)="setDocumentTypes($event)">
|
||||
</pngx-filterable-dropdown>
|
||||
<pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title
|
||||
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
|
||||
[items]="storagePaths"
|
||||
[disabled]="!userCanEditAll"
|
||||
[editing]="true"
|
||||
[applyOnClose]="applyOnClose"
|
||||
(opened)="openStoragePathDropdown()"
|
||||
[(selectionModel)]="storagePathsSelectionModel"
|
||||
[documentCounts]="storagePathDocumentCounts"
|
||||
(apply)="setStoragePaths($event)">
|
||||
</pngx-filterable-dropdown>
|
||||
@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"
|
||||
[editing]="true"
|
||||
[manyToOne]="true"
|
||||
[applyOnClose]="applyOnClose"
|
||||
[createRef]="createTag.bind(this)"
|
||||
(opened)="openTagsDropdown()"
|
||||
[(selectionModel)]="tagSelectionModel"
|
||||
[documentCounts]="tagDocumentCounts"
|
||||
(apply)="setTags($event)">
|
||||
</pngx-filterable-dropdown>
|
||||
}
|
||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
|
||||
<pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title
|
||||
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
|
||||
[items]="correspondents"
|
||||
[disabled]="!userCanEditAll"
|
||||
[editing]="true"
|
||||
[applyOnClose]="applyOnClose"
|
||||
[createRef]="createCorrespondent.bind(this)"
|
||||
(opened)="openCorrespondentDropdown()"
|
||||
[(selectionModel)]="correspondentSelectionModel"
|
||||
[documentCounts]="correspondentDocumentCounts"
|
||||
(apply)="setCorrespondents($event)">
|
||||
</pngx-filterable-dropdown>
|
||||
}
|
||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
|
||||
<pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title
|
||||
filterPlaceholder="Filter document types" i18n-filterPlaceholder
|
||||
[items]="documentTypes"
|
||||
[disabled]="!userCanEditAll"
|
||||
[editing]="true"
|
||||
[applyOnClose]="applyOnClose"
|
||||
[createRef]="createDocumentType.bind(this)"
|
||||
(opened)="openDocumentTypeDropdown()"
|
||||
[(selectionModel)]="documentTypeSelectionModel"
|
||||
[documentCounts]="documentTypeDocumentCounts"
|
||||
(apply)="setDocumentTypes($event)">
|
||||
</pngx-filterable-dropdown>
|
||||
}
|
||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
|
||||
<pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title
|
||||
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
|
||||
[items]="storagePaths"
|
||||
[disabled]="!userCanEditAll"
|
||||
[editing]="true"
|
||||
[applyOnClose]="applyOnClose"
|
||||
[createRef]="createStoragePath.bind(this)"
|
||||
(opened)="openStoragePathDropdown()"
|
||||
[(selectionModel)]="storagePathsSelectionModel"
|
||||
[documentCounts]="storagePathDocumentCounts"
|
||||
(apply)="setStoragePaths($event)">
|
||||
</pngx-filterable-dropdown>
|
||||
}
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 ms-auto">
|
||||
<div class="btn-toolbar">
|
||||
|
@@ -41,6 +41,17 @@ import { PermissionsUserComponent } from '../../common/input/permissions/permiss
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { GroupService } from 'src/app/services/rest/group.service'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { SwitchComponent } from '../../common/input/switch/switch.component'
|
||||
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
|
||||
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
|
||||
import { Results } from 'src/app/data/results'
|
||||
import { Tag } from 'src/app/data/tag'
|
||||
import { Correspondent } from 'src/app/data/correspondent'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
import { StoragePath } from 'src/app/data/storage-path'
|
||||
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
|
||||
|
||||
const selectionData: SelectionData = {
|
||||
selected_tags: [
|
||||
@@ -64,6 +75,10 @@ describe('BulkEditorComponent', () => {
|
||||
let documentService: DocumentService
|
||||
let toastService: ToastService
|
||||
let modalService: NgbModal
|
||||
let tagService: TagService
|
||||
let correspondentsService: CorrespondentService
|
||||
let documentTypeService: DocumentTypeService
|
||||
let storagePathService: StoragePathService
|
||||
let httpTestingController: HttpTestingController
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -81,6 +96,7 @@ describe('BulkEditorComponent', () => {
|
||||
SelectComponent,
|
||||
PermissionsGroupComponent,
|
||||
PermissionsUserComponent,
|
||||
SwitchComponent,
|
||||
],
|
||||
providers: [
|
||||
PermissionsService,
|
||||
@@ -163,6 +179,10 @@ describe('BulkEditorComponent', () => {
|
||||
documentService = TestBed.inject(DocumentService)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
modalService = TestBed.inject(NgbModal)
|
||||
tagService = TestBed.inject(TagService)
|
||||
correspondentsService = TestBed.inject(CorrespondentService)
|
||||
documentTypeService = TestBed.inject(DocumentTypeService)
|
||||
storagePathService = TestBed.inject(StoragePathService)
|
||||
httpTestingController = TestBed.inject(HttpTestingController)
|
||||
|
||||
fixture = TestBed.createComponent(BulkEditorComponent)
|
||||
@@ -851,7 +871,18 @@ describe('BulkEditorComponent', () => {
|
||||
fixture.detectChanges()
|
||||
component.setPermissions()
|
||||
expect(modal).not.toBeUndefined()
|
||||
modal.componentInstance.confirmClicked.next()
|
||||
const perms = {
|
||||
permissions: {
|
||||
view_users: [],
|
||||
change_users: [],
|
||||
view_groups: [],
|
||||
change_groups: [],
|
||||
},
|
||||
}
|
||||
modal.componentInstance.confirmClicked.emit({
|
||||
permissions: perms,
|
||||
merge: true,
|
||||
})
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
)
|
||||
@@ -859,7 +890,10 @@ describe('BulkEditorComponent', () => {
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [3, 4],
|
||||
method: 'set_permissions',
|
||||
parameters: undefined,
|
||||
parameters: {
|
||||
permissions: perms.permissions,
|
||||
merge: true,
|
||||
},
|
||||
})
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||
@@ -868,4 +902,198 @@ describe('BulkEditorComponent', () => {
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||
) // listAllFilteredIds
|
||||
})
|
||||
|
||||
it('should not attempt to retrieve objects if user does not have permissions', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
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/`
|
||||
)
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/document_types/`
|
||||
)
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/storage_paths/`
|
||||
)
|
||||
})
|
||||
|
||||
it('should support create new tag', () => {
|
||||
const name = 'New Tag'
|
||||
const newTag = { id: 101, name: 'New Tag' }
|
||||
const tags: Results<Tag> = {
|
||||
results: [
|
||||
{ id: 1, name: 'Tag 1' },
|
||||
{ id: 2, name: 'Tag 2' },
|
||||
],
|
||||
count: 2,
|
||||
all: [1, 2],
|
||||
}
|
||||
|
||||
const modalInstance = {
|
||||
componentInstance: {
|
||||
dialogMode: EditDialogMode.CREATE,
|
||||
object: { name },
|
||||
succeeded: of(newTag),
|
||||
},
|
||||
}
|
||||
const tagListAllSpy = jest.spyOn(tagService, 'listAll')
|
||||
tagListAllSpy.mockReturnValue(of(tags))
|
||||
|
||||
const tagSelectionModelToggleSpy = jest.spyOn(
|
||||
component.tagSelectionModel,
|
||||
'toggle'
|
||||
)
|
||||
|
||||
const modalServiceOpenSpy = jest.spyOn(modalService, 'open')
|
||||
modalServiceOpenSpy.mockReturnValue(modalInstance as any)
|
||||
|
||||
component.createTag(name)
|
||||
|
||||
expect(modalServiceOpenSpy).toHaveBeenCalledWith(TagEditDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
expect(tagListAllSpy).toHaveBeenCalled()
|
||||
|
||||
expect(tagSelectionModelToggleSpy).toHaveBeenCalledWith(newTag.id)
|
||||
expect(component.tags).toEqual(tags.results)
|
||||
})
|
||||
|
||||
it('should support create new correspondent', () => {
|
||||
const name = 'New Correspondent'
|
||||
const newCorrespondent = { id: 101, name: 'New Correspondent' }
|
||||
const correspondents: Results<Correspondent> = {
|
||||
results: [
|
||||
{ id: 1, name: 'Correspondent 1' },
|
||||
{ id: 2, name: 'Correspondent 2' },
|
||||
],
|
||||
count: 2,
|
||||
all: [1, 2],
|
||||
}
|
||||
|
||||
const modalInstance = {
|
||||
componentInstance: {
|
||||
dialogMode: EditDialogMode.CREATE,
|
||||
object: { name },
|
||||
succeeded: of(newCorrespondent),
|
||||
},
|
||||
}
|
||||
const correspondentsListAllSpy = jest.spyOn(
|
||||
correspondentsService,
|
||||
'listAll'
|
||||
)
|
||||
correspondentsListAllSpy.mockReturnValue(of(correspondents))
|
||||
|
||||
const correspondentSelectionModelToggleSpy = jest.spyOn(
|
||||
component.correspondentSelectionModel,
|
||||
'toggle'
|
||||
)
|
||||
|
||||
const modalServiceOpenSpy = jest.spyOn(modalService, 'open')
|
||||
modalServiceOpenSpy.mockReturnValue(modalInstance as any)
|
||||
|
||||
component.createCorrespondent(name)
|
||||
|
||||
expect(modalServiceOpenSpy).toHaveBeenCalledWith(
|
||||
CorrespondentEditDialogComponent,
|
||||
{ backdrop: 'static' }
|
||||
)
|
||||
expect(correspondentsListAllSpy).toHaveBeenCalled()
|
||||
|
||||
expect(correspondentSelectionModelToggleSpy).toHaveBeenCalledWith(
|
||||
newCorrespondent.id
|
||||
)
|
||||
expect(component.correspondents).toEqual(correspondents.results)
|
||||
})
|
||||
|
||||
it('should support create new document type', () => {
|
||||
const name = 'New Document Type'
|
||||
const newDocumentType = { id: 101, name: 'New Document Type' }
|
||||
const documentTypes: Results<DocumentType> = {
|
||||
results: [
|
||||
{ id: 1, name: 'Document Type 1' },
|
||||
{ id: 2, name: 'Document Type 2' },
|
||||
],
|
||||
count: 2,
|
||||
all: [1, 2],
|
||||
}
|
||||
|
||||
const modalInstance = {
|
||||
componentInstance: {
|
||||
dialogMode: EditDialogMode.CREATE,
|
||||
object: { name },
|
||||
succeeded: of(newDocumentType),
|
||||
},
|
||||
}
|
||||
const documentTypesListAllSpy = jest.spyOn(documentTypeService, 'listAll')
|
||||
documentTypesListAllSpy.mockReturnValue(of(documentTypes))
|
||||
|
||||
const documentTypeSelectionModelToggleSpy = jest.spyOn(
|
||||
component.documentTypeSelectionModel,
|
||||
'toggle'
|
||||
)
|
||||
|
||||
const modalServiceOpenSpy = jest.spyOn(modalService, 'open')
|
||||
modalServiceOpenSpy.mockReturnValue(modalInstance as any)
|
||||
|
||||
component.createDocumentType(name)
|
||||
|
||||
expect(modalServiceOpenSpy).toHaveBeenCalledWith(
|
||||
DocumentTypeEditDialogComponent,
|
||||
{ backdrop: 'static' }
|
||||
)
|
||||
expect(documentTypesListAllSpy).toHaveBeenCalled()
|
||||
|
||||
expect(documentTypeSelectionModelToggleSpy).toHaveBeenCalledWith(
|
||||
newDocumentType.id
|
||||
)
|
||||
expect(component.documentTypes).toEqual(documentTypes.results)
|
||||
})
|
||||
|
||||
it('should support create new storage path', () => {
|
||||
const name = 'New Storage Path'
|
||||
const newStoragePath = { id: 101, name: 'New Storage Path' }
|
||||
const storagePaths: Results<StoragePath> = {
|
||||
results: [
|
||||
{ id: 1, name: 'Storage Path 1' },
|
||||
{ id: 2, name: 'Storage Path 2' },
|
||||
],
|
||||
count: 2,
|
||||
all: [1, 2],
|
||||
}
|
||||
|
||||
const modalInstance = {
|
||||
componentInstance: {
|
||||
dialogMode: EditDialogMode.CREATE,
|
||||
object: { name },
|
||||
succeeded: of(newStoragePath),
|
||||
},
|
||||
}
|
||||
const storagePathsListAllSpy = jest.spyOn(storagePathService, 'listAll')
|
||||
storagePathsListAllSpy.mockReturnValue(of(storagePaths))
|
||||
|
||||
const storagePathsSelectionModelToggleSpy = jest.spyOn(
|
||||
component.storagePathsSelectionModel,
|
||||
'toggle'
|
||||
)
|
||||
|
||||
const modalServiceOpenSpy = jest.spyOn(modalService, 'open')
|
||||
modalServiceOpenSpy.mockReturnValue(modalInstance as any)
|
||||
|
||||
component.createStoragePath(name)
|
||||
|
||||
expect(modalServiceOpenSpy).toHaveBeenCalledWith(
|
||||
StoragePathEditDialogComponent,
|
||||
{ backdrop: 'static' }
|
||||
)
|
||||
expect(storagePathsListAllSpy).toHaveBeenCalled()
|
||||
|
||||
expect(storagePathsSelectionModelToggleSpy).toHaveBeenCalledWith(
|
||||
newStoragePath.id
|
||||
)
|
||||
expect(component.storagePaths).toEqual(storagePaths.results)
|
||||
})
|
||||
})
|
||||
|
@@ -33,7 +33,12 @@ import {
|
||||
PermissionType,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { FormControl, FormGroup } from '@angular/forms'
|
||||
import { first, Subject, takeUntil } from 'rxjs'
|
||||
import { first, map, Subject, switchMap, takeUntil } from 'rxjs'
|
||||
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
|
||||
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
|
||||
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-bulk-editor',
|
||||
@@ -115,22 +120,50 @@ export class BulkEditorComponent
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.tagService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.tags = result.results))
|
||||
this.correspondentService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.correspondents = result.results))
|
||||
this.documentTypeService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.documentTypes = result.results))
|
||||
this.storagePathService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.storagePaths = result.results))
|
||||
if (
|
||||
this.permissionService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.Tag
|
||||
)
|
||||
) {
|
||||
this.tagService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.tags = result.results))
|
||||
}
|
||||
if (
|
||||
this.permissionService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.Correspondent
|
||||
)
|
||||
) {
|
||||
this.correspondentService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.correspondents = result.results))
|
||||
}
|
||||
if (
|
||||
this.permissionService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.DocumentType
|
||||
)
|
||||
) {
|
||||
this.documentTypeService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.documentTypes = result.results))
|
||||
}
|
||||
if (
|
||||
this.permissionService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.StoragePath
|
||||
)
|
||||
) {
|
||||
this.storagePathService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.storagePaths = result.results))
|
||||
}
|
||||
|
||||
this.downloadForm
|
||||
.get('downloadFileTypeArchive')
|
||||
@@ -451,6 +484,92 @@ export class BulkEditorComponent
|
||||
}
|
||||
}
|
||||
|
||||
createTag(name: string) {
|
||||
let modal = this.modalService.open(TagEditDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.dialogMode = EditDialogMode.CREATE
|
||||
modal.componentInstance.object = { name }
|
||||
modal.componentInstance.succeeded
|
||||
.pipe(
|
||||
switchMap((newTag) => {
|
||||
return this.tagService
|
||||
.listAll()
|
||||
.pipe(map((tags) => ({ newTag, tags })))
|
||||
})
|
||||
)
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(({ newTag, tags }) => {
|
||||
this.tags = tags.results
|
||||
this.tagSelectionModel.toggle(newTag.id)
|
||||
})
|
||||
}
|
||||
|
||||
createCorrespondent(name: string) {
|
||||
let modal = this.modalService.open(CorrespondentEditDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.dialogMode = EditDialogMode.CREATE
|
||||
modal.componentInstance.object = { name }
|
||||
modal.componentInstance.succeeded
|
||||
.pipe(
|
||||
switchMap((newCorrespondent) => {
|
||||
return this.correspondentService
|
||||
.listAll()
|
||||
.pipe(
|
||||
map((correspondents) => ({ newCorrespondent, correspondents }))
|
||||
)
|
||||
})
|
||||
)
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(({ newCorrespondent, correspondents }) => {
|
||||
this.correspondents = correspondents.results
|
||||
this.correspondentSelectionModel.toggle(newCorrespondent.id)
|
||||
})
|
||||
}
|
||||
|
||||
createDocumentType(name: string) {
|
||||
let modal = this.modalService.open(DocumentTypeEditDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.dialogMode = EditDialogMode.CREATE
|
||||
modal.componentInstance.object = { name }
|
||||
modal.componentInstance.succeeded
|
||||
.pipe(
|
||||
switchMap((newDocumentType) => {
|
||||
return this.documentTypeService
|
||||
.listAll()
|
||||
.pipe(map((documentTypes) => ({ newDocumentType, documentTypes })))
|
||||
})
|
||||
)
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(({ newDocumentType, documentTypes }) => {
|
||||
this.documentTypes = documentTypes.results
|
||||
this.documentTypeSelectionModel.toggle(newDocumentType.id)
|
||||
})
|
||||
}
|
||||
|
||||
createStoragePath(name: string) {
|
||||
let modal = this.modalService.open(StoragePathEditDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.dialogMode = EditDialogMode.CREATE
|
||||
modal.componentInstance.object = { name }
|
||||
modal.componentInstance.succeeded
|
||||
.pipe(
|
||||
switchMap((newStoragePath) => {
|
||||
return this.storagePathService
|
||||
.listAll()
|
||||
.pipe(map((storagePaths) => ({ newStoragePath, storagePaths })))
|
||||
})
|
||||
)
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(({ newStoragePath, storagePaths }) => {
|
||||
this.storagePaths = storagePaths.results
|
||||
this.storagePathsSelectionModel.toggle(newStoragePath.id)
|
||||
})
|
||||
}
|
||||
|
||||
applyDelete() {
|
||||
let modal = this.modalService.open(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
@@ -512,9 +631,14 @@ export class BulkEditorComponent
|
||||
let modal = this.modalService.open(PermissionsDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.confirmClicked.subscribe((permissions) => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.executeBulkOperation(modal, 'set_permissions', permissions)
|
||||
})
|
||||
modal.componentInstance.confirmClicked.subscribe(
|
||||
({ permissions, merge }) => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.executeBulkOperation(modal, 'set_permissions', {
|
||||
...permissions,
|
||||
merge,
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@@ -25,7 +25,7 @@
|
||||
@if (notesEnabled && document.notes.length) {
|
||||
<a routerLink="/documents/{{document.id}}/notes" class="document-card-notes py-2 px-1">
|
||||
<span class="badge rounded-pill bg-light border text-primary">
|
||||
<i-bs width="0.9rem" height="0.9rem" class="ms-1 me-1" name="chat-left-text"></i-bs>
|
||||
<i-bs width="1.2em" height="1.2em" class="ms-1 me-1" name="chat-left-text"></i-bs>
|
||||
{{document.notes.length}}</span>
|
||||
</a>
|
||||
}
|
||||
@@ -43,14 +43,14 @@
|
||||
@if (document.document_type) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0" title="Toggle document type filter" i18n-title
|
||||
(click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()">
|
||||
<i-bs width="0.9rem" height="0.9rem" class="me-2 text-muted" name="file-earmark"></i-bs>
|
||||
<i-bs width="1em" height="1em" class="me-2 text-muted" name="file-earmark"></i-bs>
|
||||
<small>{{(document.document_type$ | async)?.name ?? privateName}}</small>
|
||||
</button>
|
||||
}
|
||||
@if (document.storage_path) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0" title="Toggle storage path filter" i18n-title
|
||||
(click)="clickStoragePath.emit(document.storage_path);$event.stopPropagation()">
|
||||
<i-bs width="0.9rem" height="0.9rem" class="me-2 text-muted" name="folder"></i-bs>
|
||||
<i-bs width="1em" height="1em" class="me-2 text-muted" name="folder"></i-bs>
|
||||
<small>{{(document.storage_path$ | async)?.name ?? privateName}}</small>
|
||||
</button>
|
||||
}
|
||||
@@ -63,25 +63,25 @@
|
||||
</div>
|
||||
</ng-template>
|
||||
<div class="ps-0 p-1" placement="top" [ngbTooltip]="dateTooltip">
|
||||
<i-bs width="0.9rem" height="0.9rem" class="me-2 text-muted" name="calendar-event"></i-bs>
|
||||
<i-bs width="1em" height="1em" class="me-2 text-muted" name="calendar-event"></i-bs>
|
||||
<small>{{document.created_date | customDate:'mediumDate'}}</small>
|
||||
</div>
|
||||
</div>
|
||||
@if (document.archive_serial_number | isNumber) {
|
||||
<div class="ps-0 p-1">
|
||||
<i-bs width="0.9rem" height="0.9rem" class="me-2 text-muted" name="upc-scan"></i-bs>
|
||||
<i-bs width="1em" height="1em" class="me-2 text-muted" name="upc-scan"></i-bs>
|
||||
<small>#{{document.archive_serial_number}}</small>
|
||||
</div>
|
||||
}
|
||||
@if (document.owner && document.owner !== settingsService.currentUser.id) {
|
||||
<div class="ps-0 p-1">
|
||||
<i-bs width="0.9rem" height="0.9rem" class="me-2 text-muted" name="person-fill-lock"></i-bs>
|
||||
<i-bs width="1em" height="1em" class="me-2 text-muted" name="person-fill-lock"></i-bs>
|
||||
<small>{{document.owner | username}}</small>
|
||||
</div>
|
||||
}
|
||||
@if (document.is_shared_by_requester) {
|
||||
<div class="ps-0 p-1">
|
||||
<i-bs width="0.9rem" height="0.9rem" class="me-2 text-muted" name="people-fill"></i-bs>
|
||||
<i-bs width="1em" height="1em" class="me-2 text-muted" name="people-fill"></i-bs>
|
||||
<small i18n>Shared</small>
|
||||
</div>
|
||||
}
|
||||
|
@@ -79,7 +79,7 @@ export class DocumentCardSmallComponent extends ComponentWithPermissions {
|
||||
|
||||
getTagsLimited$() {
|
||||
const limit = this.document.notes.length > 0 ? 6 : 7
|
||||
return this.document.tags$.pipe(
|
||||
return this.document.tags$?.pipe(
|
||||
map((tags) => {
|
||||
if (tags.length > limit) {
|
||||
this.moreTags = tags.length - (limit - 1)
|
||||
|
@@ -232,7 +232,7 @@
|
||||
@if (d.notes.length) {
|
||||
<a routerLink="/documents/{{d.id}}/notes" class="btn btn-sm p-0">
|
||||
<span class="badge rounded-pill bg-light border text-primary">
|
||||
<i-bs width="0.9rem" height="0.9rem" class="ms-1 me-1" name="chat-left-text"></i-bs>
|
||||
<i-bs width="1.2em" height="1.2em" class="ms-1 me-1" name="chat-left-text"></i-bs>
|
||||
{{d.notes.length}}</span>
|
||||
</a>
|
||||
}
|
||||
|
@@ -18,7 +18,7 @@
|
||||
</select>
|
||||
}
|
||||
@if (_textFilter) {
|
||||
<button class="btn btn-link btn-sm px-0 position-absolute top-0 end-0 z-10" (click)="resetTextField()">
|
||||
<button class="btn btn-link btn-sm px-2 position-absolute top-0 end-0 z-10" (click)="resetTextField()">
|
||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||
</button>
|
||||
}
|
||||
@@ -29,7 +29,8 @@
|
||||
<div class="col-auto">
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<pngx-filterable-dropdown class="flex-fill" title="Tags" icon="tag-fill" i18n-title
|
||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
|
||||
<pngx-filterable-dropdown class="flex-fill" title="Tags" icon="tag-fill" i18n-title
|
||||
filterPlaceholder="Filter tags" i18n-filterPlaceholder
|
||||
[items]="tags"
|
||||
[manyToOne]="true"
|
||||
@@ -37,31 +38,38 @@
|
||||
(selectionModelChange)="updateRules()"
|
||||
(opened)="onTagsDropdownOpen()"
|
||||
[documentCounts]="tagDocumentCounts"
|
||||
[allowSelectNone]="true"></pngx-filterable-dropdown>
|
||||
<pngx-filterable-dropdown class="flex-fill" title="Correspondent" icon="person-fill" i18n-title
|
||||
[allowSelectNone]="true"></pngx-filterable-dropdown>
|
||||
}
|
||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
|
||||
<pngx-filterable-dropdown class="flex-fill" title="Correspondent" icon="person-fill" i18n-title
|
||||
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
|
||||
[items]="correspondents"
|
||||
[(selectionModel)]="correspondentSelectionModel"
|
||||
(selectionModelChange)="updateRules()"
|
||||
(opened)="onCorrespondentDropdownOpen()"
|
||||
[documentCounts]="correspondentDocumentCounts"
|
||||
[allowSelectNone]="true"></pngx-filterable-dropdown>
|
||||
<pngx-filterable-dropdown class="flex-fill" title="Document type" icon="file-earmark-fill" i18n-title
|
||||
filterPlaceholder="Filter document types" i18n-filterPlaceholder
|
||||
[items]="documentTypes"
|
||||
[(selectionModel)]="documentTypeSelectionModel"
|
||||
(selectionModelChange)="updateRules()"
|
||||
(opened)="onDocumentTypeDropdownOpen()"
|
||||
[documentCounts]="documentTypeDocumentCounts"
|
||||
[allowSelectNone]="true"></pngx-filterable-dropdown>
|
||||
<pngx-filterable-dropdown class="flex-fill" title="Storage path" icon="folder-fill" i18n-title
|
||||
[allowSelectNone]="true"></pngx-filterable-dropdown>
|
||||
}
|
||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
|
||||
<pngx-filterable-dropdown class="flex-fill" title="Document type" icon="file-earmark-fill" i18n-title
|
||||
filterPlaceholder="Filter document types" i18n-filterPlaceholder
|
||||
[items]="documentTypes"
|
||||
[(selectionModel)]="documentTypeSelectionModel"
|
||||
(selectionModelChange)="updateRules()"
|
||||
(opened)="onDocumentTypeDropdownOpen()"
|
||||
[documentCounts]="documentTypeDocumentCounts"
|
||||
[allowSelectNone]="true"></pngx-filterable-dropdown>
|
||||
}
|
||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
|
||||
<pngx-filterable-dropdown class="flex-fill" title="Storage path" icon="folder-fill" i18n-title
|
||||
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
|
||||
[items]="storagePaths"
|
||||
[(selectionModel)]="storagePathSelectionModel"
|
||||
(selectionModelChange)="updateRules()"
|
||||
(opened)="onStoragePathDropdownOpen()"
|
||||
[documentCounts]="storagePathDocumentCounts"
|
||||
[allowSelectNone]="true"></pngx-filterable-dropdown>
|
||||
[allowSelectNone]="true"></pngx-filterable-dropdown>
|
||||
}
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<pngx-date-dropdown
|
||||
|
@@ -1,5 +1,8 @@
|
||||
import { DatePipe } from '@angular/common'
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||
import {
|
||||
HttpClientTestingModule,
|
||||
HttpTestingController,
|
||||
} from '@angular/common/http/testing'
|
||||
import {
|
||||
ComponentFixture,
|
||||
fakeAsync,
|
||||
@@ -78,6 +81,11 @@ import {
|
||||
} from '../../common/permissions-filter-dropdown/permissions-filter-dropdown.component'
|
||||
import { FilterEditorComponent } from './filter-editor.component'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import {
|
||||
PermissionType,
|
||||
PermissionsService,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
|
||||
const tags: Tag[] = [
|
||||
{
|
||||
@@ -135,6 +143,8 @@ describe('FilterEditorComponent', () => {
|
||||
let fixture: ComponentFixture<FilterEditorComponent>
|
||||
let documentService: DocumentService
|
||||
let settingsService: SettingsService
|
||||
let permissionsService: PermissionsService
|
||||
let httpTestingController: HttpTestingController
|
||||
|
||||
beforeEach(fakeAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
@@ -199,6 +209,15 @@ describe('FilterEditorComponent', () => {
|
||||
documentService = TestBed.inject(DocumentService)
|
||||
settingsService = TestBed.inject(SettingsService)
|
||||
settingsService.currentUser = users[0]
|
||||
permissionsService = TestBed.inject(PermissionsService)
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserCan')
|
||||
.mockImplementation((action, type) => {
|
||||
// a little hack-ish, permissions filter dropdown causes reactive forms issue due to ng-select
|
||||
// trying to apply formControlName
|
||||
return type !== PermissionType.User
|
||||
})
|
||||
httpTestingController = TestBed.inject(HttpTestingController)
|
||||
fixture = TestBed.createComponent(FilterEditorComponent)
|
||||
component = fixture.componentInstance
|
||||
component.filterRules = []
|
||||
@@ -206,6 +225,24 @@ describe('FilterEditorComponent', () => {
|
||||
tick()
|
||||
}))
|
||||
|
||||
it('should not attempt to retrieve objects if user does not have permissions', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReset()
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserCan')
|
||||
.mockImplementation((action, type) => false)
|
||||
component.ngOnInit()
|
||||
httpTestingController.expectNone(`${environment.apiBaseUrl}documents/tags/`)
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/correspondents/`
|
||||
)
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/document_types/`
|
||||
)
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/storage_paths/`
|
||||
)
|
||||
})
|
||||
|
||||
// SET filterRules
|
||||
|
||||
it('should ingest text filter rules for doc title', fakeAsync(() => {
|
||||
|
@@ -70,6 +70,12 @@ import {
|
||||
OwnerFilterType,
|
||||
PermissionsSelectionModel,
|
||||
} from '../../common/permissions-filter-dropdown/permissions-filter-dropdown.component'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionType,
|
||||
PermissionsService,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||
|
||||
const TEXT_FILTER_TARGET_TITLE = 'title'
|
||||
const TEXT_FILTER_TARGET_TITLE_CONTENT = 'title-content'
|
||||
@@ -155,7 +161,10 @@ const DEFAULT_TEXT_FILTER_MODIFIER_OPTIONS = [
|
||||
templateUrl: './filter-editor.component.html',
|
||||
styleUrls: ['./filter-editor.component.scss'],
|
||||
})
|
||||
export class FilterEditorComponent implements OnInit, OnDestroy {
|
||||
export class FilterEditorComponent
|
||||
extends ComponentWithPermissions
|
||||
implements OnInit, OnDestroy
|
||||
{
|
||||
generateFilterName() {
|
||||
if (this.filterRules.length == 1) {
|
||||
let rule = this.filterRules[0]
|
||||
@@ -224,8 +233,11 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
|
||||
private tagService: TagService,
|
||||
private correspondentService: CorrespondentService,
|
||||
private documentService: DocumentService,
|
||||
private storagePathService: StoragePathService
|
||||
) {}
|
||||
private storagePathService: StoragePathService,
|
||||
public permissionsService: PermissionsService
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
@ViewChild('textFilterInput')
|
||||
textFilterInput: ElementRef
|
||||
@@ -872,18 +884,46 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
|
||||
subscription: Subscription
|
||||
|
||||
ngOnInit() {
|
||||
this.tagService
|
||||
.listAll()
|
||||
.subscribe((result) => (this.tags = result.results))
|
||||
this.correspondentService
|
||||
.listAll()
|
||||
.subscribe((result) => (this.correspondents = result.results))
|
||||
this.documentTypeService
|
||||
.listAll()
|
||||
.subscribe((result) => (this.documentTypes = result.results))
|
||||
this.storagePathService
|
||||
.listAll()
|
||||
.subscribe((result) => (this.storagePaths = result.results))
|
||||
if (
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.Tag
|
||||
)
|
||||
) {
|
||||
this.tagService
|
||||
.listAll()
|
||||
.subscribe((result) => (this.tags = result.results))
|
||||
}
|
||||
if (
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.Correspondent
|
||||
)
|
||||
) {
|
||||
this.correspondentService
|
||||
.listAll()
|
||||
.subscribe((result) => (this.correspondents = result.results))
|
||||
}
|
||||
if (
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.DocumentType
|
||||
)
|
||||
) {
|
||||
this.documentTypeService
|
||||
.listAll()
|
||||
.subscribe((result) => (this.documentTypes = result.results))
|
||||
}
|
||||
if (
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.StoragePath
|
||||
)
|
||||
) {
|
||||
this.storagePathService
|
||||
.listAll()
|
||||
.subscribe((result) => (this.storagePaths = result.results))
|
||||
}
|
||||
|
||||
this.textFilterDebounce = new Subject<string>()
|
||||
|
||||
|
@@ -19,22 +19,32 @@ const notes: DocumentNote[] = [
|
||||
{
|
||||
id: 23,
|
||||
note: 'Note 23',
|
||||
user: 1,
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'user1',
|
||||
first_name: 'User1',
|
||||
last_name: 'Lastname1',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 24,
|
||||
note: 'Note 24',
|
||||
user: 1,
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'user1',
|
||||
first_name: 'User1',
|
||||
last_name: 'Lastname1',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 25,
|
||||
note: 'Note 25',
|
||||
user: 2,
|
||||
user: { id: 2, username: 'user2' },
|
||||
},
|
||||
{
|
||||
id: 30,
|
||||
note: 'Note 30',
|
||||
user: 3,
|
||||
user: { id: 3, username: 'user3' },
|
||||
},
|
||||
]
|
||||
|
||||
@@ -123,11 +133,24 @@ describe('DocumentNotesComponent', () => {
|
||||
})
|
||||
|
||||
it('should handle note user display in all situations', () => {
|
||||
expect(component.displayName({ id: 1, user: 1 })).toEqual(
|
||||
'User1 Lastname1 (user1)'
|
||||
)
|
||||
expect(component.displayName({ id: 1, user: 2 })).toEqual('user2')
|
||||
expect(component.displayName({ id: 1, user: 4 })).toEqual('')
|
||||
expect(
|
||||
component.displayName({
|
||||
id: 1,
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'user1',
|
||||
first_name: 'User1',
|
||||
last_name: 'Lastname1',
|
||||
},
|
||||
})
|
||||
).toEqual('User1 Lastname1 (user1)')
|
||||
expect(
|
||||
component.displayName({ id: 1, user: { id: 2, username: 'user2' } })
|
||||
).toEqual('user2')
|
||||
expect(component.displayName({ id: 1, user: 2 } as any)).toEqual('user2')
|
||||
expect(
|
||||
component.displayName({ id: 1, user: { id: 4, username: 'user4' } })
|
||||
).toEqual('')
|
||||
expect(component.displayName({ id: 1 })).toEqual('')
|
||||
})
|
||||
|
||||
@@ -146,7 +169,9 @@ describe('DocumentNotesComponent', () => {
|
||||
expect(addSpy).toHaveBeenCalledWith(12, note)
|
||||
expect(toastsSpy).toHaveBeenCalled()
|
||||
|
||||
addSpy.mockReturnValueOnce(of([...notes, { id: 31, note, user: 1 }]))
|
||||
addSpy.mockReturnValueOnce(
|
||||
of([...notes, { id: 31, note, user: { id: 1 } }])
|
||||
)
|
||||
addButton.triggerEventHandler('click')
|
||||
fixture.detectChanges()
|
||||
expect(fixture.debugElement.nativeElement.textContent).toContain(note)
|
||||
|
@@ -84,7 +84,8 @@ export class DocumentNotesComponent extends ComponentWithPermissions {
|
||||
|
||||
displayName(note: DocumentNote): string {
|
||||
if (!note.user) return ''
|
||||
const user = this.users?.find((u) => u.id === note.user)
|
||||
const user_id = typeof note.user === 'number' ? note.user : note.user.id
|
||||
const user = this.users?.find((u) => u.id === user_id)
|
||||
if (!user) return ''
|
||||
const nameComponents = []
|
||||
if (user.first_name) nameComponents.push(user.first_name)
|
||||
|
@@ -29,16 +29,16 @@
|
||||
<div class="btn-group">
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editField(field)">
|
||||
<i-bs width="1em" height="1em" name="pencil"></i-bs> <ng-container i18n>Edit</ng-container>
|
||||
</button>
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteField(field)">
|
||||
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteField(field)">
|
||||
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
@if (fields.length === 0) {
|
||||
<li class="list-group-item" i18n>No fields defined.</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
@if (fields.length === 0) {
|
||||
<li class="list-group-item" i18n>No fields defined.</li>
|
||||
}
|
||||
</ul>
|
||||
|
@@ -32,72 +32,72 @@
|
||||
<div class="btn-group">
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailAccount }" [disabled]="!userCanEdit(account)" class="btn btn-sm btn-outline-secondary" type="button" (click)="editMailAccount(account)">
|
||||
<i-bs width="1em" height="1em" name="pencil"></i-bs> <ng-container i18n>Edit</ng-container>
|
||||
</button>
|
||||
<button *pngxIfOwner="account" class="btn btn-sm btn-outline-secondary" type="button" (click)="editPermissions(account)">
|
||||
<i-bs width="1em" height="1em" name="person-lock"></i-bs> <ng-container i18n>Permissions</ng-container>
|
||||
</button>
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.MailAccount }" [disabled]="!userIsOwner(account)" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteMailAccount(account)">
|
||||
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
@if (mailAccounts.length === 0) {
|
||||
<li class="list-group-item" i18n>No mail accounts defined.</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
</ng-container>
|
||||
|
||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailRule }">
|
||||
<h4 class="mt-4">
|
||||
<ng-container i18n>Mail rules</ng-container>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editMailRule()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.MailRule }">
|
||||
<i-bs name="plus-circle"></i-bs> <ng-container i18n>Add Rule</ng-container>
|
||||
</button>
|
||||
</h4>
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col" i18n>Name</div>
|
||||
<div class="col" i18n>Account</div>
|
||||
<div class="col" i18n>Actions</div>
|
||||
</button>
|
||||
<button *pngxIfOwner="account" class="btn btn-sm btn-outline-secondary" type="button" (click)="editPermissions(account)">
|
||||
<i-bs width="1em" height="1em" name="person-lock"></i-bs> <ng-container i18n>Permissions</ng-container>
|
||||
</button>
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.MailAccount }" [disabled]="!userIsOwner(account)" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteMailAccount(account)">
|
||||
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
@if (mailAccounts.length === 0) {
|
||||
<li class="list-group-item" i18n>No mail accounts defined.</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@for (rule of mailRules; track rule) {
|
||||
<li class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailRule(rule)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailRule)">{{rule.name}}</button></div>
|
||||
<div class="col d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div>
|
||||
<div class="col">
|
||||
<div class="btn-group">
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailRule }" [disabled]="!userCanEdit(rule)" class="btn btn-sm btn-outline-secondary" type="button" (click)="editMailRule(rule)">
|
||||
<i-bs width="1em" height="1em" name="pencil"></i-bs> <ng-container i18n>Edit</ng-container>
|
||||
</button>
|
||||
<button *pngxIfOwner="rule" class="btn btn-sm btn-outline-secondary" type="button" (click)="editPermissions(rule)">
|
||||
<i-bs width="1em" height="1em" name="person-lock"></i-bs> <ng-container i18n>Permissions</ng-container>
|
||||
</button>
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.MailRule }" [disabled]="!userIsOwner(rule)" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteMailRule(rule)">
|
||||
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
@if (mailRules.length === 0) {
|
||||
<li class="list-group-item" i18n>No mail rules defined.</li>
|
||||
}
|
||||
</ul>
|
||||
</ng-container>
|
||||
|
||||
</ng-container>
|
||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailRule }">
|
||||
<h4 class="mt-4">
|
||||
<ng-container i18n>Mail rules</ng-container>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editMailRule()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.MailRule }">
|
||||
<i-bs name="plus-circle"></i-bs> <ng-container i18n>Add Rule</ng-container>
|
||||
</button>
|
||||
</h4>
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col" i18n>Name</div>
|
||||
<div class="col" i18n>Account</div>
|
||||
<div class="col" i18n>Actions</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@if (!mailAccounts || !mailRules) {
|
||||
<div>
|
||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||
<div class="visually-hidden" i18n>Loading...</div>
|
||||
</div>
|
||||
}
|
||||
@for (rule of mailRules; track rule) {
|
||||
<li class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailRule(rule)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailRule)">{{rule.name}}</button></div>
|
||||
<div class="col d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div>
|
||||
<div class="col">
|
||||
<div class="btn-group">
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailRule }" [disabled]="!userCanEdit(rule)" class="btn btn-sm btn-outline-secondary" type="button" (click)="editMailRule(rule)">
|
||||
<i-bs width="1em" height="1em" name="pencil"></i-bs> <ng-container i18n>Edit</ng-container>
|
||||
</button>
|
||||
<button *pngxIfOwner="rule" class="btn btn-sm btn-outline-secondary" type="button" (click)="editPermissions(rule)">
|
||||
<i-bs width="1em" height="1em" name="person-lock"></i-bs> <ng-container i18n>Permissions</ng-container>
|
||||
</button>
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.MailRule }" [disabled]="!userIsOwner(rule)" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteMailRule(rule)">
|
||||
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
@if (mailRules.length === 0) {
|
||||
<li class="list-group-item" i18n>No mail rules defined.</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
</ng-container>
|
||||
|
||||
@if (!mailAccounts || !mailRules) {
|
||||
<div>
|
||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||
<div class="visually-hidden" i18n>Loading...</div>
|
||||
</div>
|
||||
}
|
||||
|
@@ -41,6 +41,7 @@ import { TagsComponent } from '../../common/input/tags/tags.component'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { SwitchComponent } from '../../common/input/switch/switch.component'
|
||||
|
||||
const mailAccounts = [
|
||||
{ id: 1, name: 'account1' },
|
||||
@@ -82,6 +83,7 @@ describe('MailComponent', () => {
|
||||
PermissionsGroupComponent,
|
||||
PermissionsDialogComponent,
|
||||
PermissionsFormComponent,
|
||||
SwitchComponent,
|
||||
],
|
||||
providers: [CustomDatePipe, DatePipe, PermissionsGuard],
|
||||
imports: [
|
||||
@@ -267,11 +269,11 @@ describe('MailComponent', () => {
|
||||
rulePatchSpy.mockReturnValueOnce(
|
||||
throwError(() => new Error('error saving perms'))
|
||||
)
|
||||
dialog.confirmClicked.emit(perms)
|
||||
dialog.confirmClicked.emit({ permissions: perms, merge: true })
|
||||
expect(rulePatchSpy).toHaveBeenCalled()
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
rulePatchSpy.mockReturnValueOnce(of(mailRules[0] as MailRule))
|
||||
dialog.confirmClicked.emit(perms)
|
||||
dialog.confirmClicked.emit({ permissions: perms, merge: true })
|
||||
expect(toastInfoSpy).toHaveBeenCalledWith('Permissions updated')
|
||||
|
||||
modalService.dismissAll()
|
||||
@@ -299,8 +301,7 @@ describe('MailComponent', () => {
|
||||
expect(modal).not.toBeUndefined()
|
||||
let dialog = modal.componentInstance as PermissionsDialogComponent
|
||||
expect(dialog.object).toEqual(mailAccounts[0])
|
||||
dialog = modal.componentInstance as PermissionsDialogComponent
|
||||
dialog.confirmClicked.emit(perms)
|
||||
dialog.confirmClicked.emit({ permissions: perms, merge: true })
|
||||
expect(accountPatchSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
@@ -200,22 +200,27 @@ export class MailComponent
|
||||
const dialog: PermissionsDialogComponent =
|
||||
modal.componentInstance as PermissionsDialogComponent
|
||||
dialog.object = object
|
||||
modal.componentInstance.confirmClicked.subscribe((permissions) => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
const service: AbstractPaperlessService<MailRule | MailAccount> =
|
||||
'account' in object ? this.mailRuleService : this.mailAccountService
|
||||
object.owner = permissions['owner']
|
||||
object['set_permissions'] = permissions['set_permissions']
|
||||
service.patch(object).subscribe({
|
||||
next: () => {
|
||||
this.toastService.showInfo($localize`Permissions updated`)
|
||||
modal.close()
|
||||
},
|
||||
error: (e) => {
|
||||
this.toastService.showError($localize`Error updating permissions`, e)
|
||||
},
|
||||
})
|
||||
})
|
||||
modal.componentInstance.confirmClicked.subscribe(
|
||||
({ permissions, merge }) => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
const service: AbstractPaperlessService<MailRule | MailAccount> =
|
||||
'account' in object ? this.mailRuleService : this.mailAccountService
|
||||
object.owner = permissions['owner']
|
||||
object['set_permissions'] = permissions['set_permissions']
|
||||
service.patch(object).subscribe({
|
||||
next: () => {
|
||||
this.toastService.showInfo($localize`Permissions updated`)
|
||||
modal.close()
|
||||
},
|
||||
error: (e) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error updating permissions`,
|
||||
e
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
userCanEdit(obj: ObjectWithPermissions): boolean {
|
||||
|
@@ -2,9 +2,12 @@
|
||||
<button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedObjects.size === 0">
|
||||
<i-bs name="x"></i-bs> <ng-container i18n>Clear selection</ng-container>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary me-5" (click)="setPermissions()" [disabled]="!userOwnsAll || selectedObjects.size === 0">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || selectedObjects.size === 0">
|
||||
<i-bs name="person-fill-lock"></i-bs> <ng-container i18n>Permissions</ng-container>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger me-5" (click)="delete()" [disabled]="!userOwnsAll || selectedObjects.size === 0">
|
||||
<i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="openCreateDialog()" *pngxIfPermissions="{ action: PermissionAction.Add, type: permissionType }">
|
||||
<i-bs name="plus-circle"></i-bs> <ng-container i18n>Create</ng-container>
|
||||
</button>
|
||||
@@ -85,33 +88,39 @@
|
||||
<div class="btn-group d-none d-sm-block">
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="filterDocuments(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||
<i-bs width="1em" height="1em" name="filter"></i-bs> <ng-container i18n>Documents</ng-container>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" [disabled]="!userCanEdit(object)">
|
||||
<i-bs width="1em" height="1em" name="pencil"></i-bs> <ng-container i18n>Edit</ng-container>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" [disabled]="!userCanDelete(object)">
|
||||
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if (!isLoading) {
|
||||
<div class="d-flex mb-2">
|
||||
@if (collectionSize > 0) {
|
||||
<div>
|
||||
<ng-container i18n>{collectionSize, plural, =1 {One {{typeName}}} other {{{collectionSize || 0}} total {{typeNamePlural}}}}</ng-container>
|
||||
@if (selectedObjects.size > 0) {
|
||||
({{selectedObjects.size}} selected)
|
||||
}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" [disabled]="!userCanEdit(object)">
|
||||
<i-bs width="1em" height="1em" name="pencil"></i-bs> <ng-container i18n>Edit</ng-container>
|
||||
</button>
|
||||
<pngx-confirm-button
|
||||
label="Delete"
|
||||
i18n-label
|
||||
(confirm)="deleteObject(object)"
|
||||
*pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }"
|
||||
[disabled]="!userCanDelete(object)"
|
||||
buttonClasses=" btn-sm btn-outline-danger"
|
||||
iconName="trash">
|
||||
</pngx-confirm-button>
|
||||
</div>
|
||||
}
|
||||
@if (collectionSize > 20) {
|
||||
<ngb-pagination class="ms-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if (!isLoading) {
|
||||
<div class="d-flex mb-2">
|
||||
@if (collectionSize > 0) {
|
||||
<div>
|
||||
<ng-container i18n>{collectionSize, plural, =1 {One {{typeName}}} other {{{collectionSize || 0}} total {{typeNamePlural}}}}</ng-container>
|
||||
@if (selectedObjects.size > 0) {
|
||||
({{selectedObjects.size}} selected)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (collectionSize > 20) {
|
||||
<ngb-pagination class="ms-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
@@ -13,6 +13,7 @@ import {
|
||||
NgbModalModule,
|
||||
NgbModalRef,
|
||||
NgbPaginationModule,
|
||||
NgbPopoverModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { Tag } from 'src/app/data/tag'
|
||||
@@ -37,6 +38,8 @@ import { MATCH_NONE } from 'src/app/data/matching-model'
|
||||
import { MATCH_LITERAL } from 'src/app/data/matching-model'
|
||||
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
|
||||
import { BulkEditObjectOperation } from 'src/app/services/rest/abstract-name-filter-service'
|
||||
|
||||
const tags: Tag[] = [
|
||||
{
|
||||
@@ -75,6 +78,7 @@ describe('ManagementListComponent', () => {
|
||||
SafeHtmlPipe,
|
||||
ConfirmDialogComponent,
|
||||
PermissionsDialogComponent,
|
||||
ConfirmButtonComponent,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
@@ -96,6 +100,7 @@ describe('ManagementListComponent', () => {
|
||||
NgbModalModule,
|
||||
RouterTestingModule.withRoutes(routes),
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
NgbPopoverModule,
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
@@ -149,7 +154,7 @@ describe('ManagementListComponent', () => {
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const reloadSpy = jest.spyOn(component, 'reloadData')
|
||||
|
||||
const createButton = fixture.debugElement.queryAll(By.css('button'))[2]
|
||||
const createButton = fixture.debugElement.queryAll(By.css('button'))[3]
|
||||
createButton.triggerEventHandler('click')
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
@@ -173,7 +178,7 @@ describe('ManagementListComponent', () => {
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const reloadSpy = jest.spyOn(component, 'reloadData')
|
||||
|
||||
const editButton = fixture.debugElement.queryAll(By.css('button'))[6]
|
||||
const editButton = fixture.debugElement.queryAll(By.css('button'))[7]
|
||||
editButton.triggerEventHandler('click')
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
@@ -192,33 +197,29 @@ describe('ManagementListComponent', () => {
|
||||
})
|
||||
|
||||
it('should support delete, show notification on error / success', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const deleteSpy = jest.spyOn(tagService, 'delete')
|
||||
const reloadSpy = jest.spyOn(component, 'reloadData')
|
||||
|
||||
const deleteButton = fixture.debugElement.queryAll(By.css('button'))[7]
|
||||
deleteButton.triggerEventHandler('click')
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
const editDialog = modal.componentInstance as ConfirmDialogComponent
|
||||
const deleteButton = fixture.debugElement.query(
|
||||
By.directive(ConfirmButtonComponent)
|
||||
)
|
||||
|
||||
// fail first
|
||||
deleteSpy.mockReturnValueOnce(throwError(() => new Error('error deleting')))
|
||||
editDialog.confirmClicked.emit()
|
||||
deleteButton.nativeElement.dispatchEvent(new Event('confirm'))
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
|
||||
// succeed
|
||||
deleteSpy.mockReturnValueOnce(of(true))
|
||||
editDialog.confirmClicked.emit()
|
||||
deleteButton.nativeElement.dispatchEvent(new Event('confirm'))
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support quick filter for objects', () => {
|
||||
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
|
||||
const filterButton = fixture.debugElement.queryAll(By.css('button'))[5]
|
||||
const filterButton = fixture.debugElement.queryAll(By.css('button'))[6]
|
||||
filterButton.triggerEventHandler('click')
|
||||
expect(qfSpy).toHaveBeenCalledWith([
|
||||
{ rule_type: FILTER_HAS_TAGS_ALL, value: tags[0].id.toString() },
|
||||
@@ -246,7 +247,7 @@ describe('ManagementListComponent', () => {
|
||||
})
|
||||
|
||||
it('should support bulk edit permissions', () => {
|
||||
const bulkEditPermsSpy = jest.spyOn(tagService, 'bulk_update_permissions')
|
||||
const bulkEditPermsSpy = jest.spyOn(tagService, 'bulk_edit_objects')
|
||||
component.toggleSelected(tags[0])
|
||||
component.toggleSelected(tags[1])
|
||||
component.toggleSelected(tags[2])
|
||||
@@ -264,14 +265,51 @@ describe('ManagementListComponent', () => {
|
||||
throwError(() => new Error('error setting permissions'))
|
||||
)
|
||||
const errorToastSpy = jest.spyOn(toastService, 'showError')
|
||||
modal.componentInstance.confirmClicked.emit()
|
||||
modal.componentInstance.confirmClicked.emit({
|
||||
permissions: {},
|
||||
merge: true,
|
||||
})
|
||||
expect(bulkEditPermsSpy).toHaveBeenCalled()
|
||||
expect(errorToastSpy).toHaveBeenCalled()
|
||||
|
||||
const successToastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
bulkEditPermsSpy.mockReturnValueOnce(of('OK'))
|
||||
modal.componentInstance.confirmClicked.emit()
|
||||
modal.componentInstance.confirmClicked.emit({
|
||||
permissions: {},
|
||||
merge: true,
|
||||
})
|
||||
expect(bulkEditPermsSpy).toHaveBeenCalled()
|
||||
expect(successToastSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support bulk delete objects', () => {
|
||||
const bulkEditSpy = jest.spyOn(tagService, 'bulk_edit_objects')
|
||||
component.toggleSelected(tags[0])
|
||||
component.toggleSelected(tags[1])
|
||||
const selected = new Set([tags[0].id, tags[1].id])
|
||||
expect(component.selectedObjects).toEqual(selected)
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
fixture.detectChanges()
|
||||
component.delete()
|
||||
expect(modal).not.toBeUndefined()
|
||||
|
||||
// fail first
|
||||
bulkEditSpy.mockReturnValueOnce(
|
||||
throwError(() => new Error('error setting permissions'))
|
||||
)
|
||||
const errorToastSpy = jest.spyOn(toastService, 'showError')
|
||||
modal.componentInstance.confirmClicked.emit(null)
|
||||
expect(bulkEditSpy).toHaveBeenCalledWith(
|
||||
Array.from(selected),
|
||||
BulkEditObjectOperation.Delete
|
||||
)
|
||||
expect(errorToastSpy).toHaveBeenCalled()
|
||||
|
||||
const successToastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
bulkEditSpy.mockReturnValueOnce(of('OK'))
|
||||
modal.componentInstance.confirmClicked.emit(null)
|
||||
expect(bulkEditSpy).toHaveBeenCalled()
|
||||
expect(successToastSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
@@ -15,10 +15,7 @@ import {
|
||||
MATCH_NONE,
|
||||
} from 'src/app/data/matching-model'
|
||||
import { ObjectWithId } from 'src/app/data/object-with-id'
|
||||
import {
|
||||
ObjectWithPermissions,
|
||||
PermissionsObject,
|
||||
} from 'src/app/data/object-with-permissions'
|
||||
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
||||
import {
|
||||
SortableDirective,
|
||||
SortEvent,
|
||||
@@ -28,7 +25,10 @@ import {
|
||||
PermissionsService,
|
||||
PermissionType,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { AbstractNameFilterService } from 'src/app/services/rest/abstract-name-filter-service'
|
||||
import {
|
||||
AbstractNameFilterService,
|
||||
BulkEditObjectOperation,
|
||||
} from 'src/app/services/rest/abstract-name-filter-service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
|
||||
@@ -194,34 +194,21 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
|
||||
])
|
||||
}
|
||||
|
||||
openDeleteDialog(object: T) {
|
||||
var activeModal = this.modalService.open(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
activeModal.componentInstance.title = $localize`Confirm delete`
|
||||
activeModal.componentInstance.messageBold = this.getDeleteMessage(object)
|
||||
activeModal.componentInstance.message = $localize`Associated documents will not be deleted.`
|
||||
activeModal.componentInstance.btnClass = 'btn-danger'
|
||||
activeModal.componentInstance.btnCaption = $localize`Delete`
|
||||
activeModal.componentInstance.confirmClicked.subscribe(() => {
|
||||
activeModal.componentInstance.buttonsEnabled = false
|
||||
this.service
|
||||
.delete(object)
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
activeModal.close()
|
||||
this.reloadData()
|
||||
},
|
||||
error: (error) => {
|
||||
activeModal.componentInstance.buttonsEnabled = true
|
||||
this.toastService.showError(
|
||||
$localize`Error while deleting element`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
deleteObject(object: T) {
|
||||
this.service
|
||||
.delete(object)
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.reloadData()
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error while deleting element`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
get nameFilter() {
|
||||
@@ -279,12 +266,14 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.confirmClicked.subscribe(
|
||||
(permissions: { owner: number; set_permissions: PermissionsObject }) => {
|
||||
({ permissions, merge }) => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.service
|
||||
.bulk_update_permissions(
|
||||
.bulk_edit_objects(
|
||||
Array.from(this.selectedObjects),
|
||||
permissions
|
||||
BulkEditObjectOperation.SetPermissions,
|
||||
permissions,
|
||||
merge
|
||||
)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
@@ -305,4 +294,37 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
delete() {
|
||||
let modal = this.modalService.open(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.title = $localize`Confirm delete`
|
||||
modal.componentInstance.messageBold = $localize`This operation will permanently delete all objects.`
|
||||
modal.componentInstance.message = $localize`This operation cannot be undone.`
|
||||
modal.componentInstance.btnClass = 'btn-danger'
|
||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.service
|
||||
.bulk_edit_objects(
|
||||
Array.from(this.selectedObjects),
|
||||
BulkEditObjectOperation.Delete
|
||||
)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
this.toastService.showInfo($localize`Objects deleted successfully`)
|
||||
this.reloadData()
|
||||
},
|
||||
error: (error) => {
|
||||
modal.componentInstance.buttonsEnabled = true
|
||||
this.toastService.showError(
|
||||
$localize`Error deleting objects`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@@ -33,16 +33,16 @@
|
||||
<div class="btn-group">
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Workflow }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editWorkflow(workflow)">
|
||||
<i-bs width="1em" height="1em" name="pencil"></i-bs> <ng-container i18n>Edit</ng-container>
|
||||
</button>
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Workflow }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteWorkflow(workflow)">
|
||||
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Workflow }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteWorkflow(workflow)">
|
||||
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
@if (workflows.length === 0) {
|
||||
<li class="list-group-item" i18n>No workflows defined.</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
@if (workflows.length === 0) {
|
||||
<li class="list-group-item" i18n>No workflows defined.</li>
|
||||
}
|
||||
</ul>
|
||||
|
@@ -1,7 +1,8 @@
|
||||
import { ObjectWithId } from './object-with-id'
|
||||
import { User } from './user'
|
||||
|
||||
export interface DocumentNote extends ObjectWithId {
|
||||
created?: Date
|
||||
note?: string
|
||||
user?: number // User
|
||||
user?: User
|
||||
}
|
||||
|
@@ -63,4 +63,7 @@ export interface Document extends ObjectWithPermissions {
|
||||
__search_hit__?: SearchHit
|
||||
|
||||
custom_fields?: CustomFieldInstance[]
|
||||
|
||||
// write-only field
|
||||
remove_inbox_tags?: boolean
|
||||
}
|
||||
|
@@ -53,6 +53,8 @@ export const SETTINGS_KEYS = {
|
||||
DEFAULT_PERMS_VIEW_GROUPS: 'general-settings:permissions:default-view-groups',
|
||||
DEFAULT_PERMS_EDIT_USERS: 'general-settings:permissions:default-edit-users',
|
||||
DEFAULT_PERMS_EDIT_GROUPS: 'general-settings:permissions:default-edit-groups',
|
||||
DOCUMENT_EDITING_REMOVE_INBOX_TAGS:
|
||||
'general-settings:document-editing:remove-inbox-tags',
|
||||
}
|
||||
|
||||
export const SETTINGS: UiSetting[] = [
|
||||
@@ -206,4 +208,9 @@ export const SETTINGS: UiSetting[] = [
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS,
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
]
|
||||
|
@@ -1,7 +1,20 @@
|
||||
export interface SocialAccount {
|
||||
id: number
|
||||
provider: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface SocialAccountProvider {
|
||||
name: string
|
||||
login_url: string
|
||||
}
|
||||
|
||||
export interface PaperlessUserProfile {
|
||||
email?: string
|
||||
password?: string
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
auth_token?: string
|
||||
social_accounts?: SocialAccount[]
|
||||
has_usable_password?: boolean
|
||||
}
|
||||
|
30
src-ui/src/app/services/django-messages.service.spec.ts
Normal file
30
src-ui/src/app/services/django-messages.service.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { TestBed } from '@angular/core/testing'
|
||||
|
||||
import {
|
||||
DjangoMessageLevel,
|
||||
DjangoMessagesService,
|
||||
} from './django-messages.service'
|
||||
|
||||
const messages = [
|
||||
{ level: DjangoMessageLevel.ERROR, message: 'Error Message' },
|
||||
{ level: DjangoMessageLevel.INFO, message: 'Info Message' },
|
||||
]
|
||||
|
||||
describe('DjangoMessagesService', () => {
|
||||
let service: DjangoMessagesService
|
||||
|
||||
beforeEach(() => {
|
||||
window['DJANGO_MESSAGES'] = messages
|
||||
TestBed.configureTestingModule({
|
||||
providers: [DjangoMessagesService],
|
||||
})
|
||||
service = TestBed.inject(DjangoMessagesService)
|
||||
})
|
||||
|
||||
it('should retrieve global django messages if present', () => {
|
||||
expect(service.get()).toEqual(messages)
|
||||
|
||||
window['DJANGO_MESSAGES'] = undefined
|
||||
expect(service.get()).toEqual([])
|
||||
})
|
||||
})
|
27
src-ui/src/app/services/django-messages.service.ts
Normal file
27
src-ui/src/app/services/django-messages.service.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
|
||||
// see https://docs.djangoproject.com/en/5.0/ref/contrib/messages/#message-tags
|
||||
export enum DjangoMessageLevel {
|
||||
DEBUG = 'debug',
|
||||
INFO = 'info',
|
||||
SUCCESS = 'success',
|
||||
WARNING = 'warning',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
export interface DjangoMessage {
|
||||
level: DjangoMessageLevel
|
||||
message: string
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class DjangoMessagesService {
|
||||
constructor() {}
|
||||
|
||||
get(): DjangoMessage[] {
|
||||
// These are embedded in the HTML as raw JS, the service is for convenience
|
||||
return window['DJANGO_MESSAGES'] ?? []
|
||||
}
|
||||
}
|
@@ -51,4 +51,20 @@ describe('ProfileService', () => {
|
||||
)
|
||||
expect(req.request.method).toEqual('POST')
|
||||
})
|
||||
|
||||
it('supports disconnecting a social account', () => {
|
||||
service.disconnectSocialAccount(1).subscribe()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}profile/disconnect_social_account/`
|
||||
)
|
||||
expect(req.request.method).toEqual('POST')
|
||||
})
|
||||
|
||||
it('calls get social account provider endpoint', () => {
|
||||
service.getSocialAccountProviders().subscribe()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}profile/social_account_providers/`
|
||||
)
|
||||
expect(req.request.method).toEqual('GET')
|
||||
})
|
||||
})
|
||||
|
@@ -1,7 +1,10 @@
|
||||
import { HttpClient } from '@angular/common/http'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { Observable } from 'rxjs'
|
||||
import { PaperlessUserProfile } from '../data/user-profile'
|
||||
import {
|
||||
PaperlessUserProfile,
|
||||
SocialAccountProvider,
|
||||
} from '../data/user-profile'
|
||||
import { environment } from 'src/environments/environment'
|
||||
|
||||
@Injectable({
|
||||
@@ -31,4 +34,17 @@ export class ProfileService {
|
||||
{}
|
||||
)
|
||||
}
|
||||
|
||||
disconnectSocialAccount(id: number): Observable<number> {
|
||||
return this.http.post<number>(
|
||||
`${environment.apiBaseUrl}${this.endpoint}/disconnect_social_account/`,
|
||||
{ id: id }
|
||||
)
|
||||
}
|
||||
|
||||
getSocialAccountProviders(): Observable<SocialAccountProvider[]> {
|
||||
return this.http.get<SocialAccountProvider[]>(
|
||||
`${environment.apiBaseUrl}${this.endpoint}/social_account_providers/`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@@ -2,7 +2,10 @@ import { HttpTestingController } from '@angular/common/http/testing'
|
||||
import { Subscription } from 'rxjs'
|
||||
import { TestBed } from '@angular/core/testing'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { AbstractNameFilterService } from './abstract-name-filter-service'
|
||||
import {
|
||||
AbstractNameFilterService,
|
||||
BulkEditObjectOperation,
|
||||
} from './abstract-name-filter-service'
|
||||
import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
|
||||
|
||||
let httpTestingController: HttpTestingController
|
||||
@@ -53,15 +56,44 @@ export const commonAbstractNameFilterPaperlessServiceTests = (
|
||||
},
|
||||
}
|
||||
subscription = service
|
||||
.bulk_update_permissions([1, 2], {
|
||||
owner,
|
||||
set_permissions: permissions,
|
||||
})
|
||||
.bulk_edit_objects(
|
||||
[1, 2],
|
||||
BulkEditObjectOperation.SetPermissions,
|
||||
{
|
||||
owner,
|
||||
set_permissions: permissions,
|
||||
},
|
||||
true
|
||||
)
|
||||
.subscribe()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}bulk_edit_object_perms/`
|
||||
`${environment.apiBaseUrl}bulk_edit_objects/`
|
||||
)
|
||||
expect(req.request.method).toEqual('POST')
|
||||
expect(req.request.body).toEqual({
|
||||
objects: [1, 2],
|
||||
object_type: endpoint,
|
||||
operation: BulkEditObjectOperation.SetPermissions,
|
||||
permissions,
|
||||
owner,
|
||||
merge: true,
|
||||
})
|
||||
req.flush([])
|
||||
})
|
||||
|
||||
test('should call appropriate api endpoint for bulk delete objects', () => {
|
||||
subscription = service
|
||||
.bulk_edit_objects([1, 2], BulkEditObjectOperation.Delete)
|
||||
.subscribe()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}bulk_edit_objects/`
|
||||
)
|
||||
expect(req.request.method).toEqual('POST')
|
||||
expect(req.request.body).toEqual({
|
||||
objects: [1, 2],
|
||||
object_type: endpoint,
|
||||
operation: BulkEditObjectOperation.Delete,
|
||||
})
|
||||
req.flush([])
|
||||
})
|
||||
})
|
||||
|
@@ -3,6 +3,11 @@ import { AbstractPaperlessService } from './abstract-paperless-service'
|
||||
import { PermissionsObject } from 'src/app/data/object-with-permissions'
|
||||
import { Observable } from 'rxjs'
|
||||
|
||||
export enum BulkEditObjectOperation {
|
||||
SetPermissions = 'set_permissions',
|
||||
Delete = 'delete',
|
||||
}
|
||||
|
||||
export abstract class AbstractNameFilterService<
|
||||
T extends ObjectWithId,
|
||||
> extends AbstractPaperlessService<T> {
|
||||
@@ -24,15 +29,22 @@ export abstract class AbstractNameFilterService<
|
||||
return this.list(page, pageSize, sortField, sortReverse, params)
|
||||
}
|
||||
|
||||
bulk_update_permissions(
|
||||
bulk_edit_objects(
|
||||
objects: Array<number>,
|
||||
permissions: { owner: number; set_permissions: PermissionsObject }
|
||||
operation: BulkEditObjectOperation,
|
||||
permissions: { owner: number; set_permissions: PermissionsObject } = null,
|
||||
merge: boolean = null
|
||||
): Observable<string> {
|
||||
return this.http.post<string>(`${this.baseUrl}bulk_edit_object_perms/`, {
|
||||
const params = {
|
||||
objects,
|
||||
object_type: this.resourceName,
|
||||
owner: permissions.owner,
|
||||
permissions: permissions.set_permissions,
|
||||
})
|
||||
operation,
|
||||
}
|
||||
if (operation === BulkEditObjectOperation.SetPermissions) {
|
||||
params['owner'] = permissions?.owner
|
||||
params['permissions'] = permissions?.set_permissions
|
||||
params['merge'] = merge
|
||||
}
|
||||
return this.http.post<string>(`${this.baseUrl}bulk_edit_objects/`, params)
|
||||
}
|
||||
}
|
||||
|
@@ -7,10 +7,13 @@ import { TestBed } from '@angular/core/testing'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { DocumentService } from './document.service'
|
||||
import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
|
||||
import { SettingsService } from '../settings.service'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
|
||||
let httpTestingController: HttpTestingController
|
||||
let service: DocumentService
|
||||
let subscription: Subscription
|
||||
let settingsService: SettingsService
|
||||
const endpoint = 'documents'
|
||||
const documents = [
|
||||
{
|
||||
@@ -34,6 +37,17 @@ const documents = [
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [DocumentService],
|
||||
imports: [HttpClientTestingModule],
|
||||
})
|
||||
|
||||
httpTestingController = TestBed.inject(HttpTestingController)
|
||||
service = TestBed.inject(DocumentService)
|
||||
settingsService = TestBed.inject(SettingsService)
|
||||
})
|
||||
|
||||
describe(`DocumentService`, () => {
|
||||
// common tests e.g. commonAbstractPaperlessServiceTests differ slightly
|
||||
it('should call appropriate api endpoint for list all', () => {
|
||||
@@ -237,16 +251,21 @@ describe(`DocumentService`, () => {
|
||||
)
|
||||
expect(req.request.method).toEqual('GET')
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [DocumentService],
|
||||
imports: [HttpClientTestingModule],
|
||||
it('should pass remove_inbox_tags setting to update', () => {
|
||||
subscription = service.update(documents[0]).subscribe()
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}${endpoint}/${documents[0].id}/`
|
||||
)
|
||||
expect(req.request.body.remove_inbox_tags).toEqual(false)
|
||||
|
||||
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS, true)
|
||||
subscription = service.update(documents[0]).subscribe()
|
||||
req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}${endpoint}/${documents[0].id}/`
|
||||
)
|
||||
expect(req.request.body.remove_inbox_tags).toEqual(true)
|
||||
})
|
||||
|
||||
httpTestingController = TestBed.inject(HttpTestingController)
|
||||
service = TestBed.inject(DocumentService)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
@@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'
|
||||
import { Document } from 'src/app/data/document'
|
||||
import { DocumentMetadata } from 'src/app/data/document-metadata'
|
||||
import { AbstractPaperlessService } from './abstract-paperless-service'
|
||||
import { HttpClient, HttpParams } from '@angular/common/http'
|
||||
import { HttpClient } from '@angular/common/http'
|
||||
import { Observable } from 'rxjs'
|
||||
import { Results } from 'src/app/data/results'
|
||||
import { FilterRule } from 'src/app/data/filter-rule'
|
||||
@@ -13,6 +13,13 @@ import { TagService } from './tag.service'
|
||||
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
|
||||
import { queryParamsFromFilterRules } from '../../utils/query-params'
|
||||
import { StoragePathService } from './storage-path.service'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionType,
|
||||
PermissionsService,
|
||||
} from '../permissions.service'
|
||||
import { SettingsService } from '../settings.service'
|
||||
import { SETTINGS, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
|
||||
export const DOCUMENT_SORT_FIELDS = [
|
||||
{ field: 'archive_serial_number', name: $localize`ASN` },
|
||||
@@ -57,21 +64,41 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
||||
private correspondentService: CorrespondentService,
|
||||
private documentTypeService: DocumentTypeService,
|
||||
private tagService: TagService,
|
||||
private storagePathService: StoragePathService
|
||||
private storagePathService: StoragePathService,
|
||||
private permissionsService: PermissionsService,
|
||||
private settingsService: SettingsService
|
||||
) {
|
||||
super(http, 'documents')
|
||||
}
|
||||
|
||||
addObservablesToDocument(doc: Document) {
|
||||
if (doc.correspondent) {
|
||||
if (
|
||||
doc.correspondent &&
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.Correspondent
|
||||
)
|
||||
) {
|
||||
doc.correspondent$ = this.correspondentService.getCached(
|
||||
doc.correspondent
|
||||
)
|
||||
}
|
||||
if (doc.document_type) {
|
||||
if (
|
||||
doc.document_type &&
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.DocumentType
|
||||
)
|
||||
) {
|
||||
doc.document_type$ = this.documentTypeService.getCached(doc.document_type)
|
||||
}
|
||||
if (doc.tags) {
|
||||
if (
|
||||
doc.tags &&
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.Tag
|
||||
)
|
||||
) {
|
||||
doc.tags$ = this.tagService
|
||||
.getCachedMany(doc.tags)
|
||||
.pipe(
|
||||
@@ -80,7 +107,13 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
||||
)
|
||||
)
|
||||
}
|
||||
if (doc.storage_path) {
|
||||
if (
|
||||
doc.storage_path &&
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.StoragePath
|
||||
)
|
||||
) {
|
||||
doc.storage_path$ = this.storagePathService.getCached(doc.storage_path)
|
||||
}
|
||||
return doc
|
||||
@@ -150,6 +183,9 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
||||
update(o: Document): Observable<Document> {
|
||||
// we want to only set created_date
|
||||
o.created = undefined
|
||||
o.remove_inbox_tags = this.settingsService.get(
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS
|
||||
)
|
||||
return super.update(o)
|
||||
}
|
||||
|
||||
|
@@ -131,6 +131,12 @@ const LANGUAGE_OPTIONS = [
|
||||
englishName: 'Italian',
|
||||
dateInputFormat: 'dd/mm/yyyy',
|
||||
},
|
||||
{
|
||||
code: 'ja-jp',
|
||||
name: $localize`Japanese`,
|
||||
englishName: 'Japanese',
|
||||
dateInputFormat: 'yyyy/mm/dd',
|
||||
},
|
||||
{
|
||||
code: 'lb-lu',
|
||||
name: $localize`Luxembourgish`,
|
||||
|
@@ -3,9 +3,9 @@ const base_url = new URL(document.baseURI)
|
||||
export const environment = {
|
||||
production: true,
|
||||
apiBaseUrl: document.baseURI + 'api/',
|
||||
apiVersion: '4',
|
||||
apiVersion: '5',
|
||||
appTitle: 'Paperless-ngx',
|
||||
version: '2.4.3',
|
||||
version: '2.5.3',
|
||||
webSocketHost: window.location.host,
|
||||
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
||||
webSocketBaseUrl: base_url.pathname + 'ws/',
|
||||
|
@@ -5,7 +5,7 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
apiBaseUrl: 'http://localhost:8000/api/',
|
||||
apiVersion: '4',
|
||||
apiVersion: '5',
|
||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user