mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-04-02 13:45:10 -05:00
Merge remote-tracking branch 'origin/dev'
This commit is contained in:
commit
45c5f81b34
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
|
||||
|
7
Pipfile
7
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,11 +46,11 @@ python-magic = "*"
|
||||
pyzbar = "*"
|
||||
rapidfuzz = "*"
|
||||
redis = {extras = ["hiredis"], version = "*"}
|
||||
scikit-learn = "~=1.3"
|
||||
scikit-learn = "~=1.4"
|
||||
setproctitle = "*"
|
||||
tika-client = "*"
|
||||
tqdm = "*"
|
||||
uvicorn = {extras = ["standard"], version = "*"}
|
||||
uvicorn = {extras = ["standard"], version = "==0.25.0"}
|
||||
watchdog = "~=3.0"
|
||||
whitenoise = "~=6.6"
|
||||
whoosh="~=2.7"
|
||||
|
1196
Pipfile.lock
generated
1196
Pipfile.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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.
|
||||
@ -628,3 +640,42 @@ 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.
|
||||
|
||||
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.
|
||||
|
@ -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).
|
||||
@ -463,9 +465,21 @@ applications.
|
||||
|
||||
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).
|
||||
@ -522,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 likely need to include the relevant Django 'application' inside the
|
||||
[PAPERLESS_APPS](#PAPERLESS_APPS) setting.
|
||||
|
||||
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/)
|
||||
@ -892,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}
|
||||
@ -1162,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}
|
||||
@ -1332,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}
|
||||
|
@ -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,
|
||||
@ -295,6 +296,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 +327,7 @@ registerLocaleData(localeFi)
|
||||
registerLocaleData(localeFr)
|
||||
registerLocaleData(localeHu)
|
||||
registerLocaleData(localeIt)
|
||||
registerLocaleData(localeJa)
|
||||
registerLocaleData(localeLb)
|
||||
registerLocaleData(localeNl)
|
||||
registerLocaleData(localeNo)
|
||||
@ -437,6 +440,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()
|
||||
}
|
||||
|
@ -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,
|
||||
@ -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>()
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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.4.3-dev',
|
||||
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',
|
||||
|
@ -14,6 +14,7 @@ from PIL import Image
|
||||
|
||||
from documents.converters import convert_from_tiff_to_pdf
|
||||
from documents.data_models import ConsumableDocument
|
||||
from documents.models import Tag
|
||||
from documents.plugins.base import ConsumeTaskPlugin
|
||||
from documents.plugins.base import StopConsumeTaskError
|
||||
from documents.plugins.helpers import ProgressStatusOptions
|
||||
@ -65,7 +66,9 @@ class BarcodePlugin(ConsumeTaskPlugin):
|
||||
supported_mimes = {"application/pdf"}
|
||||
|
||||
return (
|
||||
settings.CONSUMER_ENABLE_ASN_BARCODE or settings.CONSUMER_ENABLE_BARCODES
|
||||
settings.CONSUMER_ENABLE_ASN_BARCODE
|
||||
or settings.CONSUMER_ENABLE_BARCODES
|
||||
or settings.CONSUMER_ENABLE_TAG_BARCODE
|
||||
) and self.input_doc.mime_type in supported_mimes
|
||||
|
||||
def setup(self):
|
||||
@ -90,6 +93,16 @@ class BarcodePlugin(ConsumeTaskPlugin):
|
||||
logger.info(f"Found ASN in barcode: {located_asn}")
|
||||
self.metadata.asn = located_asn
|
||||
|
||||
# try reading tags from barcodes
|
||||
if settings.CONSUMER_ENABLE_TAG_BARCODE:
|
||||
tags = self.tags
|
||||
if tags is not None and len(tags) > 0:
|
||||
if self.metadata.tag_ids:
|
||||
self.metadata.tag_ids += tags
|
||||
else:
|
||||
self.metadata.tag_ids = tags
|
||||
logger.info(f"Found tags in barcode: {tags}")
|
||||
|
||||
separator_pages = self.get_separation_pages()
|
||||
if not separator_pages:
|
||||
return "No pages to split on!"
|
||||
@ -279,6 +292,53 @@ class BarcodePlugin(ConsumeTaskPlugin):
|
||||
|
||||
return asn
|
||||
|
||||
@property
|
||||
def tags(self) -> Optional[list[int]]:
|
||||
"""
|
||||
Search the parsed barcodes for any tags.
|
||||
Returns the detected tag ids (or empty list)
|
||||
"""
|
||||
tags = []
|
||||
|
||||
# Ensure the barcodes have been read
|
||||
self.detect()
|
||||
|
||||
for x in self.barcodes:
|
||||
tag_texts = x.value
|
||||
|
||||
for raw in tag_texts.split(","):
|
||||
try:
|
||||
tag = None
|
||||
for regex in settings.CONSUMER_TAG_BARCODE_MAPPING:
|
||||
if re.match(regex, raw, flags=re.IGNORECASE):
|
||||
sub = settings.CONSUMER_TAG_BARCODE_MAPPING[regex]
|
||||
tag = (
|
||||
re.sub(regex, sub, raw, flags=re.IGNORECASE)
|
||||
if sub
|
||||
else raw
|
||||
)
|
||||
break
|
||||
|
||||
if tag:
|
||||
tag = Tag.objects.get_or_create(
|
||||
name__iexact=tag,
|
||||
defaults={"name": tag},
|
||||
)[0]
|
||||
|
||||
logger.debug(
|
||||
f"Found Tag Barcode '{raw}', substituted "
|
||||
f"to '{tag}' and mapped to "
|
||||
f"tag #{tag.pk}.",
|
||||
)
|
||||
tags.append(tag.pk)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to find or create TAG '{raw}' because: {e}",
|
||||
)
|
||||
|
||||
return tags
|
||||
|
||||
def get_separation_pages(self) -> dict[int, bool]:
|
||||
"""
|
||||
Search the parsed barcodes for separators and returns a dict of page
|
||||
|
@ -129,13 +129,17 @@ def redo_ocr(doc_ids):
|
||||
return "OK"
|
||||
|
||||
|
||||
def set_permissions(doc_ids, set_permissions, owner=None):
|
||||
def set_permissions(doc_ids, set_permissions, owner=None, merge=False):
|
||||
qs = Document.objects.filter(id__in=doc_ids)
|
||||
|
||||
qs.update(owner=owner)
|
||||
if merge:
|
||||
# If merging, only set owner for documents that don't have an owner
|
||||
qs.filter(owner__isnull=True).update(owner=owner)
|
||||
else:
|
||||
qs.update(owner=owner)
|
||||
|
||||
for doc in qs:
|
||||
set_permissions_for_object(set_permissions, doc)
|
||||
set_permissions_for_object(permissions=set_permissions, object=doc, merge=merge)
|
||||
|
||||
affected_docs = [doc.id for doc in qs]
|
||||
|
||||
|
196
src/documents/caching.py
Normal file
196
src/documents/caching.py
Normal file
@ -0,0 +1,196 @@
|
||||
import logging
|
||||
from binascii import hexlify
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Final
|
||||
from typing import Optional
|
||||
|
||||
from django.core.cache import cache
|
||||
|
||||
from documents.models import Document
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from documents.classifier import DocumentClassifier
|
||||
|
||||
logger = logging.getLogger("paperless.caching")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MetadataCacheData:
|
||||
original_checksum: str
|
||||
original_metadata: list
|
||||
archive_checksum: Optional[str]
|
||||
archive_metadata: Optional[list]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SuggestionCacheData:
|
||||
classifier_version: int
|
||||
classifier_hash: str
|
||||
suggestions: dict
|
||||
|
||||
|
||||
CLASSIFIER_VERSION_KEY: Final[str] = "classifier_version"
|
||||
CLASSIFIER_HASH_KEY: Final[str] = "classifier_hash"
|
||||
CLASSIFIER_MODIFIED_KEY: Final[str] = "classifier_modified"
|
||||
|
||||
CACHE_1_MINUTE: Final[int] = 60
|
||||
CACHE_5_MINUTES: Final[int] = 5 * CACHE_1_MINUTE
|
||||
CACHE_50_MINUTES: Final[int] = 50 * CACHE_1_MINUTE
|
||||
|
||||
|
||||
def get_suggestion_cache_key(document_id: int) -> str:
|
||||
"""
|
||||
Returns the basic key for a document's suggestions
|
||||
"""
|
||||
return f"doc_{document_id}_suggest"
|
||||
|
||||
|
||||
def get_suggestion_cache(document_id: int) -> Optional[SuggestionCacheData]:
|
||||
"""
|
||||
If possible, return the cached suggestions for the given document ID.
|
||||
The classifier needs to be matching in format and hash and the suggestions need to
|
||||
have been cached once.
|
||||
"""
|
||||
from documents.classifier import DocumentClassifier
|
||||
|
||||
doc_key = get_suggestion_cache_key(document_id)
|
||||
cache_hits = cache.get_many([CLASSIFIER_VERSION_KEY, CLASSIFIER_HASH_KEY, doc_key])
|
||||
# The document suggestions are in the cache
|
||||
if doc_key in cache_hits:
|
||||
doc_suggestions: SuggestionCacheData = cache_hits[doc_key]
|
||||
# The classifier format is the same
|
||||
# The classifier hash is the same
|
||||
# Then the suggestions can be used
|
||||
if (
|
||||
CLASSIFIER_VERSION_KEY in cache_hits
|
||||
and cache_hits[CLASSIFIER_VERSION_KEY] == DocumentClassifier.FORMAT_VERSION
|
||||
and cache_hits[CLASSIFIER_VERSION_KEY] == doc_suggestions.classifier_version
|
||||
) and (
|
||||
CLASSIFIER_HASH_KEY in cache_hits
|
||||
and cache_hits[CLASSIFIER_HASH_KEY] == doc_suggestions.classifier_hash
|
||||
):
|
||||
return doc_suggestions
|
||||
else: # pragma: no cover
|
||||
# Remove the key because something didn't match
|
||||
cache.delete(doc_key)
|
||||
return None
|
||||
|
||||
|
||||
def set_suggestions_cache(
|
||||
document_id: int,
|
||||
suggestions: dict,
|
||||
classifier: Optional["DocumentClassifier"],
|
||||
*,
|
||||
timeout=CACHE_50_MINUTES,
|
||||
) -> None:
|
||||
"""
|
||||
Caches the given suggestions, which were generated by the given classifier. If there is no classifier,
|
||||
this function is a no-op (there won't be suggestions then anyway)
|
||||
"""
|
||||
if classifier is not None:
|
||||
doc_key = get_suggestion_cache_key(document_id)
|
||||
cache.set(
|
||||
doc_key,
|
||||
SuggestionCacheData(
|
||||
classifier.FORMAT_VERSION,
|
||||
hexlify(classifier.last_auto_type_hash).decode(),
|
||||
suggestions,
|
||||
),
|
||||
timeout,
|
||||
)
|
||||
|
||||
|
||||
def refresh_suggestions_cache(
|
||||
document_id: int,
|
||||
*,
|
||||
timeout: int = CACHE_50_MINUTES,
|
||||
) -> None:
|
||||
"""
|
||||
Refreshes the expiration of the suggestions for the given document ID
|
||||
to the given timeout
|
||||
"""
|
||||
doc_key = get_suggestion_cache_key(document_id)
|
||||
cache.touch(doc_key, timeout)
|
||||
|
||||
|
||||
def get_metadata_cache_key(document_id: int) -> str:
|
||||
"""
|
||||
Returns the basic key for a document's metadata
|
||||
"""
|
||||
return f"doc_{document_id}_metadata"
|
||||
|
||||
|
||||
def get_metadata_cache(document_id: int) -> Optional[MetadataCacheData]:
|
||||
"""
|
||||
Returns the cached document metadata for the given document ID, as long as the metadata
|
||||
was cached once and the checksums have not changed
|
||||
"""
|
||||
doc_key = get_metadata_cache_key(document_id)
|
||||
doc_metadata: Optional[MetadataCacheData] = cache.get(doc_key)
|
||||
# The metadata exists in the cache
|
||||
if doc_metadata is not None:
|
||||
try:
|
||||
doc = Document.objects.get(pk=document_id)
|
||||
# The original checksums match
|
||||
# If it has one, the archive checksums match
|
||||
# Then, we can use the metadata
|
||||
if (
|
||||
doc_metadata.original_checksum == doc.checksum
|
||||
and doc.has_archive_version
|
||||
and doc_metadata.archive_checksum is not None
|
||||
and doc_metadata.archive_checksum == doc.archive_checksum
|
||||
):
|
||||
# Refresh cache
|
||||
cache.touch(doc_key, CACHE_50_MINUTES)
|
||||
return doc_metadata
|
||||
else: # pragma: no cover
|
||||
# Something didn't match, delete the key
|
||||
cache.delete(doc_key)
|
||||
except Document.DoesNotExist: # pragma: no cover
|
||||
# Basically impossible, but the key existed, but the Document didn't
|
||||
cache.delete(doc_key)
|
||||
return None
|
||||
|
||||
|
||||
def set_metadata_cache(
|
||||
document: Document,
|
||||
original_metadata: list,
|
||||
archive_metadata: Optional[list],
|
||||
*,
|
||||
timeout=CACHE_50_MINUTES,
|
||||
) -> None:
|
||||
"""
|
||||
Sets the metadata into cache for the given Document
|
||||
"""
|
||||
doc_key = get_metadata_cache_key(document.pk)
|
||||
cache.set(
|
||||
doc_key,
|
||||
MetadataCacheData(
|
||||
document.checksum,
|
||||
original_metadata,
|
||||
document.archive_checksum,
|
||||
archive_metadata,
|
||||
),
|
||||
timeout,
|
||||
)
|
||||
|
||||
|
||||
def refresh_metadata_cache(
|
||||
document_id: int,
|
||||
*,
|
||||
timeout: int = CACHE_50_MINUTES,
|
||||
) -> None:
|
||||
"""
|
||||
Refreshes the expiration of the metadata for the given document ID
|
||||
to the given timeout
|
||||
"""
|
||||
doc_key = get_metadata_cache_key(document_id)
|
||||
cache.touch(doc_key, timeout)
|
||||
|
||||
|
||||
def get_thumbnail_modified_key(document_id: int) -> str:
|
||||
"""
|
||||
Builds the key to store a thumbnail's timestamp
|
||||
"""
|
||||
return f"doc_{document_id}_thumbnail_modified"
|
@ -4,14 +4,22 @@ import pickle
|
||||
import re
|
||||
import warnings
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from sklearn.exceptions import InconsistentVersionWarning
|
||||
|
||||
from documents.caching import CACHE_50_MINUTES
|
||||
from documents.caching import CLASSIFIER_HASH_KEY
|
||||
from documents.caching import CLASSIFIER_MODIFIED_KEY
|
||||
from documents.caching import CLASSIFIER_VERSION_KEY
|
||||
from documents.models import Document
|
||||
from documents.models import MatchingModel
|
||||
|
||||
@ -208,6 +216,15 @@ class DocumentClassifier:
|
||||
and self.last_doc_change_time >= latest_doc_change
|
||||
) and self.last_auto_type_hash == hasher.digest():
|
||||
logger.info("No updates since last training")
|
||||
# Set the classifier information into the cache
|
||||
# Caching for 50 minutes, so slightly less than the normal retrain time
|
||||
cache.set(
|
||||
CLASSIFIER_MODIFIED_KEY,
|
||||
self.last_doc_change_time,
|
||||
CACHE_50_MINUTES,
|
||||
)
|
||||
cache.set(CLASSIFIER_HASH_KEY, hasher.hexdigest(), CACHE_50_MINUTES)
|
||||
cache.set(CLASSIFIER_VERSION_KEY, self.FORMAT_VERSION, CACHE_50_MINUTES)
|
||||
return False
|
||||
|
||||
# subtract 1 since -1 (null) is also part of the classes.
|
||||
@ -322,6 +339,12 @@ class DocumentClassifier:
|
||||
self.last_doc_change_time = latest_doc_change
|
||||
self.last_auto_type_hash = hasher.digest()
|
||||
|
||||
# Set the classifier information into the cache
|
||||
# Caching for 50 minutes, so slightly less than the normal retrain time
|
||||
cache.set(CLASSIFIER_MODIFIED_KEY, self.last_doc_change_time, CACHE_50_MINUTES)
|
||||
cache.set(CLASSIFIER_HASH_KEY, hasher.hexdigest(), CACHE_50_MINUTES)
|
||||
cache.set(CLASSIFIER_VERSION_KEY, self.FORMAT_VERSION, CACHE_50_MINUTES)
|
||||
|
||||
return True
|
||||
|
||||
def preprocess_content(self, content: str) -> str: # pragma: no cover
|
||||
|
@ -1,9 +1,16 @@
|
||||
import pickle
|
||||
from datetime import datetime
|
||||
from datetime import timezone
|
||||
from typing import Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
from documents.caching import CACHE_5_MINUTES
|
||||
from documents.caching import CACHE_50_MINUTES
|
||||
from documents.caching import CLASSIFIER_HASH_KEY
|
||||
from documents.caching import CLASSIFIER_MODIFIED_KEY
|
||||
from documents.caching import CLASSIFIER_VERSION_KEY
|
||||
from documents.caching import get_thumbnail_modified_key
|
||||
from documents.classifier import DocumentClassifier
|
||||
from documents.models import Document
|
||||
|
||||
@ -14,18 +21,25 @@ def suggestions_etag(request, pk: int) -> Optional[str]:
|
||||
suggestions if the classifier has not been changed and the suggested dates
|
||||
setting is also unchanged
|
||||
|
||||
TODO: It would be nice to not duplicate the partial loading and the loading
|
||||
between here and the actual classifier
|
||||
"""
|
||||
# If no model file, no etag at all
|
||||
if not settings.MODEL_FILE.exists():
|
||||
return None
|
||||
with open(settings.MODEL_FILE, "rb") as f:
|
||||
schema_version = pickle.load(f)
|
||||
if schema_version != DocumentClassifier.FORMAT_VERSION:
|
||||
return None
|
||||
_ = pickle.load(f)
|
||||
last_auto_type_hash: bytes = pickle.load(f)
|
||||
return f"{last_auto_type_hash}:{settings.NUMBER_OF_SUGGESTED_DATES}"
|
||||
# Check cache information
|
||||
cache_hits = cache.get_many(
|
||||
[CLASSIFIER_VERSION_KEY, CLASSIFIER_HASH_KEY],
|
||||
)
|
||||
# If the version differs somehow, no etag
|
||||
if (
|
||||
CLASSIFIER_VERSION_KEY in cache_hits
|
||||
and cache_hits[CLASSIFIER_VERSION_KEY] != DocumentClassifier.FORMAT_VERSION
|
||||
):
|
||||
return None
|
||||
elif CLASSIFIER_HASH_KEY in cache_hits:
|
||||
# Refresh the cache and return the hash digest and the dates setting
|
||||
cache.touch(CLASSIFIER_HASH_KEY, CACHE_5_MINUTES)
|
||||
return f"{cache_hits[CLASSIFIER_HASH_KEY]}:{settings.NUMBER_OF_SUGGESTED_DATES}"
|
||||
return None
|
||||
|
||||
|
||||
def suggestions_last_modified(request, pk: int) -> Optional[datetime]:
|
||||
@ -34,14 +48,23 @@ def suggestions_last_modified(request, pk: int) -> Optional[datetime]:
|
||||
as there is not way to track the suggested date setting modification, but it seems
|
||||
unlikely that changes too often
|
||||
"""
|
||||
# No file, no last modified
|
||||
if not settings.MODEL_FILE.exists():
|
||||
return None
|
||||
with open(settings.MODEL_FILE, "rb") as f:
|
||||
schema_version = pickle.load(f)
|
||||
if schema_version != DocumentClassifier.FORMAT_VERSION:
|
||||
return None
|
||||
last_doc_change_time = pickle.load(f)
|
||||
return last_doc_change_time
|
||||
cache_hits = cache.get_many(
|
||||
[CLASSIFIER_VERSION_KEY, CLASSIFIER_MODIFIED_KEY],
|
||||
)
|
||||
# If the version differs somehow, no last modified
|
||||
if (
|
||||
CLASSIFIER_VERSION_KEY in cache_hits
|
||||
and cache_hits[CLASSIFIER_VERSION_KEY] != DocumentClassifier.FORMAT_VERSION
|
||||
):
|
||||
return None
|
||||
elif CLASSIFIER_MODIFIED_KEY in cache_hits:
|
||||
# Refresh the cache and return the last modified
|
||||
cache.touch(CLASSIFIER_MODIFIED_KEY, CACHE_5_MINUTES)
|
||||
return cache_hits[CLASSIFIER_MODIFIED_KEY]
|
||||
return None
|
||||
|
||||
|
||||
def metadata_etag(request, pk: int) -> Optional[str]:
|
||||
@ -52,7 +75,7 @@ def metadata_etag(request, pk: int) -> Optional[str]:
|
||||
try:
|
||||
doc = Document.objects.get(pk=pk)
|
||||
return doc.checksum
|
||||
except Document.DoesNotExist:
|
||||
except Document.DoesNotExist: # pragma: no cover
|
||||
return None
|
||||
return None
|
||||
|
||||
@ -66,7 +89,7 @@ def metadata_last_modified(request, pk: int) -> Optional[datetime]:
|
||||
try:
|
||||
doc = Document.objects.get(pk=pk)
|
||||
return doc.modified
|
||||
except Document.DoesNotExist:
|
||||
except Document.DoesNotExist: # pragma: no cover
|
||||
return None
|
||||
return None
|
||||
|
||||
@ -82,6 +105,46 @@ def preview_etag(request, pk: int) -> Optional[str]:
|
||||
and request.query_params["original"] == "true"
|
||||
)
|
||||
return doc.checksum if use_original else doc.archive_checksum
|
||||
except Document.DoesNotExist:
|
||||
except Document.DoesNotExist: # pragma: no cover
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def preview_last_modified(request, pk: int) -> Optional[datetime]:
|
||||
"""
|
||||
Uses the documents modified time to set the Last-Modified header. Not strictly
|
||||
speaking correct, but close enough and quick
|
||||
"""
|
||||
try:
|
||||
doc = Document.objects.get(pk=pk)
|
||||
return doc.modified
|
||||
except Document.DoesNotExist: # pragma: no cover
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def thumbnail_last_modified(request, pk: int) -> Optional[datetime]:
|
||||
"""
|
||||
Returns the filesystem last modified either from cache or from filesystem.
|
||||
Cache should be (slightly?) faster than filesystem
|
||||
"""
|
||||
try:
|
||||
doc = Document.objects.get(pk=pk)
|
||||
if not doc.thumbnail_path.exists():
|
||||
return None
|
||||
doc_key = get_thumbnail_modified_key(pk)
|
||||
|
||||
cache_hit = cache.get(doc_key)
|
||||
if cache_hit is not None:
|
||||
cache.touch(doc_key, CACHE_50_MINUTES)
|
||||
return cache_hit
|
||||
|
||||
# No cache, get the timestamp and cache the datetime
|
||||
last_modified = datetime.fromtimestamp(
|
||||
doc.thumbnail_path.stat().st_mtime,
|
||||
tz=timezone.utc,
|
||||
)
|
||||
cache.set(doc_key, last_modified, CACHE_50_MINUTES)
|
||||
return last_modified
|
||||
except Document.DoesNotExist: # pragma: no cover
|
||||
return None
|
||||
|
@ -233,10 +233,10 @@ class Consumer(LoggingMixin):
|
||||
"""
|
||||
Ensure all required directories exist before attempting to use them
|
||||
"""
|
||||
os.makedirs(settings.SCRATCH_DIR, exist_ok=True)
|
||||
os.makedirs(settings.THUMBNAIL_DIR, exist_ok=True)
|
||||
os.makedirs(settings.ORIGINALS_DIR, exist_ok=True)
|
||||
os.makedirs(settings.ARCHIVE_DIR, exist_ok=True)
|
||||
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
settings.THUMBNAIL_DIR.mkdir(parents=True, exist_ok=True)
|
||||
settings.ORIGINALS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
settings.ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def pre_check_asn_value(self):
|
||||
"""
|
||||
|
@ -88,7 +88,7 @@ def open_index(recreate=False) -> FileIndex:
|
||||
logger.exception("Error while opening the index, recreating.")
|
||||
|
||||
if not os.path.isdir(settings.INDEX_DIR):
|
||||
os.makedirs(settings.INDEX_DIR, exist_ok=True)
|
||||
settings.INDEX_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return create_in(settings.INDEX_DIR, get_schema())
|
||||
|
||||
|
||||
|
@ -1,6 +1,5 @@
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
|
||||
import tqdm
|
||||
from django import db
|
||||
@ -52,7 +51,7 @@ class Command(MultiProcessMixin, ProgressBarMixin, BaseCommand):
|
||||
self.handle_processes_mixin(**options)
|
||||
self.handle_progress_bar_mixin(**options)
|
||||
|
||||
os.makedirs(settings.SCRATCH_DIR, exist_ok=True)
|
||||
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
overwrite = options["overwrite"]
|
||||
|
||||
|
@ -182,7 +182,7 @@ class Command(BaseCommand):
|
||||
if self.zip_export:
|
||||
self.original_target = self.target
|
||||
|
||||
os.makedirs(settings.SCRATCH_DIR, exist_ok=True)
|
||||
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
temp_dir = tempfile.TemporaryDirectory(
|
||||
dir=settings.SCRATCH_DIR,
|
||||
prefix="paperless-export",
|
||||
|
@ -243,9 +243,9 @@ class Command(BaseCommand):
|
||||
) from e
|
||||
|
||||
def _import_files_from_manifest(self, progress_bar_disable):
|
||||
os.makedirs(settings.ORIGINALS_DIR, exist_ok=True)
|
||||
os.makedirs(settings.THUMBNAIL_DIR, exist_ok=True)
|
||||
os.makedirs(settings.ARCHIVE_DIR, exist_ok=True)
|
||||
settings.ORIGINALS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
settings.THUMBNAIL_DIR.mkdir(parents=True, exist_ok=True)
|
||||
settings.ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.stdout.write("Copy files into paperless...")
|
||||
|
||||
|
@ -69,8 +69,6 @@ class Command(ProgressBarMixin, BaseCommand):
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.handle_progress_bar_mixin(**options)
|
||||
# Detect if we support color
|
||||
color = self.style.ERROR("test") != "test"
|
||||
|
||||
if options["inbox_only"]:
|
||||
queryset = Document.objects.filter(tags__is_inbox_tag=True)
|
||||
@ -96,7 +94,8 @@ class Command(ProgressBarMixin, BaseCommand):
|
||||
use_first=options["use_first"],
|
||||
suggest=options["suggest"],
|
||||
base_url=options["base_url"],
|
||||
color=color,
|
||||
stdout=self.stdout,
|
||||
style_func=self.style,
|
||||
)
|
||||
|
||||
if options["document_type"]:
|
||||
@ -108,7 +107,8 @@ class Command(ProgressBarMixin, BaseCommand):
|
||||
use_first=options["use_first"],
|
||||
suggest=options["suggest"],
|
||||
base_url=options["base_url"],
|
||||
color=color,
|
||||
stdout=self.stdout,
|
||||
style_func=self.style,
|
||||
)
|
||||
|
||||
if options["tags"]:
|
||||
@ -119,7 +119,8 @@ class Command(ProgressBarMixin, BaseCommand):
|
||||
replace=options["overwrite"],
|
||||
suggest=options["suggest"],
|
||||
base_url=options["base_url"],
|
||||
color=color,
|
||||
stdout=self.stdout,
|
||||
style_func=self.style,
|
||||
)
|
||||
if options["storage_path"]:
|
||||
set_storage_path(
|
||||
@ -130,5 +131,6 @@ class Command(ProgressBarMixin, BaseCommand):
|
||||
use_first=options["use_first"],
|
||||
suggest=options["suggest"],
|
||||
base_url=options["base_url"],
|
||||
color=color,
|
||||
stdout=self.stdout,
|
||||
style_func=self.style,
|
||||
)
|
||||
|
@ -19,7 +19,7 @@ def _process_document(doc_id):
|
||||
if parser_class:
|
||||
parser = parser_class(logging_group=None)
|
||||
else:
|
||||
print(f"{document} No parser for mime type {document.mime_type}")
|
||||
print(f"{document} No parser for mime type {document.mime_type}") # noqa: T201
|
||||
return
|
||||
|
||||
try:
|
||||
|
@ -258,7 +258,9 @@ def consumable_document_matches_workflow(
|
||||
reason = ""
|
||||
|
||||
# Document source vs trigger source
|
||||
if document.source not in [int(x) for x in list(trigger.sources)]:
|
||||
if len(trigger.sources) > 0 and document.source not in [
|
||||
int(x) for x in list(trigger.sources)
|
||||
]:
|
||||
reason = (
|
||||
f"Document source {document.source.name} not in"
|
||||
f" {[DocumentSource(int(x)).name for x in trigger.sources]}",
|
||||
|
@ -140,6 +140,7 @@ def run_convert(
|
||||
type=None,
|
||||
depth=None,
|
||||
auto_orient=False,
|
||||
use_cropbox=False,
|
||||
extra=None,
|
||||
logging_group=None,
|
||||
) -> None:
|
||||
@ -158,6 +159,7 @@ def run_convert(
|
||||
args += ["-type", str(type)] if type else []
|
||||
args += ["-depth", str(depth)] if depth else []
|
||||
args += ["-auto-orient"] if auto_orient else []
|
||||
args += ["-define", "pdf:use-cropbox=true"] if use_cropbox else []
|
||||
args += [input_file, output_file]
|
||||
|
||||
logger.debug("Execute: " + " ".join(args), extra={"group": logging_group})
|
||||
@ -229,6 +231,7 @@ def make_thumbnail_from_pdf(in_path, temp_dir, logging_group=None) -> str:
|
||||
strip=True,
|
||||
trim=False,
|
||||
auto_orient=True,
|
||||
use_cropbox=True,
|
||||
input_file=f"{in_path}[0]",
|
||||
output_file=out_path,
|
||||
logging_group=logging_group,
|
||||
@ -319,7 +322,7 @@ class DocumentParser(LoggingMixin):
|
||||
super().__init__()
|
||||
self.logging_group = logging_group
|
||||
self.settings = self.get_settings()
|
||||
os.makedirs(settings.SCRATCH_DIR, exist_ok=True)
|
||||
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
self.tempdir = Path(
|
||||
tempfile.mkdtemp(prefix="paperless-", dir=settings.SCRATCH_DIR),
|
||||
)
|
||||
|
@ -5,7 +5,9 @@ from typing import Union
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from channels_redis.pubsub import RedisPubSubChannelLayer
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from channels_redis.pubsub import RedisPubSubChannelLayer
|
||||
|
||||
|
||||
class ProgressStatusOptions(str, enum.Enum):
|
||||
|
@ -81,14 +81,15 @@ class MatchingModelSerializer(serializers.ModelSerializer):
|
||||
slug = SerializerMethodField()
|
||||
|
||||
def validate(self, data):
|
||||
# see https://github.com/encode/django-rest-framework/issues/7173
|
||||
name = data["name"] if "name" in data else self.instance.name
|
||||
# TODO: remove pending https://github.com/encode/django-rest-framework/issues/7173
|
||||
name = data.get(
|
||||
"name",
|
||||
self.instance.name if hasattr(self.instance, "name") else None,
|
||||
)
|
||||
owner = (
|
||||
data["owner"]
|
||||
if "owner" in data
|
||||
else self.user
|
||||
if hasattr(self, "user")
|
||||
else None
|
||||
else self.user if hasattr(self, "user") else None
|
||||
)
|
||||
pk = self.instance.pk if hasattr(self.instance, "pk") else None
|
||||
if ("name" in data or "owner" in data) and self.Meta.model.objects.filter(
|
||||
@ -261,7 +262,7 @@ class OwnedObjectSerializer(serializers.ModelSerializer, SetPermissionsMixin):
|
||||
if "set_permissions" in validated_data:
|
||||
self._set_permissions(validated_data["set_permissions"], instance)
|
||||
if "owner" in validated_data and "name" in self.Meta.fields:
|
||||
name = validated_data["name"] if "name" in validated_data else instance.name
|
||||
name = validated_data.get("name", instance.name)
|
||||
not_unique = (
|
||||
self.Meta.model.objects.exclude(pk=instance.pk)
|
||||
.filter(owner=validated_data["owner"], name=name)
|
||||
@ -441,6 +442,20 @@ class CustomFieldSerializer(serializers.ModelSerializer):
|
||||
"data_type",
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
# TODO: remove pending https://github.com/encode/django-rest-framework/issues/7173
|
||||
name = attrs.get(
|
||||
"name",
|
||||
self.instance.name if hasattr(self.instance, "name") else None,
|
||||
)
|
||||
if ("name" in attrs) and self.Meta.model.objects.filter(
|
||||
name=name,
|
||||
).exists():
|
||||
raise serializers.ValidationError(
|
||||
{"error": "Object violates name unique constraint"},
|
||||
)
|
||||
return super().validate(attrs)
|
||||
|
||||
|
||||
class ReadWriteSerializerMethodField(serializers.SerializerMethodField):
|
||||
"""
|
||||
@ -638,6 +653,11 @@ class DocumentSerializer(
|
||||
allow_null=True,
|
||||
)
|
||||
|
||||
remove_inbox_tags = serializers.BooleanField(
|
||||
default=False,
|
||||
write_only=True,
|
||||
)
|
||||
|
||||
def get_original_file_name(self, obj):
|
||||
return obj.original_filename
|
||||
|
||||
@ -681,12 +701,45 @@ class DocumentSerializer(
|
||||
custom_field_instance.field,
|
||||
doc_id,
|
||||
)
|
||||
if validated_data.get("remove_inbox_tags"):
|
||||
tag_ids_being_added = (
|
||||
[
|
||||
tag.id
|
||||
for tag in validated_data["tags"]
|
||||
if tag not in instance.tags.all()
|
||||
]
|
||||
if "tags" in validated_data
|
||||
else []
|
||||
)
|
||||
inbox_tags_not_being_added = Tag.objects.filter(is_inbox_tag=True).exclude(
|
||||
id__in=tag_ids_being_added,
|
||||
)
|
||||
if "tags" in validated_data:
|
||||
validated_data["tags"] = [
|
||||
tag
|
||||
for tag in validated_data["tags"]
|
||||
if tag not in inbox_tags_not_being_added
|
||||
]
|
||||
else:
|
||||
validated_data["tags"] = [
|
||||
tag
|
||||
for tag in instance.tags.all()
|
||||
if tag not in inbox_tags_not_being_added
|
||||
]
|
||||
super().update(instance, validated_data)
|
||||
return instance
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.truncate_content = kwargs.pop("truncate_content", False)
|
||||
|
||||
# return full permissions if we're doing a PATCH or PUT
|
||||
context = kwargs.get("context")
|
||||
if (
|
||||
context.get("request").method == "PATCH"
|
||||
or context.get("request").method == "PUT"
|
||||
):
|
||||
kwargs["full_perms"] = True
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
@ -714,6 +767,7 @@ class DocumentSerializer(
|
||||
"set_permissions",
|
||||
"notes",
|
||||
"custom_fields",
|
||||
"remove_inbox_tags",
|
||||
)
|
||||
|
||||
|
||||
@ -916,6 +970,8 @@ class BulkEditSerializer(DocumentListSerializer, SetPermissionsMixin):
|
||||
)
|
||||
if "owner" in parameters and parameters["owner"] is not None:
|
||||
self._validate_owner(parameters["owner"])
|
||||
if "merge" not in parameters:
|
||||
parameters["merge"] = False
|
||||
|
||||
def validate(self, attrs):
|
||||
method = attrs["method"]
|
||||
@ -1225,7 +1281,7 @@ class ShareLinkSerializer(OwnedObjectSerializer):
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class BulkEditObjectPermissionsSerializer(serializers.Serializer, SetPermissionsMixin):
|
||||
class BulkEditObjectsSerializer(serializers.Serializer, SetPermissionsMixin):
|
||||
objects = serializers.ListField(
|
||||
required=True,
|
||||
allow_empty=False,
|
||||
@ -1245,6 +1301,16 @@ class BulkEditObjectPermissionsSerializer(serializers.Serializer, SetPermissions
|
||||
write_only=True,
|
||||
)
|
||||
|
||||
operation = serializers.ChoiceField(
|
||||
choices=[
|
||||
"set_permissions",
|
||||
"delete",
|
||||
],
|
||||
label="Operation",
|
||||
required=True,
|
||||
write_only=True,
|
||||
)
|
||||
|
||||
owner = serializers.PrimaryKeyRelatedField(
|
||||
queryset=User.objects.all(),
|
||||
required=False,
|
||||
@ -1258,6 +1324,12 @@ class BulkEditObjectPermissionsSerializer(serializers.Serializer, SetPermissions
|
||||
write_only=True,
|
||||
)
|
||||
|
||||
merge = serializers.BooleanField(
|
||||
default=False,
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
def get_object_class(self, object_type):
|
||||
object_class = None
|
||||
if object_type == "tags":
|
||||
@ -1291,11 +1363,14 @@ class BulkEditObjectPermissionsSerializer(serializers.Serializer, SetPermissions
|
||||
def validate(self, attrs):
|
||||
object_type = attrs["object_type"]
|
||||
objects = attrs["objects"]
|
||||
permissions = attrs["permissions"] if "permissions" in attrs else None
|
||||
operation = attrs.get("operation")
|
||||
|
||||
self._validate_objects(objects, object_type)
|
||||
if permissions is not None:
|
||||
self._validate_permissions(permissions)
|
||||
|
||||
if operation == "set_permissions":
|
||||
permissions = attrs.get("permissions")
|
||||
if permissions is not None:
|
||||
self._validate_permissions(permissions)
|
||||
|
||||
return attrs
|
||||
|
||||
@ -1335,9 +1410,6 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
if ("filter_mailrule") in attrs and attrs["filter_mailrule"] is not None:
|
||||
attrs["sources"] = {DocumentSource.MailFetch.value}
|
||||
|
||||
# Empty strings treated as None to avoid unexpected behavior
|
||||
if (
|
||||
"filter_filename" in attrs
|
||||
@ -1453,7 +1525,7 @@ class WorkflowSerializer(serializers.ModelSerializer):
|
||||
for trigger in triggers:
|
||||
filter_has_tags = trigger.pop("filter_has_tags", None)
|
||||
trigger_instance, _ = WorkflowTrigger.objects.update_or_create(
|
||||
id=trigger["id"] if "id" in trigger else None,
|
||||
id=trigger.get("id"),
|
||||
defaults=trigger,
|
||||
)
|
||||
if filter_has_tags is not None:
|
||||
@ -1469,7 +1541,7 @@ class WorkflowSerializer(serializers.ModelSerializer):
|
||||
assign_change_groups = action.pop("assign_change_groups", None)
|
||||
assign_custom_fields = action.pop("assign_custom_fields", None)
|
||||
action_instance, _ = WorkflowAction.objects.update_or_create(
|
||||
id=action["id"] if "id" in action else None,
|
||||
id=action.get("id"),
|
||||
defaults=action,
|
||||
)
|
||||
if assign_tags is not None:
|
||||
|
@ -18,7 +18,6 @@ from django.db import close_old_connections
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.dispatch import receiver
|
||||
from django.utils import termcolors
|
||||
from django.utils import timezone
|
||||
from filelock import FileLock
|
||||
|
||||
@ -54,6 +53,26 @@ def add_inbox_tags(sender, document: Document, logging_group=None, **kwargs):
|
||||
document.tags.add(*inbox_tags)
|
||||
|
||||
|
||||
def _suggestion_printer(
|
||||
stdout,
|
||||
style_func,
|
||||
suggestion_type: str,
|
||||
document: Document,
|
||||
selected: MatchingModel,
|
||||
base_url: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Smaller helper to reduce duplication when just outputting suggestions to the console
|
||||
"""
|
||||
doc_str = str(document)
|
||||
if base_url is not None:
|
||||
stdout.write(style_func.SUCCESS(doc_str))
|
||||
stdout.write(style_func.SUCCESS(f"{base_url}/documents/{document.pk}"))
|
||||
else:
|
||||
stdout.write(style_func.SUCCESS(f"{doc_str} [{document.pk}]"))
|
||||
stdout.write(f"Suggest {suggestion_type}: {selected}")
|
||||
|
||||
|
||||
def set_correspondent(
|
||||
sender,
|
||||
document: Document,
|
||||
@ -63,7 +82,8 @@ def set_correspondent(
|
||||
use_first=True,
|
||||
suggest=False,
|
||||
base_url=None,
|
||||
color=False,
|
||||
stdout=None,
|
||||
style_func=None,
|
||||
**kwargs,
|
||||
):
|
||||
if document.correspondent and not replace:
|
||||
@ -90,23 +110,14 @@ def set_correspondent(
|
||||
|
||||
if selected or replace:
|
||||
if suggest:
|
||||
if base_url:
|
||||
print(
|
||||
termcolors.colorize(str(document), fg="green")
|
||||
if color
|
||||
else str(document),
|
||||
)
|
||||
print(f"{base_url}/documents/{document.pk}")
|
||||
else:
|
||||
print(
|
||||
(
|
||||
termcolors.colorize(str(document), fg="green")
|
||||
if color
|
||||
else str(document)
|
||||
)
|
||||
+ f" [{document.pk}]",
|
||||
)
|
||||
print(f"Suggest correspondent {selected}")
|
||||
_suggestion_printer(
|
||||
stdout,
|
||||
style_func,
|
||||
"correspondent",
|
||||
document,
|
||||
selected,
|
||||
base_url,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Assigning correspondent {selected} to {document}",
|
||||
@ -126,7 +137,8 @@ def set_document_type(
|
||||
use_first=True,
|
||||
suggest=False,
|
||||
base_url=None,
|
||||
color=False,
|
||||
stdout=None,
|
||||
style_func=None,
|
||||
**kwargs,
|
||||
):
|
||||
if document.document_type and not replace:
|
||||
@ -154,23 +166,14 @@ def set_document_type(
|
||||
|
||||
if selected or replace:
|
||||
if suggest:
|
||||
if base_url:
|
||||
print(
|
||||
termcolors.colorize(str(document), fg="green")
|
||||
if color
|
||||
else str(document),
|
||||
)
|
||||
print(f"{base_url}/documents/{document.pk}")
|
||||
else:
|
||||
print(
|
||||
(
|
||||
termcolors.colorize(str(document), fg="green")
|
||||
if color
|
||||
else str(document)
|
||||
)
|
||||
+ f" [{document.pk}]",
|
||||
)
|
||||
print(f"Suggest document type {selected}")
|
||||
_suggestion_printer(
|
||||
stdout,
|
||||
style_func,
|
||||
"document type",
|
||||
document,
|
||||
selected,
|
||||
base_url,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Assigning document type {selected} to {document}",
|
||||
@ -189,7 +192,8 @@ def set_tags(
|
||||
replace=False,
|
||||
suggest=False,
|
||||
base_url=None,
|
||||
color=False,
|
||||
stdout=None,
|
||||
style_func=None,
|
||||
**kwargs,
|
||||
):
|
||||
if replace:
|
||||
@ -212,26 +216,16 @@ def set_tags(
|
||||
]
|
||||
if not relevant_tags and not extra_tags:
|
||||
return
|
||||
doc_str = style_func.SUCCESS(str(document))
|
||||
if base_url:
|
||||
print(
|
||||
termcolors.colorize(str(document), fg="green")
|
||||
if color
|
||||
else str(document),
|
||||
)
|
||||
print(f"{base_url}/documents/{document.pk}")
|
||||
stdout.write(doc_str)
|
||||
stdout.write(f"{base_url}/documents/{document.pk}")
|
||||
else:
|
||||
print(
|
||||
(
|
||||
termcolors.colorize(str(document), fg="green")
|
||||
if color
|
||||
else str(document)
|
||||
)
|
||||
+ f" [{document.pk}]",
|
||||
)
|
||||
stdout.write(doc_str + style_func.SUCCESS(f" [{document.pk}]"))
|
||||
if relevant_tags:
|
||||
print("Suggest tags: " + ", ".join([t.name for t in relevant_tags]))
|
||||
stdout.write("Suggest tags: " + ", ".join([t.name for t in relevant_tags]))
|
||||
if extra_tags:
|
||||
print("Extra tags: " + ", ".join([t.name for t in extra_tags]))
|
||||
stdout.write("Extra tags: " + ", ".join([t.name for t in extra_tags]))
|
||||
else:
|
||||
if not relevant_tags:
|
||||
return
|
||||
@ -254,7 +248,8 @@ def set_storage_path(
|
||||
use_first=True,
|
||||
suggest=False,
|
||||
base_url=None,
|
||||
color=False,
|
||||
stdout=None,
|
||||
style_func=None,
|
||||
**kwargs,
|
||||
):
|
||||
if document.storage_path and not replace:
|
||||
@ -285,23 +280,14 @@ def set_storage_path(
|
||||
|
||||
if selected or replace:
|
||||
if suggest:
|
||||
if base_url:
|
||||
print(
|
||||
termcolors.colorize(str(document), fg="green")
|
||||
if color
|
||||
else str(document),
|
||||
)
|
||||
print(f"{base_url}/documents/{document.pk}")
|
||||
else:
|
||||
print(
|
||||
(
|
||||
termcolors.colorize(str(document), fg="green")
|
||||
if color
|
||||
else str(document)
|
||||
)
|
||||
+ f" [{document.pk}]",
|
||||
)
|
||||
print(f"Suggest storage directory {selected}")
|
||||
_suggestion_printer(
|
||||
stdout,
|
||||
style_func,
|
||||
"storage directory",
|
||||
document,
|
||||
selected,
|
||||
base_url,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Assigning storage path {selected} to {document}",
|
||||
@ -573,15 +559,21 @@ def run_workflow(
|
||||
try:
|
||||
document.title = parse_doc_title_w_placeholders(
|
||||
action.assign_title,
|
||||
document.correspondent.name
|
||||
if document.correspondent is not None
|
||||
else "",
|
||||
document.document_type.name
|
||||
if document.document_type is not None
|
||||
else "",
|
||||
document.owner.username
|
||||
if document.owner is not None
|
||||
else "",
|
||||
(
|
||||
document.correspondent.name
|
||||
if document.correspondent is not None
|
||||
else ""
|
||||
),
|
||||
(
|
||||
document.document_type.name
|
||||
if document.document_type is not None
|
||||
else ""
|
||||
),
|
||||
(
|
||||
document.owner.username
|
||||
if document.owner is not None
|
||||
else ""
|
||||
),
|
||||
timezone.localtime(document.added),
|
||||
document.original_filename,
|
||||
timezone.localtime(document.created),
|
||||
|
@ -18,7 +18,8 @@
|
||||
</head>
|
||||
|
||||
<body class="text-center">
|
||||
<form class="form-signin position-absolute top-50 start-50 translate-middle" method="post">
|
||||
<div class="position-absolute top-50 start-50 translate-middle">
|
||||
<form class="form-signin" method="post">
|
||||
{% csrf_token %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="300" class="logo mb-4" viewBox="0 0 2897.4 896.6">
|
||||
<path class="leaf" d="M140,713.7c-3.4-16.4-10.3-49.1-11.2-49.1c-145.7-87.1-128.4-238-80.2-324.2C59,449,251.2,524,139.1,656.8 c-0.9,1.7,5.2,22.4,10.3,41.4c22.4-37.9,56-83.6,54.3-87.9C65.9,273.9,496.9,248.1,586.6,39.4c40.5,201.8-20.7,513.9-367.2,593.2 c-1.7,0.9-62.9,108.6-65.5,109.5c0-1.7-25.9-0.9-22.4-9.5C133.1,727.4,136.6,720.6,140,713.7L140,713.7z M135.7,632.6 c44-50.9-7.8-137.9-38.8-166.4C149.5,556.7,146,609.3,135.7,632.6L135.7,632.6z" transform="translate(0)" style="fill:#17541f"/>
|
||||
@ -38,6 +39,11 @@
|
||||
<path d="M757.6,293.7c-20-10.8-42.6-16.2-67.8-16.2H600c-8.5,39.2-21.1,76.4-37.6,111.3c-9.9,20.8-21.1,40.6-33.6,59.4v207.2h88.9 V521.5h72c25.2,0,47.8-5.4,67.8-16.2s35.7-25.6,47.1-44.2c11.4-18.7,17.1-39.1,17.1-61.3c0.1-22.7-5.6-43.3-17-61.9 C793.3,319.2,777.6,304.5,757.6,293.7z M716.6,434.3c-9.3,8.9-21.6,13.3-36.7,13.3l-62.2,0.4v-92.5l62.2-0.4 c15.1,0,27.3,4.4,36.7,13.3c9.4,8.9,14,19.9,14,32.9C730.6,414.5,726,425.4,716.6,434.3z" transform="translate(0)"/>
|
||||
</g>
|
||||
</svg>
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.level_tag }}" role="alert">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<p>{% translate "Please sign in." %}</p>
|
||||
{% if form.errors %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
@ -55,7 +61,7 @@
|
||||
{% translate "Username" as i18n_username %}
|
||||
{% translate "Password" as i18n_password %}
|
||||
<div class="form-floating">
|
||||
<input type="text" name="username" id="inputUsername" placeholder="{{ i18n_username }}" class="form-control" autocorrect="off" autocapitalize="none" required autofocus>
|
||||
<input type="text" name="login" id="inputUsername" placeholder="{{ i18n_username }}" class="form-control" autocorrect="off" autocapitalize="none" required autofocus>
|
||||
<label for="inputUsername">{{ i18n_username }}</label>
|
||||
</div>
|
||||
<div class="form-floating">
|
||||
@ -67,9 +73,33 @@
|
||||
</div>
|
||||
{% if EMAIL_ENABLED %}
|
||||
<div class="d-grid mt-3">
|
||||
<a class="btn btn-link" href="{% url 'password_reset' %}">{% translate "Forgot your password?" %}</a>
|
||||
<a class="btn btn-link" href="{% url 'account_reset_password' %}">{% translate "Forgot your password?" %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% load allauth socialaccount %}
|
||||
{% get_providers as socialaccount_providers %}
|
||||
{% if socialaccount_providers %}
|
||||
<p class="mt-3">{% translate "or sign in via" %}</p>
|
||||
<ul class="m-0 p-0">
|
||||
{% for provider in socialaccount_providers %}
|
||||
{% if provider.id == "openid" %}
|
||||
{% for brand in provider.get_brands %}
|
||||
{% provider_login_url provider openid=brand.openid_url process=process as href %}
|
||||
<li class="d-grid mt-3"><a class="btn btn-secondary" href="{{ href }}">{{ brand.name }}</a></li>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% provider_login_url provider process=process scope=scope auth_params=auth_params as href %}
|
||||
<li class="d-grid mt-3">
|
||||
<form class="d-grid" method="POST" action="{{ href }}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-secondary">{{ provider.name }}</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user