mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-05-01 11:19:32 -05:00
Merge branch 'dev' into feature-8271
This commit is contained in:
commit
5ef31859c8
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@ -406,7 +406,7 @@ jobs:
|
|||||||
-
|
-
|
||||||
name: Login to Docker Hub
|
name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
# Don't attempt to login is not pushing to Docker Hub
|
# Don't attempt to login if not pushing to Docker Hub
|
||||||
if: steps.push-other-places.outputs.enable == 'true'
|
if: steps.push-other-places.outputs.enable == 'true'
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
@ -414,7 +414,7 @@ jobs:
|
|||||||
-
|
-
|
||||||
name: Login to Quay.io
|
name: Login to Quay.io
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
# Don't attempt to login is not pushing to Quay.io
|
# Don't attempt to login if not pushing to Quay.io
|
||||||
if: steps.push-other-places.outputs.enable == 'true'
|
if: steps.push-other-places.outputs.enable == 'true'
|
||||||
with:
|
with:
|
||||||
registry: quay.io
|
registry: quay.io
|
||||||
|
@ -48,7 +48,7 @@ repos:
|
|||||||
exclude: "(^Pipfile\\.lock$)"
|
exclude: "(^Pipfile\\.lock$)"
|
||||||
# Python hooks
|
# Python hooks
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: 'v0.7.3'
|
rev: 'v0.8.0'
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
|
6
Pipfile
6
Pipfile
@ -8,7 +8,7 @@ dateparser = "~=1.2"
|
|||||||
# WARNING: django does not use semver.
|
# WARNING: django does not use semver.
|
||||||
# Only patch versions are guaranteed to not introduce breaking changes.
|
# Only patch versions are guaranteed to not introduce breaking changes.
|
||||||
django = "~=5.1.3"
|
django = "~=5.1.3"
|
||||||
django-allauth = {extras = ["socialaccount"], version = "*"}
|
django-allauth = {extras = ["mfa", "socialaccount"], version = "*"}
|
||||||
django-auditlog = "*"
|
django-auditlog = "*"
|
||||||
django-celery-results = "*"
|
django-celery-results = "*"
|
||||||
django-compression-middleware = "*"
|
django-compression-middleware = "*"
|
||||||
@ -23,7 +23,7 @@ djangorestframework-guardian = "*"
|
|||||||
drf-writable-nested = "*"
|
drf-writable-nested = "*"
|
||||||
bleach = "*"
|
bleach = "*"
|
||||||
celery = {extras = ["redis"], version = "*"}
|
celery = {extras = ["redis"], version = "*"}
|
||||||
channels = "~=4.1"
|
channels = "~=4.2"
|
||||||
channels-redis = "*"
|
channels-redis = "*"
|
||||||
concurrent-log-handler = "*"
|
concurrent-log-handler = "*"
|
||||||
filelock = "*"
|
filelock = "*"
|
||||||
@ -55,7 +55,7 @@ tika-client = "*"
|
|||||||
tqdm = "*"
|
tqdm = "*"
|
||||||
# See https://github.com/paperless-ngx/paperless-ngx/issues/5494
|
# See https://github.com/paperless-ngx/paperless-ngx/issues/5494
|
||||||
uvicorn = {extras = ["standard"], version = "==0.25.0"}
|
uvicorn = {extras = ["standard"], version = "==0.25.0"}
|
||||||
watchdog = "~=5.0"
|
watchdog = "~=6.0"
|
||||||
whitenoise = "~=6.8"
|
whitenoise = "~=6.8"
|
||||||
whoosh = "~=2.7"
|
whoosh = "~=2.7"
|
||||||
zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"}
|
zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"}
|
||||||
|
868
Pipfile.lock
generated
868
Pipfile.lock
generated
File diff suppressed because it is too large
Load Diff
@ -19,6 +19,8 @@
|
|||||||
#
|
#
|
||||||
# - Open portainer Stacks list and click 'Add stack'
|
# - Open portainer Stacks list and click 'Add stack'
|
||||||
# - Paste the contents of this file and assign a name, e.g. 'paperless'
|
# - Paste the contents of this file and assign a name, e.g. 'paperless'
|
||||||
|
# - Upload 'docker-compose.env' by clicking on 'Load variables from .env file'
|
||||||
|
# - Modify the environment variables as needed
|
||||||
# - Click 'Deploy the stack' and wait for it to be deployed
|
# - Click 'Deploy the stack' and wait for it to be deployed
|
||||||
# - Open the list of containers, select paperless_webserver_1
|
# - Open the list of containers, select paperless_webserver_1
|
||||||
# - Click 'Console' and then 'Connect' to open the command line inside the container
|
# - Click 'Console' and then 'Connect' to open the command line inside the container
|
||||||
@ -61,28 +63,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
PAPERLESS_REDIS: redis://broker:6379
|
PAPERLESS_REDIS: redis://broker:6379
|
||||||
PAPERLESS_DBHOST: db
|
PAPERLESS_DBHOST: db
|
||||||
# The UID and GID of the user used to run paperless in the container. Set this
|
env_file:
|
||||||
# to your UID and GID on the host so that you have write access to the
|
- stack.env
|
||||||
# consumption directory.
|
|
||||||
USERMAP_UID: 1000
|
|
||||||
USERMAP_GID: 100
|
|
||||||
# Additional languages to install for text recognition, separated by a
|
|
||||||
# whitespace. Note that this is
|
|
||||||
# different from PAPERLESS_OCR_LANGUAGE (default=eng), which defines the
|
|
||||||
# language used for OCR.
|
|
||||||
# The container installs English, German, Italian, Spanish and French by
|
|
||||||
# default.
|
|
||||||
# See https://packages.debian.org/search?keywords=tesseract-ocr-&searchon=names&suite=buster
|
|
||||||
# for available languages.
|
|
||||||
#PAPERLESS_OCR_LANGUAGES: tur ces
|
|
||||||
# Adjust this key if you plan to make paperless available publicly. It should
|
|
||||||
# be a very long sequence of random characters. You don't need to remember it.
|
|
||||||
#PAPERLESS_SECRET_KEY: change-me
|
|
||||||
# Use this variable to set a timezone for the Paperless Docker containers. If not specified, defaults to UTC.
|
|
||||||
#PAPERLESS_TIME_ZONE: America/Los_Angeles
|
|
||||||
# The default language to use for OCR. Set this to the language most of your
|
|
||||||
# documents are written in.
|
|
||||||
#PAPERLESS_OCR_LANGUAGE: eng
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
|
@ -241,6 +241,7 @@ document_exporter target [-c] [-d] [-f] [-na] [-nt] [-p] [-sm] [-z]
|
|||||||
|
|
||||||
optional arguments:
|
optional arguments:
|
||||||
-c, --compare-checksums
|
-c, --compare-checksums
|
||||||
|
-cj, --compare-json
|
||||||
-d, --delete
|
-d, --delete
|
||||||
-f, --use-filename-format
|
-f, --use-filename-format
|
||||||
-na, --no-archive
|
-na, --no-archive
|
||||||
@ -269,7 +270,8 @@ only export changed and added files. Paperless determines whether a file
|
|||||||
has changed by inspecting the file attributes "date/time modified" and
|
has changed by inspecting the file attributes "date/time modified" and
|
||||||
"size". If that does not work out for you, specify `-c` or
|
"size". If that does not work out for you, specify `-c` or
|
||||||
`--compare-checksums` and paperless will attempt to compare file
|
`--compare-checksums` and paperless will attempt to compare file
|
||||||
checksums instead. This is slower.
|
checksums instead. This is slower. The manifest and metadata json files
|
||||||
|
are always updated, unless `cj` or `--compare-json` is specified.
|
||||||
|
|
||||||
Paperless will not remove any existing files in the export directory. If
|
Paperless will not remove any existing files in the export directory. If
|
||||||
you want paperless to also remove files that do not belong to the
|
you want paperless to also remove files that do not belong to the
|
||||||
|
@ -556,3 +556,11 @@ Initial API version.
|
|||||||
|
|
||||||
- Consumption templates were refactored to workflows and API endpoints
|
- Consumption templates were refactored to workflows and API endpoints
|
||||||
changed as such.
|
changed as such.
|
||||||
|
|
||||||
|
#### Version 5
|
||||||
|
|
||||||
|
- Added bulk deletion methods for documents and objects.
|
||||||
|
|
||||||
|
#### Version 6
|
||||||
|
|
||||||
|
- Moved acknowledge tasks endpoint to be under `/api/tasks/acknowledge/`.
|
||||||
|
@ -1,5 +1,58 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## paperless-ngx 2.13.5
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: handle page count exception for pw-protected files [@shamoon](https://github.com/shamoon) ([#8240](https://github.com/paperless-ngx/paperless-ngx/pull/8240))
|
||||||
|
- Fix: correctly track task id in list for change detection [@shamoon](https://github.com/shamoon) ([#8230](https://github.com/paperless-ngx/paperless-ngx/pull/8230))
|
||||||
|
- Fix: Admin pages should show trashed documents [@stumpylog](https://github.com/stumpylog) ([#8068](https://github.com/paperless-ngx/paperless-ngx/pull/8068))
|
||||||
|
- Fix: tag colors shouldn't change when selected in list [@shamoon](https://github.com/shamoon) ([#8225](https://github.com/paperless-ngx/paperless-ngx/pull/8225))
|
||||||
|
- Fix: fix re-activation of save button when changing array items [@shamoon](https://github.com/shamoon) ([#8208](https://github.com/paperless-ngx/paperless-ngx/pull/8208))
|
||||||
|
- Fix: fix thumbnail clipping, select inverted color in safari dark mode not system [@shamoon](https://github.com/shamoon) ([#8193](https://github.com/paperless-ngx/paperless-ngx/pull/8193))
|
||||||
|
- Fix: select checkbox should remain visible [@shamoon](https://github.com/shamoon) ([#8185](https://github.com/paperless-ngx/paperless-ngx/pull/8185))
|
||||||
|
- Fix: warn with proper error on ASN exists in trash [@shamoon](https://github.com/shamoon) ([#8176](https://github.com/paperless-ngx/paperless-ngx/pull/8176))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
- Chore: Updates all runner images to use Ubuntu Noble [@stumpylog](https://github.com/stumpylog) ([#8213](https://github.com/paperless-ngx/paperless-ngx/pull/8213))
|
||||||
|
- Chore(deps): Bump stumpylog/image-cleaner-action from 0.8.0 to 0.9.0 in the actions group [@dependabot](https://github.com/dependabot) ([#8142](https://github.com/paperless-ngx/paperless-ngx/pull/8142))
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- Chore(deps): Bump stumpylog/image-cleaner-action from 0.8.0 to 0.9.0 in the actions group [@dependabot](https://github.com/dependabot) ([#8142](https://github.com/paperless-ngx/paperless-ngx/pull/8142))
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>7 changes</summary>
|
||||||
|
|
||||||
|
- Fix: handle page count exception for pw-protected files [@shamoon](https://github.com/shamoon) ([#8240](https://github.com/paperless-ngx/paperless-ngx/pull/8240))
|
||||||
|
- Fix: correctly track task id in list for change detection [@shamoon](https://github.com/shamoon) ([#8230](https://github.com/paperless-ngx/paperless-ngx/pull/8230))
|
||||||
|
- Fix: Admin pages should show trashed documents [@stumpylog](https://github.com/stumpylog) ([#8068](https://github.com/paperless-ngx/paperless-ngx/pull/8068))
|
||||||
|
- Fix: tag colors shouldn't change when selected in list [@shamoon](https://github.com/shamoon) ([#8225](https://github.com/paperless-ngx/paperless-ngx/pull/8225))
|
||||||
|
- Fix: fix re-activation of save button when changing array items [@shamoon](https://github.com/shamoon) ([#8208](https://github.com/paperless-ngx/paperless-ngx/pull/8208))
|
||||||
|
- Fix: fix thumbnail clipping, select inverted color in safari dark mode not system [@shamoon](https://github.com/shamoon) ([#8193](https://github.com/paperless-ngx/paperless-ngx/pull/8193))
|
||||||
|
- Fix: select checkbox should remain visible [@shamoon](https://github.com/shamoon) ([#8185](https://github.com/paperless-ngx/paperless-ngx/pull/8185))
|
||||||
|
- Fix: warn with proper error on ASN exists in trash [@shamoon](https://github.com/shamoon) ([#8176](https://github.com/paperless-ngx/paperless-ngx/pull/8176))
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## paperless-ngx 2.13.4
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: fix dark mode icon blend mode in 2.13.3 [@shamoon](https://github.com/shamoon) ([#8166](https://github.com/paperless-ngx/paperless-ngx/pull/8166))
|
||||||
|
- Fix: fix clipped popup preview in 2.13.3 [@shamoon](https://github.com/shamoon) ([#8165](https://github.com/paperless-ngx/paperless-ngx/pull/8165))
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>2 changes</summary>
|
||||||
|
|
||||||
|
- Fix: fix dark mode icon blend mode in 2.13.3 [@shamoon](https://github.com/shamoon) ([#8166](https://github.com/paperless-ngx/paperless-ngx/pull/8166))
|
||||||
|
- Fix: fix clipped popup preview in 2.13.3 [@shamoon](https://github.com/shamoon) ([#8165](https://github.com/paperless-ngx/paperless-ngx/pull/8165))
|
||||||
|
</details>
|
||||||
|
|
||||||
## paperless-ngx 2.13.3
|
## paperless-ngx 2.13.3
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
@ -299,6 +299,12 @@ In order to enable the password reset feature you will need to setup an SMTP bac
|
|||||||
[`PAPERLESS_EMAIL_HOST`](configuration.md#PAPERLESS_EMAIL_HOST). If your installation does not have
|
[`PAPERLESS_EMAIL_HOST`](configuration.md#PAPERLESS_EMAIL_HOST). If your installation does not have
|
||||||
[`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) set, the reset link included in emails will use the server host.
|
[`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) set, the reset link included in emails will use the server host.
|
||||||
|
|
||||||
|
### Two-factor authentication
|
||||||
|
|
||||||
|
Users can enable two-factor authentication (2FA) for their accounts from the 'My Profile' dialog. Opening the dropdown reveals a QR code that can be scanned by a 2FA app (e.g. Google Authenticator) to generate a code. The code must then be entered in the dialog to enable 2FA. If the code is accepted and 2FA is enabled, the user will be shown a set of 10 recovery codes that can be used to login in the event that the 2FA device is lost or unavailable. These codes should be stored securely and cannot be retrieved again. Once enabled, users will be required to enter a code from their 2FA app when logging in.
|
||||||
|
|
||||||
|
Should a user lose access to their 2FA device and all recovery codes, a superuser can disable 2FA for the user from the 'Users & Groups' management screen.
|
||||||
|
|
||||||
## Workflows
|
## Workflows
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
@ -325,8 +331,10 @@ Currently, there are three events that correspond to workflow trigger 'types':
|
|||||||
be used for filtering.
|
be used for filtering.
|
||||||
3. **Document Updated**: when a document is updated. Similar to 'added' events, triggers can include filtering by content matching,
|
3. **Document Updated**: when a document is updated. Similar to 'added' events, triggers can include filtering by content matching,
|
||||||
tags, doc type, or correspondent.
|
tags, doc type, or correspondent.
|
||||||
|
4. **Scheduled**: a scheduled trigger that can be used to run workflows at a specific time. The date used can be either the document
|
||||||
|
added, created, updated date or you can specify a (date) custom field. You can also specify a day offset from the date.
|
||||||
|
|
||||||
The following flow diagram illustrates the three trigger types:
|
The following flow diagram illustrates the three document trigger types:
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
|
@ -330,8 +330,13 @@ SECRET_KEY=$(LC_ALL=C tr -dc 'a-zA-Z0-9!#$%&()*+,-./:;<=>?@[\]^_`{|}~' < /dev/ur
|
|||||||
|
|
||||||
DEFAULT_LANGUAGES=("deu eng fra ita spa")
|
DEFAULT_LANGUAGES=("deu eng fra ita spa")
|
||||||
|
|
||||||
_split_langs="${OCR_LANGUAGE//+/ }"
|
# OCR_LANG requires underscores, replace dashes if the user gave them with underscores
|
||||||
read -r -a OCR_LANGUAGES_ARRAY <<< "${_split_langs}"
|
readonly ocr_langs=${OCR_LANGUAGE//-/_}
|
||||||
|
# OCR_LANGS (the install version) uses dashes, not underscores, so convert underscore to dash and plus to space
|
||||||
|
install_langs=${OCR_LANGUAGE//_/-} # First convert any underscores to dashes
|
||||||
|
install_langs=${install_langs//+/ } # Then convert plus signs to spaces
|
||||||
|
|
||||||
|
read -r -a install_langs_array <<< "${install_langs}"
|
||||||
|
|
||||||
{
|
{
|
||||||
if [[ ! $URL == "" ]] ; then
|
if [[ ! $URL == "" ]] ; then
|
||||||
@ -344,10 +349,10 @@ read -r -a OCR_LANGUAGES_ARRAY <<< "${_split_langs}"
|
|||||||
echo "USERMAP_GID=$USERMAP_GID"
|
echo "USERMAP_GID=$USERMAP_GID"
|
||||||
fi
|
fi
|
||||||
echo "PAPERLESS_TIME_ZONE=$TIME_ZONE"
|
echo "PAPERLESS_TIME_ZONE=$TIME_ZONE"
|
||||||
echo "PAPERLESS_OCR_LANGUAGE=$OCR_LANGUAGE"
|
echo "PAPERLESS_OCR_LANGUAGE=$ocr_langs"
|
||||||
echo "PAPERLESS_SECRET_KEY='$SECRET_KEY'"
|
echo "PAPERLESS_SECRET_KEY='$SECRET_KEY'"
|
||||||
if [[ ! ${DEFAULT_LANGUAGES[*]} =~ ${OCR_LANGUAGES_ARRAY[*]} ]] ; then
|
if [[ ! ${DEFAULT_LANGUAGES[*]} =~ ${install_langs_array[*]} ]] ; then
|
||||||
echo "PAPERLESS_OCR_LANGUAGES=${OCR_LANGUAGES_ARRAY[*]}"
|
echo "PAPERLESS_OCR_LANGUAGES=${install_langs_array[*]}"
|
||||||
fi
|
fi
|
||||||
} > docker-compose.env
|
} > docker-compose.env
|
||||||
|
|
||||||
|
File diff suppressed because it is too large
Load Diff
16
src-ui/package-lock.json
generated
16
src-ui/package-lock.json
generated
@ -33,6 +33,7 @@
|
|||||||
"ngx-ui-tour-ng-bootstrap": "^15.0.0",
|
"ngx-ui-tour-ng-bootstrap": "^15.0.0",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
|
"utif": "^3.1.0",
|
||||||
"uuid": "^11.0.2",
|
"uuid": "^11.0.2",
|
||||||
"zone.js": "^0.14.8"
|
"zone.js": "^0.14.8"
|
||||||
},
|
},
|
||||||
@ -13758,6 +13759,12 @@
|
|||||||
"node": "^16.14.0 || >=18.0.0"
|
"node": "^16.14.0 || >=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@ -16563,6 +16570,15 @@
|
|||||||
"requires-port": "^1.0.0"
|
"requires-port": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/utif": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/utif/-/utif-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-WEo4D/xOvFW53K5f5QTaTbbiORcm2/pCL9P6qmJnup+17eYfKaEhDeX9PeQkuyEoIxlbGklDuGl8xwuXYMrrXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
@ -35,6 +35,7 @@
|
|||||||
"ngx-ui-tour-ng-bootstrap": "^15.0.0",
|
"ngx-ui-tour-ng-bootstrap": "^15.0.0",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
|
"utif": "^3.1.0",
|
||||||
"uuid": "^11.0.2",
|
"uuid": "^11.0.2",
|
||||||
"zone.js": "^0.14.8"
|
"zone.js": "^0.14.8"
|
||||||
},
|
},
|
||||||
|
@ -145,7 +145,6 @@ import {
|
|||||||
asterisk,
|
asterisk,
|
||||||
braces,
|
braces,
|
||||||
bodyText,
|
bodyText,
|
||||||
boxArrowInRight,
|
|
||||||
boxArrowUp,
|
boxArrowUp,
|
||||||
boxArrowUpRight,
|
boxArrowUpRight,
|
||||||
boxes,
|
boxes,
|
||||||
@ -186,6 +185,7 @@ import {
|
|||||||
fileEarmarkFill,
|
fileEarmarkFill,
|
||||||
fileEarmarkLock,
|
fileEarmarkLock,
|
||||||
fileEarmarkMinus,
|
fileEarmarkMinus,
|
||||||
|
fileEarmarkRichtext,
|
||||||
files,
|
files,
|
||||||
fileText,
|
fileText,
|
||||||
filter,
|
filter,
|
||||||
@ -253,7 +253,6 @@ const icons = {
|
|||||||
asterisk,
|
asterisk,
|
||||||
braces,
|
braces,
|
||||||
bodyText,
|
bodyText,
|
||||||
boxArrowInRight,
|
|
||||||
boxArrowUp,
|
boxArrowUp,
|
||||||
boxArrowUpRight,
|
boxArrowUpRight,
|
||||||
boxes,
|
boxes,
|
||||||
@ -294,6 +293,7 @@ const icons = {
|
|||||||
fileEarmarkFill,
|
fileEarmarkFill,
|
||||||
fileEarmarkLock,
|
fileEarmarkLock,
|
||||||
fileEarmarkMinus,
|
fileEarmarkMinus,
|
||||||
|
fileEarmarkRichtext,
|
||||||
files,
|
files,
|
||||||
fileText,
|
fileText,
|
||||||
filter,
|
filter,
|
||||||
|
@ -36,9 +36,7 @@
|
|||||||
<ng-container i18n>Loading...</ng-container>
|
<ng-container i18n>Loading...</ng-container>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@for (log of logs; track log) {
|
@for (log of logs; track $index) {
|
||||||
<p
|
<p class="m-0 p-0 log-entry-{{getLogLevel(log)}}">{{log}}</p>
|
||||||
class="m-0 p-0 log-entry-{{getLogLevel(log)}}"
|
|
||||||
>{{log}}</p>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@ -47,14 +47,19 @@
|
|||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@for (document of documentsInTrash; track document.id) {
|
@for (document of documentsInTrash; track document.id) {
|
||||||
<tr (click)="toggleSelected(document); $event.stopPropagation();">
|
<tr (click)="toggleSelected(document); $event.stopPropagation();" (mouseleave)="popupPreview.close()">
|
||||||
<td>
|
<td>
|
||||||
<div class="form-check m-0 ms-2 me-n2">
|
<div class="form-check m-0 ms-2 me-n2">
|
||||||
<input type="checkbox" class="form-check-input" id="{{document.id}}" [checked]="selectedDocuments.has(document.id)" (click)="toggleSelected(document); $event.stopPropagation();">
|
<input type="checkbox" class="form-check-input" id="{{document.id}}" [checked]="selectedDocuments.has(document.id)" (click)="toggleSelected(document); $event.stopPropagation();">
|
||||||
<label class="form-check-label" for="{{document.id}}"></label>
|
<label class="form-check-label" for="{{document.id}}"></label>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td scope="row">{{ document.title }}</td>
|
<td scope="row">
|
||||||
|
{{ document.title }}
|
||||||
|
<pngx-preview-popup [document]="document" linkClasses="btn btn-sm btn-link" #popupPreview>
|
||||||
|
<i-bs name="eye"></i-bs>
|
||||||
|
</pngx-preview-popup>
|
||||||
|
</td>
|
||||||
<td scope="row" i18n>{{ getDaysRemaining(document) }} days</td>
|
<td scope="row" i18n>{{ getDaysRemaining(document) }} days</td>
|
||||||
<td scope="row">
|
<td scope="row">
|
||||||
<div class="btn-group d-block d-sm-none">
|
<div class="btn-group d-block d-sm-none">
|
||||||
|
@ -16,6 +16,7 @@ import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dial
|
|||||||
import { By } from '@angular/platform-browser'
|
import { By } from '@angular/platform-browser'
|
||||||
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
|
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { Router } from '@angular/router'
|
||||||
|
|
||||||
const documentsInTrash = [
|
const documentsInTrash = [
|
||||||
{
|
{
|
||||||
@ -38,6 +39,7 @@ describe('TrashComponent', () => {
|
|||||||
let trashService: TrashService
|
let trashService: TrashService
|
||||||
let modalService: NgbModal
|
let modalService: NgbModal
|
||||||
let toastService: ToastService
|
let toastService: ToastService
|
||||||
|
let router: Router
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
@ -61,6 +63,7 @@ describe('TrashComponent', () => {
|
|||||||
trashService = TestBed.inject(TrashService)
|
trashService = TestBed.inject(TrashService)
|
||||||
modalService = TestBed.inject(NgbModal)
|
modalService = TestBed.inject(NgbModal)
|
||||||
toastService = TestBed.inject(ToastService)
|
toastService = TestBed.inject(ToastService)
|
||||||
|
router = TestBed.inject(Router)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
@ -161,6 +164,22 @@ describe('TrashComponent', () => {
|
|||||||
expect(restoreSpy).toHaveBeenCalledWith([1, 2])
|
expect(restoreSpy).toHaveBeenCalledWith([1, 2])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should offer link to restored document', () => {
|
||||||
|
let toasts
|
||||||
|
const navigateSpy = jest.spyOn(router, 'navigate')
|
||||||
|
toastService.getToasts().subscribe((allToasts) => {
|
||||||
|
toasts = [...allToasts]
|
||||||
|
})
|
||||||
|
jest.spyOn(trashService, 'restoreDocuments').mockReturnValue(of('OK'))
|
||||||
|
component.restore(documentsInTrash[0])
|
||||||
|
expect(toasts.length).toEqual(1)
|
||||||
|
toasts[0].action()
|
||||||
|
expect(navigateSpy).toHaveBeenCalledWith([
|
||||||
|
'documents',
|
||||||
|
documentsInTrash[0].id,
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
it('should support toggle all items in view', () => {
|
it('should support toggle all items in view', () => {
|
||||||
component.documentsInTrash = documentsInTrash
|
component.documentsInTrash = documentsInTrash
|
||||||
expect(component.selectedDocuments.size).toEqual(0)
|
expect(component.selectedDocuments.size).toEqual(0)
|
||||||
|
@ -7,6 +7,7 @@ import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dial
|
|||||||
import { Subject, takeUntil } from 'rxjs'
|
import { Subject, takeUntil } from 'rxjs'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
|
import { Router } from '@angular/router'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-trash',
|
selector: 'pngx-trash',
|
||||||
@ -26,7 +27,8 @@ export class TrashComponent implements OnDestroy {
|
|||||||
private trashService: TrashService,
|
private trashService: TrashService,
|
||||||
private toastService: ToastService,
|
private toastService: ToastService,
|
||||||
private modalService: NgbModal,
|
private modalService: NgbModal,
|
||||||
private settingsService: SettingsService
|
private settingsService: SettingsService,
|
||||||
|
private router: Router
|
||||||
) {
|
) {
|
||||||
this.reload()
|
this.reload()
|
||||||
}
|
}
|
||||||
@ -110,7 +112,14 @@ export class TrashComponent implements OnDestroy {
|
|||||||
restore(document: Document) {
|
restore(document: Document) {
|
||||||
this.trashService.restoreDocuments([document.id]).subscribe({
|
this.trashService.restoreDocuments([document.id]).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastService.showInfo($localize`Document restored`)
|
this.toastService.show({
|
||||||
|
content: $localize`Document restored`,
|
||||||
|
delay: 5000,
|
||||||
|
actionName: $localize`Open document`,
|
||||||
|
action: () => {
|
||||||
|
this.router.navigate(['documents', document.id])
|
||||||
|
},
|
||||||
|
})
|
||||||
this.reload()
|
this.reload()
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
<nav class="navbar navbar-dark sticky-top bg-primary flex-md-nowrap p-0 shadow-sm">
|
<nav class="navbar navbar-dark fixed-top bg-primary flex-md-nowrap p-0 shadow-sm">
|
||||||
<button class="navbar-toggler d-md-none collapsed border-0" type="button" data-toggle="collapse"
|
<button class="navbar-toggler d-md-none collapsed border-0" type="button" data-toggle="collapse"
|
||||||
data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation"
|
data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation"
|
||||||
(click)="isMenuCollapsed = !isMenuCollapsed">
|
(click)="isMenuCollapsed = !isMenuCollapsed">
|
||||||
|
@ -48,6 +48,13 @@
|
|||||||
|
|
||||||
main {
|
main {
|
||||||
transition: all .2s ease;
|
transition: all .2s ease;
|
||||||
|
padding-top: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
main {
|
||||||
|
padding-top: 56px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-slim-toggler {
|
.sidebar-slim-toggler {
|
||||||
|
@ -343,6 +343,7 @@ describe('AppFrameComponent', () => {
|
|||||||
component.editProfile()
|
component.editProfile()
|
||||||
expect(modalSpy).toHaveBeenCalledWith(ProfileEditDialogComponent, {
|
expect(modalSpy).toHaveBeenCalledWith(ProfileEditDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
|
size: 'xl',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -136,6 +136,7 @@ export class AppFrameComponent
|
|||||||
editProfile() {
|
editProfile() {
|
||||||
this.modalService.open(ProfileEditDialogComponent, {
|
this.modalService.open(ProfileEditDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
|
size: 'xl',
|
||||||
})
|
})
|
||||||
this.closeMenu()
|
this.closeMenu()
|
||||||
}
|
}
|
||||||
|
@ -49,7 +49,7 @@
|
|||||||
[disabled]="disablePrimaryButton(type, item)"
|
[disabled]="disablePrimaryButton(type, item)"
|
||||||
(mouseenter)="onButtonHover($event)">
|
(mouseenter)="onButtonHover($event)">
|
||||||
@if (type === DataType.Document) {
|
@if (type === DataType.Document) {
|
||||||
<i-bs width="1em" height="1em" name="box-arrow-in-right"></i-bs>
|
<i-bs width="1em" height="1em" name="file-earmark-richtext"></i-bs>
|
||||||
<span> <ng-container i18n>Open</ng-container></span>
|
<span> <ng-container i18n>Open</ng-container></span>
|
||||||
} @else if (type === DataType.SavedView) {
|
} @else if (type === DataType.SavedView) {
|
||||||
<i-bs width="1em" height="1em" name="eye"></i-bs>
|
<i-bs width="1em" height="1em" name="eye"></i-bs>
|
||||||
@ -72,7 +72,7 @@
|
|||||||
<i-bs width="1em" height="1em" name="download"></i-bs>
|
<i-bs width="1em" height="1em" name="download"></i-bs>
|
||||||
<span> <ng-container i18n>Download</ng-container></span>
|
<span> <ng-container i18n>Download</ng-container></span>
|
||||||
} @else {
|
} @else {
|
||||||
<i-bs width="1em" height="1em" name="box-arrow-in-right"></i-bs>
|
<i-bs width="1em" height="1em" name="file-earmark-richtext"></i-bs>
|
||||||
<span> <ng-container i18n>Open</ng-container></span>
|
<span> <ng-container i18n>Open</ng-container></span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
.pdf-viewer-container {
|
.pdf-viewer-container {
|
||||||
background-color: gray;
|
background-color: gray;
|
||||||
height: 350px;
|
height: 550px;
|
||||||
|
|
||||||
pdf-viewer {
|
pdf-viewer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p>{{message}}</p>
|
<p>{{message}}</p>
|
||||||
<div class="row mb-2">
|
<div class="row mb-2">
|
||||||
<div class="col-8">
|
<div class="col-7">
|
||||||
<div class="input-group input-group-sm">
|
<div class="input-group input-group-sm">
|
||||||
<div class="input-group-text" i18n>Page</div>
|
<div class="input-group-text" i18n>Page</div>
|
||||||
<input class="form-control" type="number" min="1" [(ngModel)]="page" />
|
<input class="form-control" type="number" min="1" [(ngModel)]="page" />
|
||||||
@ -21,7 +21,7 @@
|
|||||||
</pdf-viewer>
|
</pdf-viewer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-4">
|
<div class="col-5">
|
||||||
<div class="d-grid">
|
<div class="d-grid">
|
||||||
<button class="btn btn-sm btn-primary" (click)="addSplit()" [disabled]="!canSplit">
|
<button class="btn btn-sm btn-primary" (click)="addSplit()" [disabled]="!canSplit">
|
||||||
<i-bs name="plus-circle"></i-bs>
|
<i-bs name="plus-circle"></i-bs>
|
||||||
@ -44,12 +44,12 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-check form-switch mt-4">
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<div class="form-check form-switch me-auto">
|
||||||
<input class="form-check-input" type="checkbox" role="switch" id="deleteOriginalSwitch" [(ngModel)]="deleteOriginal" [disabled]="!userOwnsDocument">
|
<input class="form-check-input" type="checkbox" role="switch" id="deleteOriginalSwitch" [(ngModel)]="deleteOriginal" [disabled]="!userOwnsDocument">
|
||||||
<label class="form-check-label" for="deleteOriginalSwitch" i18n>Delete original document after successful split</label>
|
<label class="form-check-label" for="deleteOriginalSwitch" i18n>Delete original document after successful split</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
|
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
|
||||||
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
||||||
</button>
|
</button>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
.pdf-viewer-container {
|
.pdf-viewer-container {
|
||||||
background-color: gray;
|
background-color: gray;
|
||||||
height: 350px;
|
height: 500px;
|
||||||
|
|
||||||
pdf-viewer {
|
pdf-viewer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -28,6 +28,9 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@if (object?.id) {
|
||||||
|
<small class="d-block mt-2" i18n>Warning: existing instances of this field will retain their current value index (e.g. option #1, #2, #3) after editing the options here</small>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@case (CustomFieldDataType.Monetary) {
|
@case (CustomFieldDataType.Monetary) {
|
||||||
<div class="my-3">
|
<div class="my-3">
|
||||||
|
@ -32,6 +32,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<pngx-input-select i18n-title title="Groups" [items]="groups" multiple="true" formControlName="groups"></pngx-input-select>
|
<pngx-input-select i18n-title title="Groups" [items]="groups" multiple="true" formControlName="groups"></pngx-input-select>
|
||||||
|
|
||||||
|
@if (object?.is_mfa_enabled && currentUserIsSuperUser) {
|
||||||
|
<label class="form-label" i18n>Two-factor Authentication</label>
|
||||||
|
<pngx-confirm-button
|
||||||
|
label="Disable Two-factor Authentication"
|
||||||
|
i18n-label
|
||||||
|
title="Disable Two-factor Authentication"
|
||||||
|
i18n-title
|
||||||
|
buttonClasses="btn-outline-danger btn-sm"
|
||||||
|
iconName="trash"
|
||||||
|
[disabled]="totpLoading"
|
||||||
|
(confirm)="deactivateTotp()">
|
||||||
|
</pngx-confirm-button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<pngx-permissions-select i18n-title title="Permissions" formControlName="user_permissions" [error]="error?.user_permissions" [inheritedPermissions]="inheritedPermissions"></pngx-permissions-select>
|
<pngx-permissions-select i18n-title title="Permissions" formControlName="user_permissions" [error]="error?.user_permissions" [inheritedPermissions]="inheritedPermissions"></pngx-permissions-select>
|
||||||
|
@ -7,7 +7,7 @@ import {
|
|||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
import { of } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { GroupService } from 'src/app/services/rest/group.service'
|
import { GroupService } from 'src/app/services/rest/group.service'
|
||||||
@ -21,10 +21,15 @@ import { EditDialogMode } from '../edit-dialog.component'
|
|||||||
import { UserEditDialogComponent } from './user-edit-dialog.component'
|
import { UserEditDialogComponent } from './user-edit-dialog.component'
|
||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
|
|
||||||
describe('UserEditDialogComponent', () => {
|
describe('UserEditDialogComponent', () => {
|
||||||
let component: UserEditDialogComponent
|
let component: UserEditDialogComponent
|
||||||
let settingsService: SettingsService
|
let settingsService: SettingsService
|
||||||
|
let permissionsService: PermissionsService
|
||||||
|
let toastService: ToastService
|
||||||
let fixture: ComponentFixture<UserEditDialogComponent>
|
let fixture: ComponentFixture<UserEditDialogComponent>
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@ -71,6 +76,8 @@ describe('UserEditDialogComponent', () => {
|
|||||||
fixture = TestBed.createComponent(UserEditDialogComponent)
|
fixture = TestBed.createComponent(UserEditDialogComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser = { id: 99, username: 'user99' }
|
settingsService.currentUser = { id: 99, username: 'user99' }
|
||||||
|
permissionsService = TestBed.inject(PermissionsService)
|
||||||
|
toastService = TestBed.inject(ToastService)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
@ -121,4 +128,38 @@ describe('UserEditDialogComponent', () => {
|
|||||||
component.save()
|
component.save()
|
||||||
expect(component.passwordIsSet).toBeTruthy()
|
expect(component.passwordIsSet).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should support deactivation of TOTP', () => {
|
||||||
|
component.object = { id: 99, username: 'user99' }
|
||||||
|
const deactivateSpy = jest.spyOn(
|
||||||
|
component['service'] as UserService,
|
||||||
|
'deactivateTotp'
|
||||||
|
)
|
||||||
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
|
deactivateSpy.mockReturnValueOnce(throwError(() => new Error('error')))
|
||||||
|
component.deactivateTotp()
|
||||||
|
expect(deactivateSpy).toHaveBeenCalled()
|
||||||
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
deactivateSpy.mockReturnValueOnce(of(false))
|
||||||
|
component.deactivateTotp()
|
||||||
|
expect(deactivateSpy).toHaveBeenCalled()
|
||||||
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
deactivateSpy.mockReturnValueOnce(of(true))
|
||||||
|
component.deactivateTotp()
|
||||||
|
expect(deactivateSpy).toHaveBeenCalled()
|
||||||
|
expect(toastInfoSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should check superuser status of current user', () => {
|
||||||
|
expect(component.currentUserIsSuperUser).toBeFalsy()
|
||||||
|
permissionsService.initialize([], {
|
||||||
|
id: 99,
|
||||||
|
username: 'user99',
|
||||||
|
is_superuser: true,
|
||||||
|
})
|
||||||
|
expect(component.currentUserIsSuperUser).toBeTruthy()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@ -5,9 +5,11 @@ import { first } from 'rxjs'
|
|||||||
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
||||||
import { Group } from 'src/app/data/group'
|
import { Group } from 'src/app/data/group'
|
||||||
import { User } from 'src/app/data/user'
|
import { User } from 'src/app/data/user'
|
||||||
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
import { GroupService } from 'src/app/services/rest/group.service'
|
import { GroupService } from 'src/app/services/rest/group.service'
|
||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-user-edit-dialog',
|
selector: 'pngx-user-edit-dialog',
|
||||||
@ -20,12 +22,15 @@ export class UserEditDialogComponent
|
|||||||
{
|
{
|
||||||
groups: Group[]
|
groups: Group[]
|
||||||
passwordIsSet: boolean = false
|
passwordIsSet: boolean = false
|
||||||
|
public totpLoading: boolean = false
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
service: UserService,
|
service: UserService,
|
||||||
activeModal: NgbActiveModal,
|
activeModal: NgbActiveModal,
|
||||||
groupsService: GroupService,
|
groupsService: GroupService,
|
||||||
settingsService: SettingsService
|
settingsService: SettingsService,
|
||||||
|
private toastService: ToastService,
|
||||||
|
private permissionsService: PermissionsService
|
||||||
) {
|
) {
|
||||||
super(service, activeModal, service, settingsService)
|
super(service, activeModal, service, settingsService)
|
||||||
|
|
||||||
@ -87,4 +92,30 @@ export class UserEditDialogComponent
|
|||||||
.length > 0
|
.length > 0
|
||||||
super.save()
|
super.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get currentUserIsSuperUser(): boolean {
|
||||||
|
return this.permissionsService.isSuperUser()
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivateTotp() {
|
||||||
|
this.totpLoading = true
|
||||||
|
;(this.service as UserService)
|
||||||
|
.deactivateTotp(this.object)
|
||||||
|
.pipe(first())
|
||||||
|
.subscribe({
|
||||||
|
next: (result) => {
|
||||||
|
this.totpLoading = false
|
||||||
|
if (result) {
|
||||||
|
this.toastService.showInfo($localize`Totp deactivated`)
|
||||||
|
this.object.is_mfa_enabled = false
|
||||||
|
} else {
|
||||||
|
this.toastService.showError($localize`Totp deactivation failed`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.totpLoading = false
|
||||||
|
this.toastService.showError($localize`Totp deactivation failed`, e)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -119,6 +119,32 @@
|
|||||||
<div [formGroup]="formGroup">
|
<div [formGroup]="formGroup">
|
||||||
<input type="hidden" formControlName="id" />
|
<input type="hidden" formControlName="id" />
|
||||||
<pngx-input-select i18n-title title="Trigger type" [horizontal]="true" [items]="triggerTypeOptions" formControlName="type"></pngx-input-select>
|
<pngx-input-select i18n-title title="Trigger type" [horizontal]="true" [items]="triggerTypeOptions" formControlName="type"></pngx-input-select>
|
||||||
|
@if (formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
|
||||||
|
<p class="small" i18n>Set scheduled trigger offset and which field to use.</p>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-4">
|
||||||
|
<pngx-input-number i18n-title title="Offset days" formControlName="schedule_offset_days" i18n-hint hint="Use 0 for immediate." [showAdd]="false" [error]="error?.schedule_offset_days"></pngx-input-number>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<pngx-input-select i18n-title title="Relative to" formControlName="schedule_date_field" [items]="scheduleDateFieldOptions" [error]="error?.schedule_date_field"></pngx-input-select>
|
||||||
|
</div>
|
||||||
|
@if (formGroup.get('schedule_date_field').value === 'custom_field') {
|
||||||
|
<div class="col-4">
|
||||||
|
<pngx-input-select i18n-title title="Delay custom field" formControlName="schedule_date_custom_field" [items]="dateCustomFields" i18n-hint hint="Custom field to use for date." [error]="error?.schedule_date_custom_field"></pngx-input-select>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-4">
|
||||||
|
<pngx-input-check i18n-title title="Recurring" formControlName="schedule_is_recurring" i18n-hint hint="Trigger is recurring." [error]="error?.schedule_is_recurring"></pngx-input-check>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
@if (formGroup.get('schedule_is_recurring').value === true) {
|
||||||
|
<pngx-input-number i18n-title title="Recurring interval days" formControlName="schedule_recurring_interval_days" i18n-hint hint="Repeat the trigger every n days." [showAdd]="false" [error]="error?.schedule_recurring_interval_days"></pngx-input-number>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<p class="small" i18n>Trigger for documents that match <em>all</em> filters specified below.</p>
|
<p class="small" i18n>Trigger for documents that match <em>all</em> filters specified below.</p>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
@ -128,7 +154,7 @@
|
|||||||
<pngx-input-text i18n-title title="Filter path" formControlName="filter_path" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case-normalized.</a>" [error]="error?.filter_path"></pngx-input-text>
|
<pngx-input-text i18n-title title="Filter path" formControlName="filter_path" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case-normalized.</a>" [error]="error?.filter_path"></pngx-input-text>
|
||||||
<pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select>
|
<pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select>
|
||||||
}
|
}
|
||||||
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated) {
|
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
|
||||||
<pngx-input-select i18n-title title="Content matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
<pngx-input-select i18n-title title="Content matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
||||||
@if (patternRequired) {
|
@if (patternRequired) {
|
||||||
<pngx-input-text i18n-title title="Content matching pattern" formControlName="match" [error]="error?.match"></pngx-input-text>
|
<pngx-input-text i18n-title title="Content matching pattern" formControlName="match" [error]="error?.match"></pngx-input-text>
|
||||||
@ -138,7 +164,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated) {
|
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<pngx-input-tags [allowCreate]="false" i18n-title title="Has any of tags" formControlName="filter_has_tags"></pngx-input-tags>
|
<pngx-input-tags [allowCreate]="false" i18n-title title="Has any of tags" formControlName="filter_has_tags"></pngx-input-tags>
|
||||||
<pngx-input-select i18n-title title="Has correspondent" [items]="correspondents" [allowNull]="true" formControlName="filter_has_correspondent"></pngx-input-select>
|
<pngx-input-select i18n-title title="Has correspondent" [items]="correspondents" [allowNull]="true" formControlName="filter_has_correspondent"></pngx-input-select>
|
||||||
|
@ -22,6 +22,7 @@ import { SwitchComponent } from '../../input/switch/switch.component'
|
|||||||
import { EditDialogMode } from '../edit-dialog.component'
|
import { EditDialogMode } from '../edit-dialog.component'
|
||||||
import {
|
import {
|
||||||
DOCUMENT_SOURCE_OPTIONS,
|
DOCUMENT_SOURCE_OPTIONS,
|
||||||
|
SCHEDULE_DATE_FIELD_OPTIONS,
|
||||||
WORKFLOW_ACTION_OPTIONS,
|
WORKFLOW_ACTION_OPTIONS,
|
||||||
WORKFLOW_TYPE_OPTIONS,
|
WORKFLOW_TYPE_OPTIONS,
|
||||||
WorkflowEditDialogComponent,
|
WorkflowEditDialogComponent,
|
||||||
@ -40,6 +41,7 @@ import {
|
|||||||
import { MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/matching-model'
|
import { MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/matching-model'
|
||||||
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
|
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
|
||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
|
import { CustomFieldDataType } from 'src/app/data/custom-field'
|
||||||
|
|
||||||
const workflow: Workflow = {
|
const workflow: Workflow = {
|
||||||
name: 'Workflow 1',
|
name: 'Workflow 1',
|
||||||
@ -148,7 +150,18 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
useValue: {
|
useValue: {
|
||||||
listAll: () =>
|
listAll: () =>
|
||||||
of({
|
of({
|
||||||
results: [],
|
results: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'cf1',
|
||||||
|
data_type: CustomFieldDataType.String,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'cf2',
|
||||||
|
data_type: CustomFieldDataType.Date,
|
||||||
|
},
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -186,7 +199,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
expect(editTitleSpy).toHaveBeenCalled()
|
expect(editTitleSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return source options, type options, type name', () => {
|
it('should return source options, type options, type name, schedule date field options', () => {
|
||||||
// coverage
|
// coverage
|
||||||
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
|
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
|
||||||
expect(component.triggerTypeOptions).toEqual(WORKFLOW_TYPE_OPTIONS)
|
expect(component.triggerTypeOptions).toEqual(WORKFLOW_TYPE_OPTIONS)
|
||||||
@ -200,6 +213,9 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
component.getActionTypeOptionName(WorkflowActionType.Assignment)
|
component.getActionTypeOptionName(WorkflowActionType.Assignment)
|
||||||
).toEqual('Assignment')
|
).toEqual('Assignment')
|
||||||
expect(component.getActionTypeOptionName(null)).toEqual('')
|
expect(component.getActionTypeOptionName(null)).toEqual('')
|
||||||
|
expect(component.scheduleDateFieldOptions).toEqual(
|
||||||
|
SCHEDULE_DATE_FIELD_OPTIONS
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support add and remove triggers and actions', () => {
|
it('should support add and remove triggers and actions', () => {
|
||||||
|
@ -16,9 +16,10 @@ import { EditDialogComponent } from '../edit-dialog.component'
|
|||||||
import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
|
import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
|
||||||
import { MailRule } from 'src/app/data/mail-rule'
|
import { MailRule } from 'src/app/data/mail-rule'
|
||||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||||
import { CustomField } from 'src/app/data/custom-field'
|
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||||
import {
|
import {
|
||||||
DocumentSource,
|
DocumentSource,
|
||||||
|
ScheduleDateField,
|
||||||
WorkflowTrigger,
|
WorkflowTrigger,
|
||||||
WorkflowTriggerType,
|
WorkflowTriggerType,
|
||||||
} from 'src/app/data/workflow-trigger'
|
} from 'src/app/data/workflow-trigger'
|
||||||
@ -48,6 +49,25 @@ export const DOCUMENT_SOURCE_OPTIONS = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export const SCHEDULE_DATE_FIELD_OPTIONS = [
|
||||||
|
{
|
||||||
|
id: ScheduleDateField.Added,
|
||||||
|
name: $localize`Added`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: ScheduleDateField.Created,
|
||||||
|
name: $localize`Created`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: ScheduleDateField.Modified,
|
||||||
|
name: $localize`Modified`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: ScheduleDateField.CustomField,
|
||||||
|
name: $localize`Custom Field`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
export const WORKFLOW_TYPE_OPTIONS = [
|
export const WORKFLOW_TYPE_OPTIONS = [
|
||||||
{
|
{
|
||||||
id: WorkflowTriggerType.Consumption,
|
id: WorkflowTriggerType.Consumption,
|
||||||
@ -61,6 +81,10 @@ export const WORKFLOW_TYPE_OPTIONS = [
|
|||||||
id: WorkflowTriggerType.DocumentUpdated,
|
id: WorkflowTriggerType.DocumentUpdated,
|
||||||
name: $localize`Document Updated`,
|
name: $localize`Document Updated`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: WorkflowTriggerType.Scheduled,
|
||||||
|
name: $localize`Scheduled`,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export const WORKFLOW_ACTION_OPTIONS = [
|
export const WORKFLOW_ACTION_OPTIONS = [
|
||||||
@ -96,6 +120,7 @@ export class WorkflowEditDialogComponent
|
|||||||
storagePaths: StoragePath[]
|
storagePaths: StoragePath[]
|
||||||
mailRules: MailRule[]
|
mailRules: MailRule[]
|
||||||
customFields: CustomField[]
|
customFields: CustomField[]
|
||||||
|
dateCustomFields: CustomField[]
|
||||||
|
|
||||||
expandedItem: number = null
|
expandedItem: number = null
|
||||||
|
|
||||||
@ -135,7 +160,12 @@ export class WorkflowEditDialogComponent
|
|||||||
customFieldsService
|
customFieldsService
|
||||||
.listAll()
|
.listAll()
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe((result) => (this.customFields = result.results))
|
.subscribe((result) => {
|
||||||
|
this.customFields = result.results
|
||||||
|
this.dateCustomFields = this.customFields?.filter(
|
||||||
|
(f) => f.data_type === CustomFieldDataType.Date
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
getCreateTitle() {
|
getCreateTitle() {
|
||||||
@ -314,6 +344,15 @@ export class WorkflowEditDialogComponent
|
|||||||
filter_has_document_type: new FormControl(
|
filter_has_document_type: new FormControl(
|
||||||
trigger.filter_has_document_type
|
trigger.filter_has_document_type
|
||||||
),
|
),
|
||||||
|
schedule_offset_days: new FormControl(trigger.schedule_offset_days),
|
||||||
|
schedule_is_recurring: new FormControl(trigger.schedule_is_recurring),
|
||||||
|
schedule_recurring_interval_days: new FormControl(
|
||||||
|
trigger.schedule_recurring_interval_days
|
||||||
|
),
|
||||||
|
schedule_date_field: new FormControl(trigger.schedule_date_field),
|
||||||
|
schedule_date_custom_field: new FormControl(
|
||||||
|
trigger.schedule_date_custom_field
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
{ emitEvent }
|
{ emitEvent }
|
||||||
)
|
)
|
||||||
@ -388,6 +427,10 @@ export class WorkflowEditDialogComponent
|
|||||||
return WORKFLOW_TYPE_OPTIONS
|
return WORKFLOW_TYPE_OPTIONS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get scheduleDateFieldOptions() {
|
||||||
|
return SCHEDULE_DATE_FIELD_OPTIONS
|
||||||
|
}
|
||||||
|
|
||||||
getTriggerTypeOptionName(type: WorkflowTriggerType): string {
|
getTriggerTypeOptionName(type: WorkflowTriggerType): string {
|
||||||
return this.triggerTypeOptions.find((t) => t.id === type)?.name ?? ''
|
return this.triggerTypeOptions.find((t) => t.id === type)?.name ?? ''
|
||||||
}
|
}
|
||||||
@ -408,6 +451,11 @@ export class WorkflowEditDialogComponent
|
|||||||
matching_algorithm: MATCH_NONE,
|
matching_algorithm: MATCH_NONE,
|
||||||
match: '',
|
match: '',
|
||||||
is_insensitive: true,
|
is_insensitive: true,
|
||||||
|
schedule_offset_days: 0,
|
||||||
|
schedule_is_recurring: false,
|
||||||
|
schedule_recurring_interval_days: 1,
|
||||||
|
schedule_date_field: ScheduleDateField.Added,
|
||||||
|
schedule_date_custom_field: null,
|
||||||
}
|
}
|
||||||
this.object.triggers.push(trigger)
|
this.object.triggers.push(trigger)
|
||||||
this.createTriggerField(trigger)
|
this.createTriggerField(trigger)
|
||||||
|
@ -35,23 +35,31 @@
|
|||||||
</div>
|
</div>
|
||||||
@if (selectionModel.items) {
|
@if (selectionModel.items) {
|
||||||
<div class="items" #buttonItems>
|
<div class="items" #buttonItems>
|
||||||
@for (item of selectionModel.itemsSorted | filter: filterText:'name'; track item; let i = $index) {
|
@for (item of selectionModel.items | filter: filterText:'name'; track item; let i = $index) {
|
||||||
@if (allowSelectNone || item.id) {
|
@if (allowSelectNone || item.id) {
|
||||||
<pngx-toggleable-dropdown-button
|
<pngx-toggleable-dropdown-button
|
||||||
[item]="item" [hideCount]="hideCount(item)" [state]="selectionModel.get(item.id)" [count]="getUpdatedDocumentCount(item.id)" (toggled)="selectionModel.toggle(item.id)" (exclude)="excludeClicked(item.id)" (click)="setButtonItemIndex(i - 1)" [disabled]="disabled">
|
[item]="item"
|
||||||
|
[hideCount]="hideCount(item)"
|
||||||
|
[opacifyCount]="!editing"
|
||||||
|
[state]="selectionModel.get(item.id)"
|
||||||
|
[count]="getUpdatedDocumentCount(item.id)"
|
||||||
|
(toggled)="selectionModel.toggle(item.id)"
|
||||||
|
(exclude)="excludeClicked(item.id)"
|
||||||
|
(click)="setButtonItemIndex(i - 1)"
|
||||||
|
[disabled]="disabled">
|
||||||
</pngx-toggleable-dropdown-button>
|
</pngx-toggleable-dropdown-button>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (editing) {
|
@if (editing) {
|
||||||
@if ((selectionModel.itemsSorted | filter: filterText:'name').length === 0 && createRef !== undefined) {
|
@if ((selectionModel.items | filter: filterText:'name').length === 0 && createRef !== undefined) {
|
||||||
<button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled">
|
<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>
|
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small>
|
||||||
<i-bs width="1.5em" height="1em" name="plus"></i-bs>
|
<i-bs width="1.5em" height="1em" name="plus"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if ((selectionModel.itemsSorted | filter: filterText:'name').length > 0) {
|
@if ((selectionModel.items | filter: filterText:'name').length > 0) {
|
||||||
<button class="list-group-item list-group-item-action bg-light" (click)="applyClicked()" [disabled]="!modelIsDirty || disabled">
|
<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>
|
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty}" i18n>Apply</small>
|
||||||
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
|
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
|
||||||
|
@ -501,7 +501,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
component.selectionModel = selectionModel
|
component.selectionModel = selectionModel
|
||||||
selectionModel.toggle(items[1].id)
|
selectionModel.toggle(items[1].id)
|
||||||
selectionModel.apply()
|
selectionModel.apply()
|
||||||
expect(selectionModel.itemsSorted).toEqual([
|
expect(selectionModel.items).toEqual([
|
||||||
nullItem,
|
nullItem,
|
||||||
{ id: null, name: 'Null B' },
|
{ id: null, name: 'Null B' },
|
||||||
items[1],
|
items[1],
|
||||||
@ -509,6 +509,37 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('selection model should sort items by state and document counts, if set', () => {
|
||||||
|
component.items = items.concat([{ id: 4, name: 'Item D' }])
|
||||||
|
component.selectionModel = selectionModel
|
||||||
|
component.documentCounts = [
|
||||||
|
{ id: 1, document_count: 0 }, // Tag1
|
||||||
|
{ id: 2, document_count: 1 }, // Tag2
|
||||||
|
{ id: 4, document_count: 2 },
|
||||||
|
]
|
||||||
|
component.selectionModel.apply()
|
||||||
|
expect(selectionModel.items).toEqual([
|
||||||
|
nullItem,
|
||||||
|
{ id: 4, name: 'Item D' },
|
||||||
|
items[1], // Tag2
|
||||||
|
items[0], // Tag1
|
||||||
|
])
|
||||||
|
|
||||||
|
selectionModel.toggle(items[1].id)
|
||||||
|
component.documentCounts = [
|
||||||
|
{ id: 1, document_count: 0 },
|
||||||
|
{ id: 2, document_count: 1 },
|
||||||
|
{ id: 4, document_count: 0 },
|
||||||
|
]
|
||||||
|
selectionModel.apply()
|
||||||
|
expect(selectionModel.items).toEqual([
|
||||||
|
nullItem,
|
||||||
|
items[1], // Tag2
|
||||||
|
{ id: 4, name: 'Item D' },
|
||||||
|
items[0], // Tag1
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
it('should set support create, keep open model and call createRef method', fakeAsync(() => {
|
it('should set support create, keep open model and call createRef method', fakeAsync(() => {
|
||||||
component.items = items
|
component.items = items
|
||||||
component.icon = 'tag-fill'
|
component.icon = 'tag-fill'
|
||||||
|
@ -43,11 +43,23 @@ export class FilterableDropdownSelectionModel {
|
|||||||
private _intersection: Intersection = Intersection.Include
|
private _intersection: Intersection = Intersection.Include
|
||||||
temporaryIntersection: Intersection = this._intersection
|
temporaryIntersection: Intersection = this._intersection
|
||||||
|
|
||||||
items: MatchingModel[] = []
|
private _documentCounts: SelectionDataItem[] = []
|
||||||
|
public set documentCounts(counts: SelectionDataItem[]) {
|
||||||
|
this._documentCounts = counts
|
||||||
|
}
|
||||||
|
|
||||||
get itemsSorted(): MatchingModel[] {
|
private _items: MatchingModel[] = []
|
||||||
// TODO: this is getting called very often
|
get items(): MatchingModel[] {
|
||||||
return this.items.sort((a, b) => {
|
return this._items
|
||||||
|
}
|
||||||
|
|
||||||
|
set items(items: MatchingModel[]) {
|
||||||
|
this._items = items
|
||||||
|
this.sortItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
private sortItems() {
|
||||||
|
this._items.sort((a, b) => {
|
||||||
if (a.id == null && b.id != null) {
|
if (a.id == null && b.id != null) {
|
||||||
return -1
|
return -1
|
||||||
} else if (a.id != null && b.id == null) {
|
} else if (a.id != null && b.id == null) {
|
||||||
@ -62,6 +74,16 @@ export class FilterableDropdownSelectionModel {
|
|||||||
this.getNonTemporary(b.id) == ToggleableItemState.NotSelected
|
this.getNonTemporary(b.id) == ToggleableItemState.NotSelected
|
||||||
) {
|
) {
|
||||||
return -1
|
return -1
|
||||||
|
} else if (
|
||||||
|
this._documentCounts.length &&
|
||||||
|
this.getDocumentCount(a.id) > this.getDocumentCount(b.id)
|
||||||
|
) {
|
||||||
|
return -1
|
||||||
|
} else if (
|
||||||
|
this._documentCounts.length &&
|
||||||
|
this.getDocumentCount(a.id) < this.getDocumentCount(b.id)
|
||||||
|
) {
|
||||||
|
return 1
|
||||||
} else {
|
} else {
|
||||||
return a.name.localeCompare(b.name)
|
return a.name.localeCompare(b.name)
|
||||||
}
|
}
|
||||||
@ -279,6 +301,10 @@ export class FilterableDropdownSelectionModel {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getDocumentCount(id: number) {
|
||||||
|
return this._documentCounts.find((c) => c.id === id)?.document_count
|
||||||
|
}
|
||||||
|
|
||||||
init(map: Map<number, ToggleableItemState>) {
|
init(map: Map<number, ToggleableItemState>) {
|
||||||
this.temporarySelectionStates = map
|
this.temporarySelectionStates = map
|
||||||
this.apply()
|
this.apply()
|
||||||
@ -291,6 +317,7 @@ export class FilterableDropdownSelectionModel {
|
|||||||
})
|
})
|
||||||
this._logicalOperator = this.temporaryLogicalOperator
|
this._logicalOperator = this.temporaryLogicalOperator
|
||||||
this._intersection = this.temporaryIntersection
|
this._intersection = this.temporaryIntersection
|
||||||
|
this.sortItems()
|
||||||
}
|
}
|
||||||
|
|
||||||
reset(complete: boolean = false) {
|
reset(complete: boolean = false) {
|
||||||
@ -423,7 +450,11 @@ export class FilterableDropdownComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
documentCounts: SelectionDataItem[]
|
set documentCounts(counts: SelectionDataItem[]) {
|
||||||
|
if (counts) {
|
||||||
|
this.selectionModel.documentCounts = counts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
shortcutKey: string
|
shortcutKey: string
|
||||||
@ -536,9 +567,7 @@ export class FilterableDropdownComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getUpdatedDocumentCount(id: number) {
|
getUpdatedDocumentCount(id: number) {
|
||||||
if (this.documentCounts) {
|
return this.selectionModel.getDocumentCount(id)
|
||||||
return this.documentCounts.find((c) => c.id === id)?.document_count
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
listKeyDown(event: KeyboardEvent) {
|
listKeyDown(event: KeyboardEvent) {
|
||||||
|
@ -1,4 +1,9 @@
|
|||||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="toggleItem($event)" [disabled]="disabled">
|
<button
|
||||||
|
class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom"
|
||||||
|
[class.opacity-50]="opacifyCount && !hideCount && currentCount === 0"
|
||||||
|
role="menuitem"
|
||||||
|
(click)="toggleItem($event)"
|
||||||
|
[disabled]="disabled">
|
||||||
<div class="selected-icon me-1">
|
<div class="selected-icon me-1">
|
||||||
@if (isChecked()) {
|
@if (isChecked()) {
|
||||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||||
@ -18,6 +23,6 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (!hideCount) {
|
@if (!hideCount) {
|
||||||
<div class="badge bg-light text-dark rounded-pill ms-auto me-1">{{count ?? item.document_count}}</div>
|
<div class="badge bg-light text-dark rounded-pill ms-auto me-1">{{currentCount}}</div>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
@ -29,6 +29,9 @@ export class ToggleableDropdownButtonComponent {
|
|||||||
@Input()
|
@Input()
|
||||||
hideCount: boolean = false
|
hideCount: boolean = false
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
opacifyCount: boolean = true
|
||||||
|
|
||||||
@Output()
|
@Output()
|
||||||
toggled = new EventEmitter()
|
toggled = new EventEmitter()
|
||||||
|
|
||||||
@ -39,6 +42,10 @@ export class ToggleableDropdownButtonComponent {
|
|||||||
return 'is_inbox_tag' in this.item
|
return 'is_inbox_tag' in this.item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get currentCount(): number {
|
||||||
|
return this.count ?? this.item.document_count
|
||||||
|
}
|
||||||
|
|
||||||
toggleItem(event: MouseEvent): void {
|
toggleItem(event: MouseEvent): void {
|
||||||
if (this.state == ToggleableItemState.Selected) {
|
if (this.state == ToggleableItemState.Selected) {
|
||||||
this.exclude.emit()
|
this.exclude.emit()
|
||||||
|
@ -1,30 +1,37 @@
|
|||||||
<div class="preview-popup-container">
|
<a [href]="link ?? previewUrl" class="{{linkClasses}}" [target]="linkTarget" [title]="linkTitle"
|
||||||
@if (error) {
|
[ngbPopover]="previewContent" [popoverTitle]="document.title | documentTitle" container="body"
|
||||||
<div class="w-100 h-100 position-relative">
|
autoClose="true" [popoverClass]="popoverClass" (mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()" #popover="ngbPopover">
|
||||||
<p class="fst-italic position-absolute top-50 start-50 translate-middle" i18n>Error loading preview</p>
|
<ng-content></ng-content>
|
||||||
</div>
|
</a>
|
||||||
} @else {
|
<ng-template #previewContent>
|
||||||
@if (renderAsObject) {
|
<div class="preview-popup-container">
|
||||||
@if (previewText) {
|
@if (error) {
|
||||||
<div class="bg-light p-3 overflow-auto whitespace-preserve" width="100%">{{previewText}}</div>
|
<div class="w-100 h-100 position-relative">
|
||||||
} @else {
|
<p class="fst-italic position-absolute top-50 start-50 translate-middle" i18n>Error loading preview</p>
|
||||||
<object [data]="previewURL | safeUrl" width="100%" class="bg-light" [class.p-2]="!isPdf"></object>
|
</div>
|
||||||
}
|
|
||||||
} @else {
|
} @else {
|
||||||
@if (requiresPassword) {
|
@if (renderAsObject) {
|
||||||
<div class="w-100 h-100 position-relative">
|
@if (previewText) {
|
||||||
<i-bs width="2em" height="2em" class="position-absolute top-50 start-50 translate-middle" name="file-earmark-lock"></i-bs>
|
<div class="bg-light p-3 overflow-auto whitespace-preserve" width="100%">{{previewText}}</div>
|
||||||
</div>
|
} @else {
|
||||||
}
|
<object [data]="previewURL | safeUrl" width="100%" class="bg-light" [class.p-2]="!isPdf"></object>
|
||||||
@if (!requiresPassword) {
|
}
|
||||||
<pdf-viewer
|
} @else {
|
||||||
[src]="previewURL"
|
@if (requiresPassword) {
|
||||||
[original-size]="false"
|
<div class="w-100 h-100 position-relative">
|
||||||
[show-borders]="false"
|
<i-bs width="2em" height="2em" class="position-absolute top-50 start-50 translate-middle" name="file-earmark-lock"></i-bs>
|
||||||
[show-all]="true"
|
</div>
|
||||||
(error)="onError($event)">
|
}
|
||||||
</pdf-viewer>
|
@if (!requiresPassword) {
|
||||||
|
<pdf-viewer
|
||||||
|
[src]="previewURL"
|
||||||
|
[original-size]="false"
|
||||||
|
[show-borders]="false"
|
||||||
|
[show-all]="true"
|
||||||
|
(error)="onError($event)">
|
||||||
|
</pdf-viewer>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
</div>
|
||||||
</div>
|
</ng-template>
|
||||||
|
@ -1,4 +1,9 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import {
|
||||||
|
ComponentFixture,
|
||||||
|
fakeAsync,
|
||||||
|
TestBed,
|
||||||
|
tick,
|
||||||
|
} from '@angular/core/testing'
|
||||||
|
|
||||||
import { PreviewPopupComponent } from './preview-popup.component'
|
import { PreviewPopupComponent } from './preview-popup.component'
|
||||||
import { By } from '@angular/platform-browser'
|
import { By } from '@angular/platform-browser'
|
||||||
@ -15,6 +20,8 @@ import {
|
|||||||
withInterceptorsFromDi,
|
withInterceptorsFromDi,
|
||||||
} from '@angular/common/http'
|
} from '@angular/common/http'
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
|
import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
||||||
|
|
||||||
const doc = {
|
const doc = {
|
||||||
id: 10,
|
id: 10,
|
||||||
@ -34,8 +41,12 @@ describe('PreviewPopupComponent', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
declarations: [PreviewPopupComponent, SafeUrlPipe],
|
declarations: [PreviewPopupComponent, SafeUrlPipe, DocumentTitlePipe],
|
||||||
imports: [NgxBootstrapIconsModule.pick(allIcons), PdfViewerModule],
|
imports: [
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
PdfViewerModule,
|
||||||
|
NgbPopoverModule,
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
@ -70,12 +81,14 @@ describe('PreviewPopupComponent', () => {
|
|||||||
|
|
||||||
it('should render object if native PDF viewer enabled', () => {
|
it('should render object if native PDF viewer enabled', () => {
|
||||||
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, true)
|
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, true)
|
||||||
|
component.popover.open()
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(fixture.debugElement.query(By.css('object'))).not.toBeNull()
|
expect(fixture.debugElement.query(By.css('object'))).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render pngx viewer if native PDF viewer disabled', () => {
|
it('should render pngx viewer if native PDF viewer disabled', () => {
|
||||||
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
|
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
|
||||||
|
component.popover.open()
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(fixture.debugElement.query(By.css('object'))).toBeNull()
|
expect(fixture.debugElement.query(By.css('object'))).toBeNull()
|
||||||
expect(fixture.debugElement.query(By.css('pdf-viewer'))).not.toBeNull()
|
expect(fixture.debugElement.query(By.css('pdf-viewer'))).not.toBeNull()
|
||||||
@ -83,6 +96,7 @@ describe('PreviewPopupComponent', () => {
|
|||||||
|
|
||||||
it('should show lock icon on password error', () => {
|
it('should show lock icon on password error', () => {
|
||||||
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
|
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
|
||||||
|
component.popover.open()
|
||||||
component.onError({ name: 'PasswordException' })
|
component.onError({ name: 'PasswordException' })
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(component.requiresPassword).toBeTruthy()
|
expect(component.requiresPassword).toBeTruthy()
|
||||||
@ -93,16 +107,18 @@ describe('PreviewPopupComponent', () => {
|
|||||||
component.document.original_file_name = 'sample.png'
|
component.document.original_file_name = 'sample.png'
|
||||||
component.document.mime_type = 'image/png'
|
component.document.mime_type = 'image/png'
|
||||||
component.document.archived_file_name = undefined
|
component.document.archived_file_name = undefined
|
||||||
|
component.popover.open()
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(fixture.debugElement.query(By.css('object'))).not.toBeNull()
|
expect(fixture.debugElement.query(By.css('object'))).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should show message on error', () => {
|
it('should show message on error', () => {
|
||||||
|
component.popover.open()
|
||||||
component.onError({})
|
component.onError({})
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(fixture.debugElement.nativeElement.textContent).toContain(
|
expect(
|
||||||
'Error loading preview'
|
fixture.debugElement.query(By.css('.popover')).nativeElement.textContent
|
||||||
)
|
).toContain('Error loading preview')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should get text content from http if appropriate', () => {
|
it('should get text content from http if appropriate', () => {
|
||||||
@ -122,4 +138,17 @@ describe('PreviewPopupComponent', () => {
|
|||||||
component.init()
|
component.init()
|
||||||
expect(component.previewText).toEqual('Preview text')
|
expect(component.previewText).toEqual('Preview text')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should show preview on mouseover after delay to preload content', fakeAsync(() => {
|
||||||
|
component.mouseEnterPreview()
|
||||||
|
expect(component.popover.isOpen()).toBeTruthy()
|
||||||
|
tick(600)
|
||||||
|
component.close()
|
||||||
|
|
||||||
|
component.mouseEnterPreview()
|
||||||
|
tick(100)
|
||||||
|
component.mouseLeavePreview()
|
||||||
|
tick(600)
|
||||||
|
expect(component.popover.isOpen()).toBeFalsy()
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { HttpClient } from '@angular/common/http'
|
import { HttpClient } from '@angular/common/http'
|
||||||
import { Component, Input, OnDestroy } from '@angular/core'
|
import { Component, Input, OnDestroy, ViewChild } from '@angular/core'
|
||||||
|
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { first, Subject, takeUntil } from 'rxjs'
|
import { first, Subject, takeUntil } from 'rxjs'
|
||||||
import { Document } from 'src/app/data/document'
|
import { Document } from 'src/app/data/document'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
@ -23,6 +24,18 @@ export class PreviewPopupComponent implements OnDestroy {
|
|||||||
return this._document
|
return this._document
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
link: string
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
linkClasses: string = 'btn btn-sm btn-outline-secondary'
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
linkTarget: string = '_blank'
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
linkTitle: string = $localize`Open preview`
|
||||||
|
|
||||||
unsubscribeNotifier: Subject<any> = new Subject()
|
unsubscribeNotifier: Subject<any> = new Subject()
|
||||||
|
|
||||||
error = false
|
error = false
|
||||||
@ -31,6 +44,12 @@ export class PreviewPopupComponent implements OnDestroy {
|
|||||||
|
|
||||||
previewText: string
|
previewText: string
|
||||||
|
|
||||||
|
@ViewChild('popover') popover: NgbPopover
|
||||||
|
|
||||||
|
mouseOnPreview: boolean
|
||||||
|
|
||||||
|
popoverClass: string = 'shadow popover-preview'
|
||||||
|
|
||||||
get renderAsObject(): boolean {
|
get renderAsObject(): boolean {
|
||||||
return (this.isPdf && this.useNativePdfViewer) || !this.isPdf
|
return (this.isPdf && this.useNativePdfViewer) || !this.isPdf
|
||||||
}
|
}
|
||||||
@ -83,4 +102,33 @@ export class PreviewPopupComponent implements OnDestroy {
|
|||||||
this.error = true
|
this.error = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get previewUrl() {
|
||||||
|
return this.documentService.getPreviewUrl(this.document.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
mouseEnterPreview() {
|
||||||
|
this.mouseOnPreview = true
|
||||||
|
if (!this.popover.isOpen()) {
|
||||||
|
// we're going to open but hide to pre-load content during hover delay
|
||||||
|
this.popover.open()
|
||||||
|
this.popoverClass = 'shadow popover-preview pe-none opacity-0'
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.mouseOnPreview) {
|
||||||
|
// show popover
|
||||||
|
this.popoverClass = this.popoverClass.replace('pe-none opacity-0', '')
|
||||||
|
} else {
|
||||||
|
this.popover.close()
|
||||||
|
}
|
||||||
|
}, 600)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mouseLeavePreview() {
|
||||||
|
this.mouseOnPreview = false
|
||||||
|
}
|
||||||
|
|
||||||
|
public close() {
|
||||||
|
this.popover.close(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,94 +5,179 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<pngx-input-text i18n-title title="Email" formControlName="email" (keyup)="onEmailKeyUp($event)" [error]="error?.email"></pngx-input-text>
|
<div class="row">
|
||||||
<div ngbAccordion>
|
<div class="col-12 col-md-6">
|
||||||
<div ngbAccordionItem="first" [collapsed]="!showEmailConfirm" class="border-0 bg-transparent">
|
<pngx-input-text i18n-title title="Email" formControlName="email" (keyup)="onEmailKeyUp($event)" [error]="error?.email"></pngx-input-text>
|
||||||
<div ngbAccordionCollapse>
|
<div ngbAccordion>
|
||||||
<div ngbAccordionBody class="p-0 pb-3">
|
<div ngbAccordionItem="first" [collapsed]="!showEmailConfirm" class="border-0 bg-transparent">
|
||||||
<pngx-input-text i18n-title title="Confirm Email" formControlName="email_confirm" (keyup)="onEmailConfirmKeyUp($event)" autocomplete="email" [error]="error?.email_confirm"></pngx-input-text>
|
<div ngbAccordionCollapse>
|
||||||
|
<div ngbAccordionBody class="p-0 pb-3">
|
||||||
|
<pngx-input-text i18n-title title="Confirm Email" formControlName="email_confirm" (keyup)="onEmailConfirmKeyUp($event)" autocomplete="email" [error]="error?.email_confirm"></pngx-input-text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<pngx-input-password i18n-title title="Password" formControlName="password" (keyup)="onPasswordKeyUp($event)" [showReveal]="true" autocomplete="current-password" [error]="error?.password"></pngx-input-password>
|
||||||
</div>
|
<div ngbAccordion>
|
||||||
<pngx-input-password i18n-title title="Password" formControlName="password" (keyup)="onPasswordKeyUp($event)" [showReveal]="true" autocomplete="current-password" [error]="error?.password"></pngx-input-password>
|
<div ngbAccordionItem="first" [collapsed]="!showPasswordConfirm" class="border-0 bg-transparent">
|
||||||
<div ngbAccordion>
|
<div ngbAccordionCollapse>
|
||||||
<div ngbAccordionItem="first" [collapsed]="!showPasswordConfirm" class="border-0 bg-transparent">
|
<div ngbAccordionBody class="p-0 pb-3">
|
||||||
<div ngbAccordionCollapse>
|
<pngx-input-password i18n-title title="Confirm Password" formControlName="password_confirm" (keyup)="onPasswordConfirmKeyUp($event)" autocomplete="new-password" [error]="error?.password_confirm"></pngx-input-password>
|
||||||
<div ngbAccordionBody class="p-0 pb-3">
|
</div>
|
||||||
<pngx-input-password i18n-title title="Confirm Password" formControlName="password_confirm" (keyup)="onPasswordConfirmKeyUp($event)" autocomplete="new-password" [error]="error?.password_confirm"></pngx-input-password>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<pngx-input-text i18n-title title="First name" formControlName="first_name" [error]="error?.first_name"></pngx-input-text>
|
||||||
</div>
|
<pngx-input-text i18n-title title="Last name" formControlName="last_name" [error]="error?.first_name"></pngx-input-text>
|
||||||
<pngx-input-text i18n-title title="First name" formControlName="first_name" [error]="error?.first_name"></pngx-input-text>
|
|
||||||
<pngx-input-text i18n-title title="Last name" formControlName="last_name" [error]="error?.first_name"></pngx-input-text>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label" i18n>API Auth Token</label>
|
|
||||||
<div class="position-relative">
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="text" class="form-control" formControlName="auth_token" readonly>
|
|
||||||
<button type="button" class="btn btn-outline-secondary" (click)="copyAuthToken()" i18n-title title="Copy">
|
|
||||||
@if (!copied) {
|
|
||||||
<i-bs width="1em" height="1em" name="clipboard-fill"></i-bs>
|
|
||||||
}
|
|
||||||
@if (copied) {
|
|
||||||
<i-bs width="1em" height="1em" name="clipboard-check-fill"></i-bs>
|
|
||||||
}
|
|
||||||
<span class="visually-hidden" i18n>Copy</span>
|
|
||||||
</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">
|
<div class="mb-3">
|
||||||
<p i18n>Connected social accounts</p>
|
<label class="form-label" i18n>API Auth Token</label>
|
||||||
<ul class="list-group">
|
<div class="position-relative">
|
||||||
@for (account of socialAccounts; track account.id) {
|
<div class="input-group">
|
||||||
<li class="list-group-item"
|
<input type="text" class="form-control" formControlName="auth_token" readonly>
|
||||||
ngbPopover="Set a password before disconnecting social account."
|
<button type="button" class="btn btn-outline-secondary" (click)="copyAuthToken()" i18n-title title="Copy">
|
||||||
i18n-ngbPopover
|
@if (!copied) {
|
||||||
[disablePopover]="hasUsablePassword"
|
<i-bs width="1em" height="1em" name="clipboard-fill"></i-bs>
|
||||||
triggers="mouseenter:mouseleave">
|
}
|
||||||
{{account.name}} ({{account.provider}})
|
@if (copied) {
|
||||||
|
<i-bs width="1em" height="1em" name="clipboard-check-fill"></i-bs>
|
||||||
|
}
|
||||||
|
<span class="visually-hidden" i18n>Copy</span>
|
||||||
|
</button>
|
||||||
<pngx-confirm-button
|
<pngx-confirm-button
|
||||||
label="Disconnect"
|
title="Regenerate auth token"
|
||||||
i18n-label
|
|
||||||
title="Disconnect {{ account.name }} social account"
|
|
||||||
i18n-title
|
i18n-title
|
||||||
buttonClasses="btn-outline-danger btn-sm ms-2 align-baseline"
|
buttonClasses=" btn-outline-secondary"
|
||||||
iconName="trash"
|
iconName="arrow-repeat"
|
||||||
[disabled]="!hasUsablePassword"
|
[disabled]="!hasUsablePassword"
|
||||||
(confirm)="disconnectSocialAccount(account.id)">
|
(confirm)="generateAuthToken()">
|
||||||
</pngx-confirm-button>
|
</pngx-confirm-button>
|
||||||
</li>
|
</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>
|
||||||
</ul>
|
</div>
|
||||||
<div class="form-text text-muted text-end fst-italic" i18n>Warning: disconnecting social accounts cannot be undone</div>
|
<div class="form-text text-muted text-end fst-italic" i18n>Warning: changing the token 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>
|
||||||
}
|
<div class="col-12 col-md-6">
|
||||||
|
@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>
|
||||||
|
}
|
||||||
|
@if (!isTotpEnabled) {
|
||||||
|
<div ngbAccordion>
|
||||||
|
<div ngbAccordionItem>
|
||||||
|
<h2 ngbAccordionHeader>
|
||||||
|
<button ngbAccordionButton (click)="gettotpSettings()" i18n>Two-factor Authentication</button>
|
||||||
|
</h2>
|
||||||
|
<div ngbAccordionCollapse>
|
||||||
|
<div ngbAccordionBody>
|
||||||
|
<ng-template>
|
||||||
|
@if (totpSettingsLoading) {
|
||||||
|
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||||
|
<div class="visually-hidden" i18n>Loading...</div>
|
||||||
|
} @else if (totpSettings) {
|
||||||
|
<figure class="figure">
|
||||||
|
<div class="bg-white d-inline-block" [innerHTML]="totpSettings.qr_svg | safeHtml"></div>
|
||||||
|
<figcaption class="figure-caption text-end mt-2" i18n>Scan the QR code with your authenticator app and then enter the code below</figcaption>
|
||||||
|
</figure>
|
||||||
|
<p>
|
||||||
|
<ng-container i18n>Authenticator secret</ng-container>: <code>{{totpSettings.secret}}</code>.
|
||||||
|
<ng-container i18n>You can store this secret and use it to reinstall your authenticator app at a later time.</ng-container>
|
||||||
|
</p>
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<input type="text" class="form-control" formControlName="totp_code" placeholder="Code" i18n-placeholder>
|
||||||
|
<button type="button" class="btn btn-primary ml-auto" (click)="activateTotp()" [disabled]="totpLoading">
|
||||||
|
<ng-container i18n>Enable</ng-container>
|
||||||
|
@if (totpLoading) {
|
||||||
|
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
|
||||||
|
<div class="visually-hidden" i18n>Loading...</div>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<label class="d-block mb-2" i18n>Two-factor Authentication</label>
|
||||||
|
@if (recoveryCodes) {
|
||||||
|
<div class="alert alert-warning" role="alert">
|
||||||
|
<i-bs name="exclamation-triangle"></i-bs> <ng-container i18n>Recovery codes will not be shown again, make sure to save them.</ng-container>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-row align-items-start mb-3">
|
||||||
|
<ul class="list-group w-50">
|
||||||
|
@for (code of recoveryCodes; track code; let i = $index) {
|
||||||
|
@if (i % 2 === 0) {
|
||||||
|
<li class="list-group-item d-flex justify-content-around align-items-center">
|
||||||
|
<code>{{code}}</code>
|
||||||
|
@if (recoveryCodes[i + 1]) {
|
||||||
|
<code>{{recoveryCodes[i + 1]}}</code>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary ms-2" (click)="copyRecoveryCodes()" i18n-title title="Copy">
|
||||||
|
@if (!codesCopied) {
|
||||||
|
<i-bs width="1em" height="1em" name="clipboard-fill"></i-bs>
|
||||||
|
<span i18n>Copy codes</span>
|
||||||
|
}
|
||||||
|
@if (codesCopied) {
|
||||||
|
<i-bs width="1em" height="1em" name="clipboard-check-fill" class="text-primary"></i-bs>
|
||||||
|
<span class="text-primary" i18n>Copied!</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<pngx-confirm-button
|
||||||
|
label="Disable Two-factor Authentication"
|
||||||
|
i18n-label
|
||||||
|
title="Disable Two-factor Authentication"
|
||||||
|
i18n-title
|
||||||
|
buttonClasses="btn-outline-danger btn-sm"
|
||||||
|
iconName="trash"
|
||||||
|
[disabled]="totpLoading"
|
||||||
|
(confirm)="deactivateTotp()">
|
||||||
|
</pngx-confirm-button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
|
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
|
||||||
|
@ -294,4 +294,85 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
expect(disconnectSpy).toHaveBeenCalled()
|
expect(disconnectSpy).toHaveBeenCalled()
|
||||||
expect(component.socialAccounts).not.toContainEqual(socialAccount)
|
expect(component.socialAccounts).not.toContainEqual(socialAccount)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should get totp settings', () => {
|
||||||
|
const settings = {
|
||||||
|
url: 'http://localhost/',
|
||||||
|
qr_svg: 'svg',
|
||||||
|
secret: 'secret',
|
||||||
|
}
|
||||||
|
const getSpy = jest.spyOn(profileService, 'getTotpSettings')
|
||||||
|
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
getSpy.mockReturnValueOnce(
|
||||||
|
throwError(() => new Error('failed to get settings'))
|
||||||
|
)
|
||||||
|
component.gettotpSettings()
|
||||||
|
expect(getSpy).toHaveBeenCalled()
|
||||||
|
expect(toastSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
getSpy.mockReturnValue(of(settings))
|
||||||
|
component.gettotpSettings()
|
||||||
|
expect(getSpy).toHaveBeenCalled()
|
||||||
|
expect(component.totpSettings).toEqual(settings)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should activate totp', () => {
|
||||||
|
const activateSpy = jest.spyOn(profileService, 'activateTotp')
|
||||||
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
|
const error = new Error('failed to activate totp')
|
||||||
|
activateSpy.mockReturnValueOnce(throwError(() => error))
|
||||||
|
component.totpSettings = {
|
||||||
|
url: 'http://localhost/',
|
||||||
|
qr_svg: 'svg',
|
||||||
|
secret: 'secret',
|
||||||
|
}
|
||||||
|
component.form.get('totp_code').patchValue('123456')
|
||||||
|
component.activateTotp()
|
||||||
|
expect(activateSpy).toHaveBeenCalledWith(
|
||||||
|
component.totpSettings.secret,
|
||||||
|
component.form.get('totp_code').value
|
||||||
|
)
|
||||||
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
activateSpy.mockReturnValueOnce(of({ success: false, recovery_codes: [] }))
|
||||||
|
component.activateTotp()
|
||||||
|
expect(toastErrorSpy).toHaveBeenCalledWith('Error activating TOTP', error)
|
||||||
|
|
||||||
|
activateSpy.mockReturnValueOnce(
|
||||||
|
of({ success: true, recovery_codes: ['1', '2', '3'] })
|
||||||
|
)
|
||||||
|
component.activateTotp()
|
||||||
|
expect(toastInfoSpy).toHaveBeenCalled()
|
||||||
|
expect(component.isTotpEnabled).toBeTruthy()
|
||||||
|
expect(component.recoveryCodes).toEqual(['1', '2', '3'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should deactivate totp', () => {
|
||||||
|
const deactivateSpy = jest.spyOn(profileService, 'deactivateTotp')
|
||||||
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
|
const error = new Error('failed to deactivate totp')
|
||||||
|
deactivateSpy.mockReturnValueOnce(throwError(() => error))
|
||||||
|
component.deactivateTotp()
|
||||||
|
expect(deactivateSpy).toHaveBeenCalled()
|
||||||
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
deactivateSpy.mockReturnValueOnce(of(false))
|
||||||
|
component.deactivateTotp()
|
||||||
|
expect(toastErrorSpy).toHaveBeenCalledWith('Error deactivating TOTP', error)
|
||||||
|
|
||||||
|
deactivateSpy.mockReturnValueOnce(of(true))
|
||||||
|
component.deactivateTotp()
|
||||||
|
expect(toastInfoSpy).toHaveBeenCalled()
|
||||||
|
expect(component.isTotpEnabled).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should copy recovery codes', fakeAsync(() => {
|
||||||
|
const copySpy = jest.spyOn(clipboard, 'copy')
|
||||||
|
component.recoveryCodes = ['1', '2', '3']
|
||||||
|
component.copyRecoveryCodes()
|
||||||
|
expect(copySpy).toHaveBeenCalledWith('1\n2\n3')
|
||||||
|
tick(3000)
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
|
@ -2,7 +2,11 @@ import { Component, OnDestroy, OnInit } from '@angular/core'
|
|||||||
import { FormControl, FormGroup } from '@angular/forms'
|
import { FormControl, FormGroup } from '@angular/forms'
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { ProfileService } from 'src/app/services/profile.service'
|
import { ProfileService } from 'src/app/services/profile.service'
|
||||||
import { SocialAccount, SocialAccountProvider } from 'src/app/data/user-profile'
|
import {
|
||||||
|
TotpSettings,
|
||||||
|
SocialAccount,
|
||||||
|
SocialAccountProvider,
|
||||||
|
} from 'src/app/data/user-profile'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { Subject, takeUntil } from 'rxjs'
|
import { Subject, takeUntil } from 'rxjs'
|
||||||
import { Clipboard } from '@angular/cdk/clipboard'
|
import { Clipboard } from '@angular/cdk/clipboard'
|
||||||
@ -25,6 +29,7 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
|
|||||||
first_name: new FormControl(''),
|
first_name: new FormControl(''),
|
||||||
last_name: new FormControl(''),
|
last_name: new FormControl(''),
|
||||||
auth_token: new FormControl(''),
|
auth_token: new FormControl(''),
|
||||||
|
totp_code: new FormControl(''),
|
||||||
})
|
})
|
||||||
|
|
||||||
private currentPassword: string
|
private currentPassword: string
|
||||||
@ -38,7 +43,14 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
|
|||||||
private emailConfirm: string
|
private emailConfirm: string
|
||||||
public showEmailConfirm: boolean = false
|
public showEmailConfirm: boolean = false
|
||||||
|
|
||||||
|
public isTotpEnabled: boolean = false
|
||||||
|
public totpSettings: TotpSettings
|
||||||
|
public totpSettingsLoading: boolean = false
|
||||||
|
public totpLoading: boolean = false
|
||||||
|
public recoveryCodes: string[]
|
||||||
|
|
||||||
public copied: boolean = false
|
public copied: boolean = false
|
||||||
|
public codesCopied: boolean = false
|
||||||
|
|
||||||
public socialAccounts: SocialAccount[] = []
|
public socialAccounts: SocialAccount[] = []
|
||||||
public socialAccountProviders: SocialAccountProvider[] = []
|
public socialAccountProviders: SocialAccountProvider[] = []
|
||||||
@ -70,6 +82,7 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
|
|||||||
this.onPasswordChange()
|
this.onPasswordChange()
|
||||||
})
|
})
|
||||||
this.socialAccounts = profile.social_accounts
|
this.socialAccounts = profile.social_accounts
|
||||||
|
this.isTotpEnabled = profile.is_mfa_enabled
|
||||||
})
|
})
|
||||||
|
|
||||||
this.profileService
|
this.profileService
|
||||||
@ -147,6 +160,7 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
|
|||||||
const passwordChanged =
|
const passwordChanged =
|
||||||
this.newPassword && this.currentPassword !== this.newPassword
|
this.newPassword && this.currentPassword !== this.newPassword
|
||||||
const profile = Object.assign({}, this.form.value)
|
const profile = Object.assign({}, this.form.value)
|
||||||
|
delete profile.totp_code
|
||||||
this.networkActive = true
|
this.networkActive = true
|
||||||
this.profileService
|
this.profileService
|
||||||
.update(profile)
|
.update(profile)
|
||||||
@ -213,4 +227,81 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public gettotpSettings(): void {
|
||||||
|
this.totpSettingsLoading = true
|
||||||
|
this.profileService
|
||||||
|
.getTotpSettings()
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe({
|
||||||
|
next: (totpSettings) => {
|
||||||
|
this.totpSettingsLoading = false
|
||||||
|
this.totpSettings = totpSettings
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.toastService.showError(
|
||||||
|
$localize`Error fetching TOTP settings`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
this.totpSettingsLoading = false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public activateTotp(): void {
|
||||||
|
this.totpLoading = true
|
||||||
|
this.form.get('totp_code').disable()
|
||||||
|
this.profileService
|
||||||
|
.activateTotp(this.totpSettings.secret, this.form.get('totp_code').value)
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe({
|
||||||
|
next: (activationResponse) => {
|
||||||
|
this.totpLoading = false
|
||||||
|
this.isTotpEnabled = activationResponse.success
|
||||||
|
this.recoveryCodes = activationResponse.recovery_codes
|
||||||
|
this.form.get('totp_code').enable()
|
||||||
|
if (activationResponse.success) {
|
||||||
|
this.toastService.showInfo($localize`TOTP activated successfully`)
|
||||||
|
} else {
|
||||||
|
this.toastService.showError($localize`Error activating TOTP`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.totpLoading = false
|
||||||
|
this.form.get('totp_code').enable()
|
||||||
|
this.toastService.showError($localize`Error activating TOTP`, error)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public deactivateTotp(): void {
|
||||||
|
this.totpLoading = true
|
||||||
|
this.profileService
|
||||||
|
.deactivateTotp()
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe({
|
||||||
|
next: (success) => {
|
||||||
|
this.totpLoading = false
|
||||||
|
this.isTotpEnabled = !success
|
||||||
|
this.recoveryCodes = null
|
||||||
|
if (success) {
|
||||||
|
this.toastService.showInfo($localize`TOTP deactivated successfully`)
|
||||||
|
} else {
|
||||||
|
this.toastService.showError($localize`Error deactivating TOTP`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.totpLoading = false
|
||||||
|
this.toastService.showError($localize`Error deactivating TOTP`, error)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public copyRecoveryCodes(): void {
|
||||||
|
this.clipboard.copy(this.recoveryCodes.join('\n'))
|
||||||
|
this.codesCopied = true
|
||||||
|
setTimeout(() => {
|
||||||
|
this.codesCopied = false
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -389,6 +389,15 @@
|
|||||||
<img [src]="previewUrl | safeUrl" width="100%" height="100%" alt="{{title}}" />
|
<img [src]="previewUrl | safeUrl" width="100%" height="100%" alt="{{title}}" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@case (ContentRenderType.TIFF) {
|
||||||
|
@if (!tiffError) {
|
||||||
|
<div class="preview-sticky">
|
||||||
|
<img [src]="tiffURL" width="100%" height="100%" alt="{{title}}" />
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="preview-sticky bg-light p-3 overflow-auto whitespace-preserve" width="100%">{{tiffError}}</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@case (ContentRenderType.Other) {
|
@case (ContentRenderType.Other) {
|
||||||
<object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object>
|
<object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object>
|
||||||
}
|
}
|
||||||
|
@ -61,6 +61,7 @@ textarea.rtl {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
|
object-position: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumb-preview {
|
.thumb-preview {
|
||||||
|
@ -1270,4 +1270,46 @@ describe('DocumentDetailComponent', () => {
|
|||||||
expect(component.createDisabled(DataType.StoragePath)).toBeFalsy()
|
expect(component.createDisabled(DataType.StoragePath)).toBeFalsy()
|
||||||
expect(component.createDisabled(DataType.Tag)).toBeFalsy()
|
expect(component.createDisabled(DataType.Tag)).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should call tryRenderTiff when no archive and file is tiff', () => {
|
||||||
|
initNormally()
|
||||||
|
const tiffRenderSpy = jest.spyOn(
|
||||||
|
DocumentDetailComponent.prototype as any,
|
||||||
|
'tryRenderTiff'
|
||||||
|
)
|
||||||
|
const doc = Object.assign({}, component.document)
|
||||||
|
doc.archived_file_name = null
|
||||||
|
doc.mime_type = 'image/tiff'
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'getMetadata')
|
||||||
|
.mockReturnValue(
|
||||||
|
of({ has_archive_version: false, original_mime_type: 'image/tiff' })
|
||||||
|
)
|
||||||
|
component.updateComponent(doc)
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(component.archiveContentRenderType).toEqual(
|
||||||
|
component.ContentRenderType.TIFF
|
||||||
|
)
|
||||||
|
expect(tiffRenderSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should try to render tiff and show error if failed', () => {
|
||||||
|
initNormally()
|
||||||
|
// just the text request
|
||||||
|
httpTestingController.expectOne(component.previewUrl)
|
||||||
|
|
||||||
|
// invalid tiff
|
||||||
|
component['tryRenderTiff']()
|
||||||
|
httpTestingController
|
||||||
|
.expectOne(component.previewUrl)
|
||||||
|
.flush(new ArrayBuffer(100)) // arraybuffer
|
||||||
|
expect(component.tiffError).not.toBeUndefined()
|
||||||
|
|
||||||
|
// http error
|
||||||
|
component['tryRenderTiff']()
|
||||||
|
httpTestingController
|
||||||
|
.expectOne(component.previewUrl)
|
||||||
|
.error(new ErrorEvent('failed'))
|
||||||
|
expect(component.tiffError).not.toBeUndefined()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@ -72,6 +72,7 @@ import { DeletePagesConfirmDialogComponent } from '../common/confirm-dialog/dele
|
|||||||
import { HotKeyService } from 'src/app/services/hot-key.service'
|
import { HotKeyService } from 'src/app/services/hot-key.service'
|
||||||
import { PDFDocumentProxy } from 'ng2-pdf-viewer'
|
import { PDFDocumentProxy } from 'ng2-pdf-viewer'
|
||||||
import { DataType } from 'src/app/data/datatype'
|
import { DataType } from 'src/app/data/datatype'
|
||||||
|
import * as UTIF from 'utif'
|
||||||
|
|
||||||
enum DocumentDetailNavIDs {
|
enum DocumentDetailNavIDs {
|
||||||
Details = 1,
|
Details = 1,
|
||||||
@ -89,6 +90,7 @@ enum ContentRenderType {
|
|||||||
Text = 'text',
|
Text = 'text',
|
||||||
Other = 'other',
|
Other = 'other',
|
||||||
Unknown = 'unknown',
|
Unknown = 'unknown',
|
||||||
|
TIFF = 'tiff',
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ZoomSetting {
|
enum ZoomSetting {
|
||||||
@ -136,6 +138,8 @@ export class DocumentDetailComponent
|
|||||||
downloadUrl: string
|
downloadUrl: string
|
||||||
downloadOriginalUrl: string
|
downloadOriginalUrl: string
|
||||||
previewLoaded: boolean = false
|
previewLoaded: boolean = false
|
||||||
|
tiffURL: string
|
||||||
|
tiffError: string
|
||||||
|
|
||||||
correspondents: Correspondent[]
|
correspondents: Correspondent[]
|
||||||
documentTypes: DocumentType[]
|
documentTypes: DocumentType[]
|
||||||
@ -244,6 +248,8 @@ export class DocumentDetailComponent
|
|||||||
['text/plain', 'application/csv', 'text/csv'].includes(mimeType)
|
['text/plain', 'application/csv', 'text/csv'].includes(mimeType)
|
||||||
) {
|
) {
|
||||||
return ContentRenderType.Text
|
return ContentRenderType.Text
|
||||||
|
} else if (mimeType.indexOf('tiff') >= 0) {
|
||||||
|
return ContentRenderType.TIFF
|
||||||
} else if (mimeType?.indexOf('image/') === 0) {
|
} else if (mimeType?.indexOf('image/') === 0) {
|
||||||
return ContentRenderType.Image
|
return ContentRenderType.Image
|
||||||
}
|
}
|
||||||
@ -542,6 +548,9 @@ export class DocumentDetailComponent
|
|||||||
this.document = doc
|
this.document = doc
|
||||||
this.requiresPassword = false
|
this.requiresPassword = false
|
||||||
this.updateFormForCustomFields()
|
this.updateFormForCustomFields()
|
||||||
|
if (this.archiveContentRenderType === ContentRenderType.TIFF) {
|
||||||
|
this.tryRenderTiff()
|
||||||
|
}
|
||||||
this.documentsService
|
this.documentsService
|
||||||
.getMetadata(doc.id)
|
.getMetadata(doc.id)
|
||||||
.pipe(
|
.pipe(
|
||||||
@ -721,6 +730,7 @@ export class DocumentDetailComponent
|
|||||||
|
|
||||||
save(close: boolean = false) {
|
save(close: boolean = false) {
|
||||||
this.networkActive = true
|
this.networkActive = true
|
||||||
|
;(document.activeElement as HTMLElement)?.dispatchEvent(new Event('change'))
|
||||||
this.documentsService
|
this.documentsService
|
||||||
.update(this.document)
|
.update(this.document)
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
@ -1163,6 +1173,7 @@ export class DocumentDetailComponent
|
|||||||
splitDocument() {
|
splitDocument() {
|
||||||
let modal = this.modalService.open(SplitConfirmDialogComponent, {
|
let modal = this.modalService.open(SplitConfirmDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
|
size: 'lg',
|
||||||
})
|
})
|
||||||
modal.componentInstance.title = $localize`Split confirm`
|
modal.componentInstance.title = $localize`Split confirm`
|
||||||
modal.componentInstance.messageBold = $localize`This operation will split the selected document(s) into new documents.`
|
modal.componentInstance.messageBold = $localize`This operation will split the selected document(s) into new documents.`
|
||||||
@ -1201,6 +1212,7 @@ export class DocumentDetailComponent
|
|||||||
rotateDocument() {
|
rotateDocument() {
|
||||||
let modal = this.modalService.open(RotateConfirmDialogComponent, {
|
let modal = this.modalService.open(RotateConfirmDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
|
size: 'lg',
|
||||||
})
|
})
|
||||||
modal.componentInstance.title = $localize`Rotate confirm`
|
modal.componentInstance.title = $localize`Rotate confirm`
|
||||||
modal.componentInstance.messageBold = $localize`This operation will permanently rotate the original version of the current document.`
|
modal.componentInstance.messageBold = $localize`This operation will permanently rotate the original version of the current document.`
|
||||||
@ -1275,4 +1287,45 @@ export class DocumentDetailComponent
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private tryRenderTiff() {
|
||||||
|
this.http.get(this.previewUrl, { responseType: 'arraybuffer' }).subscribe({
|
||||||
|
next: (res) => {
|
||||||
|
/* istanbul ignore next */
|
||||||
|
try {
|
||||||
|
// See UTIF.js > _imgLoaded
|
||||||
|
const tiffIfds: any[] = UTIF.decode(res)
|
||||||
|
var vsns = tiffIfds,
|
||||||
|
ma = 0,
|
||||||
|
page = vsns[0]
|
||||||
|
if (tiffIfds[0].subIFD) vsns = vsns.concat(tiffIfds[0].subIFD)
|
||||||
|
for (var i = 0; i < vsns.length; i++) {
|
||||||
|
var img = vsns[i]
|
||||||
|
if (img['t258'] == null || img['t258'].length < 3) continue
|
||||||
|
var ar = img['t256'] * img['t257']
|
||||||
|
if (ar > ma) {
|
||||||
|
ma = ar
|
||||||
|
page = img
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UTIF.decodeImage(res, page, tiffIfds)
|
||||||
|
const rgba = UTIF.toRGBA8(page)
|
||||||
|
const { width: w, height: h } = page
|
||||||
|
var cnv = document.createElement('canvas')
|
||||||
|
cnv.width = w
|
||||||
|
cnv.height = h
|
||||||
|
var ctx = cnv.getContext('2d'),
|
||||||
|
imgd = ctx.createImageData(w, h)
|
||||||
|
for (var i = 0; i < rgba.length; i++) imgd.data[i] = rgba[i]
|
||||||
|
ctx.putImageData(imgd, 0, 0)
|
||||||
|
this.tiffURL = cnv.toDataURL()
|
||||||
|
} catch (err) {
|
||||||
|
this.tiffError = $localize`An error occurred loading tiff: ${err.toString()}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.tiffError = $localize`An error occurred loading tiff: ${err.toString()}`
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -782,11 +782,11 @@ export class BulkEditorComponent
|
|||||||
rotateSelected() {
|
rotateSelected() {
|
||||||
let modal = this.modalService.open(RotateConfirmDialogComponent, {
|
let modal = this.modalService.open(RotateConfirmDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
|
size: 'lg',
|
||||||
})
|
})
|
||||||
const rotateDialog = modal.componentInstance as RotateConfirmDialogComponent
|
const rotateDialog = modal.componentInstance as RotateConfirmDialogComponent
|
||||||
rotateDialog.title = $localize`Rotate confirm`
|
rotateDialog.title = $localize`Rotate confirm`
|
||||||
rotateDialog.messageBold = $localize`This operation will permanently rotate the original version of ${this.list.selected.size} document(s).`
|
rotateDialog.messageBold = $localize`This operation will permanently rotate the original version of ${this.list.selected.size} document(s).`
|
||||||
rotateDialog.message = $localize`This will alter the original copy.`
|
|
||||||
rotateDialog.btnClass = 'btn-danger'
|
rotateDialog.btnClass = 'btn-danger'
|
||||||
rotateDialog.btnCaption = $localize`Proceed`
|
rotateDialog.btnCaption = $localize`Proceed`
|
||||||
rotateDialog.documentID = Array.from(this.list.selected)[0]
|
rotateDialog.documentID = Array.from(this.list.selected)[0]
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
<div class="card mb-3 shadow-sm bg-light" [class.card-selected]="selected" [class.document-card]="selectable" [class.popover-hidden]="popoverHidden" (mouseleave)="mouseLeaveCard()">
|
<div class="card mb-3 shadow-sm bg-light" [class.card-selected]="selected" [class.document-card]="selectable" (mouseleave)="mouseLeaveCard()">
|
||||||
<div class="row g-0">
|
<div class="row g-0">
|
||||||
<div class="col-md-2 doc-img-container rounded-start" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit()">
|
<div class="col-md-2 doc-img-container rounded-start" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit()">
|
||||||
<img [src]="getThumbUrl()" class="card-img doc-img border-end rounded-start" [class.inverted]="getIsThumbInverted()">
|
<img [src]="getThumbUrl()" class="card-img doc-img border-end rounded-start" [class.inverted]="getIsThumbInverted()">
|
||||||
@ -54,16 +54,11 @@
|
|||||||
<i-bs name="diagram-3"></i-bs> <span class="d-none d-md-inline" i18n>More like this</span>
|
<i-bs name="diagram-3"></i-bs> <span class="d-none d-md-inline" i18n>More like this</span>
|
||||||
</a>
|
</a>
|
||||||
<a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
<a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
||||||
<i-bs name="box-arrow-in-right"></i-bs> <span class="d-none d-md-inline" i18n>Open</span>
|
<i-bs name="file-earmark-richtext"></i-bs> <span class="d-none d-md-inline" i18n>Open</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="btn btn-sm btn-outline-secondary" target="_blank" [href]="previewUrl"
|
<pngx-preview-popup [document]="document" #popupPreview>
|
||||||
[ngbPopover]="previewContent" [popoverTitle]="document.title | documentTitle"
|
|
||||||
autoClose="true" popoverClass="shadow popover-preview" (mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()" #popover="ngbPopover">
|
|
||||||
<i-bs name="eye"></i-bs> <span class="d-none d-md-inline" i18n>View</span>
|
<i-bs name="eye"></i-bs> <span class="d-none d-md-inline" i18n>View</span>
|
||||||
</a>
|
</pngx-preview-popup>
|
||||||
<ng-template #previewContent>
|
|
||||||
<pngx-preview-popup [document]="document"></pngx-preview-popup>
|
|
||||||
</ng-template>
|
|
||||||
<a class="btn btn-sm btn-outline-secondary" [href]="getDownloadUrl()">
|
<a class="btn btn-sm btn-outline-secondary" [href]="getDownloadUrl()">
|
||||||
<i-bs name="download"></i-bs> <span class="d-none d-md-inline" i18n>Download</span>
|
<i-bs name="download"></i-bs> <span class="d-none d-md-inline" i18n>Download</span>
|
||||||
</a>
|
</a>
|
||||||
|
@ -1,11 +1,6 @@
|
|||||||
import { DatePipe } from '@angular/common'
|
import { DatePipe } from '@angular/common'
|
||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
import {
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
ComponentFixture,
|
|
||||||
TestBed,
|
|
||||||
fakeAsync,
|
|
||||||
tick,
|
|
||||||
} from '@angular/core/testing'
|
|
||||||
import { By } from '@angular/platform-browser'
|
import { By } from '@angular/platform-browser'
|
||||||
import { RouterTestingModule } from '@angular/router/testing'
|
import { RouterTestingModule } from '@angular/router/testing'
|
||||||
import {
|
import {
|
||||||
@ -84,21 +79,6 @@ describe('DocumentCardLargeComponent', () => {
|
|||||||
expect(fixture.nativeElement.textContent).toContain('8 pages')
|
expect(fixture.nativeElement.textContent).toContain('8 pages')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should show preview on mouseover after delay to preload content', fakeAsync(() => {
|
|
||||||
component.mouseEnterPreview()
|
|
||||||
expect(component.popover.isOpen()).toBeTruthy()
|
|
||||||
expect(component.popoverHidden).toBeTruthy()
|
|
||||||
tick(600)
|
|
||||||
expect(component.popoverHidden).toBeFalsy()
|
|
||||||
component.mouseLeaveCard()
|
|
||||||
|
|
||||||
component.mouseEnterPreview()
|
|
||||||
tick(100)
|
|
||||||
component.mouseLeavePreview()
|
|
||||||
tick(600)
|
|
||||||
expect(component.popover.isOpen()).toBeFalsy()
|
|
||||||
}))
|
|
||||||
|
|
||||||
it('should trim content', () => {
|
it('should trim content', () => {
|
||||||
expect(component.contentTrimmed).toHaveLength(503) // includes ...
|
expect(component.contentTrimmed).toHaveLength(503) // includes ...
|
||||||
})
|
})
|
||||||
|
@ -12,9 +12,9 @@ import {
|
|||||||
} from 'src/app/data/document'
|
} from 'src/app/data/document'
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
|
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||||
|
import { PreviewPopupComponent } from '../../common/preview-popup/preview-popup.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-document-card-large',
|
selector: 'pngx-document-card-large',
|
||||||
@ -65,7 +65,7 @@ export class DocumentCardLargeComponent extends ComponentWithPermissions {
|
|||||||
@Output()
|
@Output()
|
||||||
clickMoreLike = new EventEmitter()
|
clickMoreLike = new EventEmitter()
|
||||||
|
|
||||||
@ViewChild('popover') popover: NgbPopover
|
@ViewChild('popupPreview') popupPreview: PreviewPopupComponent
|
||||||
|
|
||||||
mouseOnPreview = false
|
mouseOnPreview = false
|
||||||
popoverHidden = true
|
popoverHidden = true
|
||||||
@ -112,29 +112,8 @@ export class DocumentCardLargeComponent extends ComponentWithPermissions {
|
|||||||
return this.documentService.getPreviewUrl(this.document.id)
|
return this.documentService.getPreviewUrl(this.document.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
mouseEnterPreview() {
|
|
||||||
this.mouseOnPreview = true
|
|
||||||
if (!this.popover.isOpen()) {
|
|
||||||
// we're going to open but hide to pre-load content during hover delay
|
|
||||||
this.popover.open()
|
|
||||||
this.popoverHidden = true
|
|
||||||
setTimeout(() => {
|
|
||||||
if (this.mouseOnPreview) {
|
|
||||||
// show popover
|
|
||||||
this.popoverHidden = false
|
|
||||||
} else {
|
|
||||||
this.popover.close()
|
|
||||||
}
|
|
||||||
}, 600)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mouseLeavePreview() {
|
|
||||||
this.mouseOnPreview = false
|
|
||||||
}
|
|
||||||
|
|
||||||
mouseLeaveCard() {
|
mouseLeaveCard() {
|
||||||
this.popover.close()
|
this.popupPreview.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
get contentTrimmed() {
|
get contentTrimmed() {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<div class="col p-2 h-100">
|
<div class="col p-2 h-100">
|
||||||
<div class="card h-100 shadow-sm document-card" [class.card-selected]="selected" [class.popover-hidden]="popoverHidden" (mouseleave)="mouseLeaveCard()">
|
<div class="card h-100 shadow-sm document-card" [class.card-selected]="selected" (mouseleave)="mouseLeaveCard()">
|
||||||
<div class="border-bottom doc-img-container rounded-top" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit(this)">
|
<div class="border-bottom doc-img-container rounded-top" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit(this)">
|
||||||
<img class="card-img doc-img" [class.inverted]="getIsThumbInverted()" [src]="getThumbUrl()">
|
<img class="card-img doc-img" [class.inverted]="getIsThumbInverted()" [src]="getThumbUrl()">
|
||||||
|
|
||||||
@ -127,16 +127,11 @@
|
|||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<div class="btn-group w-100">
|
<div class="btn-group w-100">
|
||||||
<a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary" title="Open" i18n-title *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }" i18n-title>
|
<a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary" title="Open" i18n-title *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }" i18n-title>
|
||||||
<i-bs name="box-arrow-in-right"></i-bs>
|
<i-bs name="file-earmark-richtext"></i-bs>
|
||||||
</a>
|
</a>
|
||||||
<a [href]="previewUrl" target="_blank" class="btn btn-sm btn-outline-secondary"
|
<pngx-preview-popup [document]="document" #popupPreview>
|
||||||
[ngbPopover]="previewContent" [popoverTitle]="document.title | documentTitle"
|
|
||||||
autoClose="true" popoverClass="shadow popover-preview" (mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()" #popover="ngbPopover">
|
|
||||||
<i-bs name="eye"></i-bs>
|
<i-bs name="eye"></i-bs>
|
||||||
</a>
|
</pngx-preview-popup>
|
||||||
<ng-template #previewContent>
|
|
||||||
<pngx-preview-popup [document]="document"></pngx-preview-popup>
|
|
||||||
</ng-template>
|
|
||||||
<a [href]="getDownloadUrl()" class="btn btn-sm btn-outline-secondary" title="Download" i18n-title (click)="$event.stopPropagation()">
|
<a [href]="getDownloadUrl()" class="btn btn-sm btn-outline-secondary" title="Download" i18n-title (click)="$event.stopPropagation()">
|
||||||
<i-bs name="download"></i-bs>
|
<i-bs name="download"></i-bs>
|
||||||
</a>
|
</a>
|
||||||
|
@ -1,11 +1,6 @@
|
|||||||
import { DatePipe } from '@angular/common'
|
import { DatePipe } from '@angular/common'
|
||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
import {
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
ComponentFixture,
|
|
||||||
TestBed,
|
|
||||||
fakeAsync,
|
|
||||||
tick,
|
|
||||||
} from '@angular/core/testing'
|
|
||||||
import { RouterTestingModule } from '@angular/router/testing'
|
import { RouterTestingModule } from '@angular/router/testing'
|
||||||
import {
|
import {
|
||||||
NgbPopoverModule,
|
NgbPopoverModule,
|
||||||
@ -116,19 +111,4 @@ describe('DocumentCardSmallComponent', () => {
|
|||||||
fixture.debugElement.queryAll(By.directive(TagComponent))
|
fixture.debugElement.queryAll(By.directive(TagComponent))
|
||||||
).toHaveLength(6)
|
).toHaveLength(6)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should show preview on mouseover after delay to preload content', fakeAsync(() => {
|
|
||||||
component.mouseEnterPreview()
|
|
||||||
expect(component.popover.isOpen()).toBeTruthy()
|
|
||||||
expect(component.popoverHidden).toBeTruthy()
|
|
||||||
tick(600)
|
|
||||||
expect(component.popoverHidden).toBeFalsy()
|
|
||||||
component.mouseLeaveCard()
|
|
||||||
|
|
||||||
component.mouseEnterPreview()
|
|
||||||
tick(100)
|
|
||||||
component.mouseLeavePreview()
|
|
||||||
tick(600)
|
|
||||||
expect(component.popover.isOpen()).toBeFalsy()
|
|
||||||
}))
|
|
||||||
})
|
})
|
||||||
|
@ -13,9 +13,9 @@ import {
|
|||||||
} from 'src/app/data/document'
|
} from 'src/app/data/document'
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
|
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||||
|
import { PreviewPopupComponent } from '../../common/preview-popup/preview-popup.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-document-card-small',
|
selector: 'pngx-document-card-small',
|
||||||
@ -61,10 +61,7 @@ export class DocumentCardSmallComponent extends ComponentWithPermissions {
|
|||||||
|
|
||||||
moreTags: number = null
|
moreTags: number = null
|
||||||
|
|
||||||
@ViewChild('popover') popover: NgbPopover
|
@ViewChild('popupPreview') popupPreview: PreviewPopupComponent
|
||||||
|
|
||||||
mouseOnPreview = false
|
|
||||||
popoverHidden = true
|
|
||||||
|
|
||||||
getIsThumbInverted() {
|
getIsThumbInverted() {
|
||||||
return this.settingsService.get(SETTINGS_KEYS.DARK_MODE_THUMB_INVERTED)
|
return this.settingsService.get(SETTINGS_KEYS.DARK_MODE_THUMB_INVERTED)
|
||||||
@ -78,10 +75,6 @@ export class DocumentCardSmallComponent extends ComponentWithPermissions {
|
|||||||
return this.documentService.getDownloadUrl(this.document.id)
|
return this.documentService.getDownloadUrl(this.document.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
get previewUrl() {
|
|
||||||
return this.documentService.getPreviewUrl(this.document.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
get privateName() {
|
get privateName() {
|
||||||
return $localize`Private`
|
return $localize`Private`
|
||||||
}
|
}
|
||||||
@ -100,29 +93,8 @@ export class DocumentCardSmallComponent extends ComponentWithPermissions {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
mouseEnterPreview() {
|
|
||||||
this.mouseOnPreview = true
|
|
||||||
if (!this.popover.isOpen()) {
|
|
||||||
// we're going to open but hide to pre-load content during hover delay
|
|
||||||
this.popover.open()
|
|
||||||
this.popoverHidden = true
|
|
||||||
setTimeout(() => {
|
|
||||||
if (this.mouseOnPreview) {
|
|
||||||
// show popover
|
|
||||||
this.popoverHidden = false
|
|
||||||
} else {
|
|
||||||
this.popover.close()
|
|
||||||
}
|
|
||||||
}, 600)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mouseLeavePreview() {
|
|
||||||
this.mouseOnPreview = false
|
|
||||||
}
|
|
||||||
|
|
||||||
mouseLeaveCard() {
|
mouseLeaveCard() {
|
||||||
this.popover.close()
|
this.popupPreview.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
get notesEnabled(): boolean {
|
get notesEnabled(): boolean {
|
||||||
|
@ -292,7 +292,12 @@
|
|||||||
@if (activeDisplayFields.includes(DisplayField.TITLE) || activeDisplayFields.includes(DisplayField.TAGS)) {
|
@if (activeDisplayFields.includes(DisplayField.TITLE) || activeDisplayFields.includes(DisplayField.TAGS)) {
|
||||||
<td width="30%">
|
<td width="30%">
|
||||||
@if (activeDisplayFields.includes(DisplayField.TITLE)) {
|
@if (activeDisplayFields.includes(DisplayField.TITLE)) {
|
||||||
<a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
|
<div class="d-inline-block" (mouseleave)="popupPreview.close()">
|
||||||
|
<a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
|
||||||
|
<pngx-preview-popup [document]="d" linkClasses="btn btn-sm btn-link text-secondary" linkTitle="Preview document" (click)="$event.stopPropagation()" i18n-linkTitle #popupPreview>
|
||||||
|
<i-bs name="eye"></i-bs>
|
||||||
|
</pngx-preview-popup>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
@if (activeDisplayFields.includes(DisplayField.TAGS)) {
|
@if (activeDisplayFields.includes(DisplayField.TAGS)) {
|
||||||
@for (t of d.tags$ | async; track t) {
|
@for (t of d.tags$ | async; track t) {
|
||||||
|
@ -72,6 +72,7 @@ import { IsNumberPipe } from 'src/app/pipes/is-number.pipe'
|
|||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
|
import { PreviewPopupComponent } from '../common/preview-popup/preview-popup.component'
|
||||||
|
|
||||||
const docs: Document[] = [
|
const docs: Document[] = [
|
||||||
{
|
{
|
||||||
@ -137,6 +138,7 @@ describe('DocumentListComponent', () => {
|
|||||||
UsernamePipe,
|
UsernamePipe,
|
||||||
SafeHtmlPipe,
|
SafeHtmlPipe,
|
||||||
IsNumberPipe,
|
IsNumberPipe,
|
||||||
|
PreviewPopupComponent,
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
RouterTestingModule.withRoutes(routes),
|
RouterTestingModule.withRoutes(routes),
|
||||||
@ -698,5 +700,31 @@ describe('DocumentListComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'o' }))
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'o' }))
|
||||||
expect(detailSpy).toHaveBeenCalledWith(docs[1].id)
|
expect(detailSpy).toHaveBeenCalledWith(docs[1].id)
|
||||||
|
|
||||||
|
const lotsOfDocs: Document[] = Array.from({ length: 100 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
title: `Doc${i + 1}`,
|
||||||
|
notes: [],
|
||||||
|
tags$: new Subject(),
|
||||||
|
content: `document content ${i + 1}`,
|
||||||
|
}))
|
||||||
|
jest
|
||||||
|
.spyOn(documentListService, 'documents', 'get')
|
||||||
|
.mockReturnValue(lotsOfDocs)
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'listAllFilteredIds')
|
||||||
|
.mockReturnValue(of(lotsOfDocs.map((d) => d.id)))
|
||||||
|
jest.spyOn(documentListService, 'getLastPage').mockReturnValue(4)
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.list.currentPage).toEqual(1)
|
||||||
|
document.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'ArrowRight', ctrlKey: true })
|
||||||
|
)
|
||||||
|
expect(component.list.currentPage).toEqual(2)
|
||||||
|
document.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'ArrowLeft', ctrlKey: true })
|
||||||
|
)
|
||||||
|
expect(component.list.currentPage).toEqual(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -273,6 +273,30 @@ export class DocumentListComponent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
this.hotKeyService
|
||||||
|
.addShortcut({
|
||||||
|
keys: 'control.arrowleft',
|
||||||
|
description: $localize`Previous page`,
|
||||||
|
})
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe(() => {
|
||||||
|
if (this.list.currentPage > 1) {
|
||||||
|
this.list.currentPage--
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.hotKeyService
|
||||||
|
.addShortcut({
|
||||||
|
keys: 'control.arrowright',
|
||||||
|
description: $localize`Next page`,
|
||||||
|
})
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe(() => {
|
||||||
|
if (this.list.currentPage < this.list.getLastPage()) {
|
||||||
|
this.list.currentPage++
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
|
@ -77,14 +77,19 @@ describe('CorrespondentListComponent', () => {
|
|||||||
it('should support very old date strings', () => {
|
it('should support very old date strings', () => {
|
||||||
jest.spyOn(correspondentsService, 'listFiltered').mockReturnValue(
|
jest.spyOn(correspondentsService, 'listFiltered').mockReturnValue(
|
||||||
of({
|
of({
|
||||||
count: 1,
|
count: 2,
|
||||||
all: [1],
|
all: [1, 2],
|
||||||
results: [
|
results: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'Correspondent1',
|
name: 'Correspondent1',
|
||||||
last_correspondence: '1832-12-31T15:32:54-07:52:58',
|
last_correspondence: '1832-12-31T15:32:54-07:52:58',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Correspondent2',
|
||||||
|
last_correspondence: '1901-07-01T00:00:00+00:09:21',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
@ -52,7 +52,7 @@ export class CorrespondentListComponent extends ManagementListComponent<Correspo
|
|||||||
date = new Date(
|
date = new Date(
|
||||||
c.last_correspondence
|
c.last_correspondence
|
||||||
?.toString()
|
?.toString()
|
||||||
.replace(/-(\d\d):\d\d:\d\d/gm, `-$1:00`)
|
.replace(/([-+])(\d\d):\d\d:\d\d/gm, `$1$2:00`)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return this.datePipe.transform(date)
|
return this.datePipe.transform(date)
|
||||||
|
@ -30,4 +30,6 @@ export interface PaperlessTask extends ObjectWithId {
|
|||||||
result?: string
|
result?: string
|
||||||
|
|
||||||
related_document?: number
|
related_document?: number
|
||||||
|
|
||||||
|
owner?: number
|
||||||
}
|
}
|
||||||
|
@ -17,6 +17,8 @@ export enum GlobalSearchType {
|
|||||||
TITLE_CONTENT = 'title-content',
|
TITLE_CONTENT = 'title-content',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const PAPERLESS_GREEN_HEX = '#17541f'
|
||||||
|
|
||||||
export const SETTINGS_KEYS = {
|
export const SETTINGS_KEYS = {
|
||||||
LANGUAGE: 'language',
|
LANGUAGE: 'language',
|
||||||
APP_LOGO: 'app_logo',
|
APP_LOGO: 'app_logo',
|
||||||
|
@ -17,4 +17,11 @@ export interface PaperlessUserProfile {
|
|||||||
auth_token?: string
|
auth_token?: string
|
||||||
social_accounts?: SocialAccount[]
|
social_accounts?: SocialAccount[]
|
||||||
has_usable_password?: boolean
|
has_usable_password?: boolean
|
||||||
|
is_mfa_enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TotpSettings {
|
||||||
|
url: string
|
||||||
|
qr_svg: string
|
||||||
|
secret: string
|
||||||
}
|
}
|
||||||
|
@ -11,4 +11,5 @@ export interface User extends ObjectWithId {
|
|||||||
groups?: number[] // Group[]
|
groups?: number[] // Group[]
|
||||||
user_permissions?: string[]
|
user_permissions?: string[]
|
||||||
inherited_permissions?: string[]
|
inherited_permissions?: string[]
|
||||||
|
is_mfa_enabled?: boolean
|
||||||
}
|
}
|
||||||
|
@ -10,6 +10,14 @@ export enum WorkflowTriggerType {
|
|||||||
Consumption = 1,
|
Consumption = 1,
|
||||||
DocumentAdded = 2,
|
DocumentAdded = 2,
|
||||||
DocumentUpdated = 3,
|
DocumentUpdated = 3,
|
||||||
|
Scheduled = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ScheduleDateField {
|
||||||
|
Added = 'added',
|
||||||
|
Created = 'created',
|
||||||
|
Modified = 'modified',
|
||||||
|
CustomField = 'custom_field',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkflowTrigger extends ObjectWithId {
|
export interface WorkflowTrigger extends ObjectWithId {
|
||||||
@ -34,4 +42,14 @@ export interface WorkflowTrigger extends ObjectWithId {
|
|||||||
filter_has_correspondent?: number // Correspondent.id
|
filter_has_correspondent?: number // Correspondent.id
|
||||||
|
|
||||||
filter_has_document_type?: number // DocumentType.id
|
filter_has_document_type?: number // DocumentType.id
|
||||||
|
|
||||||
|
schedule_offset_days?: number
|
||||||
|
|
||||||
|
schedule_is_recurring?: boolean
|
||||||
|
|
||||||
|
schedule_recurring_interval_days?: number
|
||||||
|
|
||||||
|
schedule_date_field?: ScheduleDateField
|
||||||
|
|
||||||
|
schedule_date_custom_field?: number // CustomField.id
|
||||||
}
|
}
|
||||||
|
@ -439,4 +439,25 @@ describe('PermissionsService', () => {
|
|||||||
|
|
||||||
expect(permissionsService.isAdmin()).toBeFalsy()
|
expect(permissionsService.isAdmin()).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('correctly checks superuser status', () => {
|
||||||
|
permissionsService.initialize([], {
|
||||||
|
username: 'testuser',
|
||||||
|
last_name: 'User',
|
||||||
|
first_name: 'Test',
|
||||||
|
id: 1,
|
||||||
|
is_superuser: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(permissionsService.isSuperUser()).toBeTruthy()
|
||||||
|
|
||||||
|
permissionsService.initialize([], {
|
||||||
|
username: 'testuser',
|
||||||
|
last_name: 'User',
|
||||||
|
first_name: 'Test',
|
||||||
|
id: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(permissionsService.isSuperUser()).toBeFalsy()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@ -56,6 +56,10 @@ export class PermissionsService {
|
|||||||
return this.currentUser?.is_staff
|
return this.currentUser?.is_staff
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public isSuperUser(): boolean {
|
||||||
|
return this.currentUser?.is_superuser
|
||||||
|
}
|
||||||
|
|
||||||
public currentUserOwnsObject(object: ObjectWithPermissions): boolean {
|
public currentUserOwnsObject(object: ObjectWithPermissions): boolean {
|
||||||
return (
|
return (
|
||||||
!object ||
|
!object ||
|
||||||
|
@ -72,4 +72,32 @@ describe('ProfileService', () => {
|
|||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('calls get totp settings endpoint', () => {
|
||||||
|
service.getTotpSettings().subscribe()
|
||||||
|
const req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}profile/totp/`
|
||||||
|
)
|
||||||
|
expect(req.request.method).toEqual('GET')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls activate totp endpoint', () => {
|
||||||
|
service.activateTotp('secret', 'code').subscribe()
|
||||||
|
const req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}profile/totp/`
|
||||||
|
)
|
||||||
|
expect(req.request.method).toEqual('POST')
|
||||||
|
expect(req.request.body).toEqual({
|
||||||
|
secret: 'secret',
|
||||||
|
code: 'code',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls deactivate totp endpoint', () => {
|
||||||
|
service.deactivateTotp().subscribe()
|
||||||
|
const req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}profile/totp/`
|
||||||
|
)
|
||||||
|
expect(req.request.method).toEqual('DELETE')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@ -2,6 +2,7 @@ import { HttpClient } from '@angular/common/http'
|
|||||||
import { Injectable } from '@angular/core'
|
import { Injectable } from '@angular/core'
|
||||||
import { Observable } from 'rxjs'
|
import { Observable } from 'rxjs'
|
||||||
import {
|
import {
|
||||||
|
TotpSettings,
|
||||||
PaperlessUserProfile,
|
PaperlessUserProfile,
|
||||||
SocialAccountProvider,
|
SocialAccountProvider,
|
||||||
} from '../data/user-profile'
|
} from '../data/user-profile'
|
||||||
@ -47,4 +48,30 @@ export class ProfileService {
|
|||||||
`${environment.apiBaseUrl}${this.endpoint}/social_account_providers/`
|
`${environment.apiBaseUrl}${this.endpoint}/social_account_providers/`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getTotpSettings(): Observable<TotpSettings> {
|
||||||
|
return this.http.get<TotpSettings>(
|
||||||
|
`${environment.apiBaseUrl}${this.endpoint}/totp/`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
activateTotp(
|
||||||
|
totpSecret: string,
|
||||||
|
totpCode: string
|
||||||
|
): Observable<{ success: boolean; recovery_codes: string[] }> {
|
||||||
|
return this.http.post<{ success: boolean; recovery_codes: string[] }>(
|
||||||
|
`${environment.apiBaseUrl}${this.endpoint}/totp/`,
|
||||||
|
{
|
||||||
|
secret: totpSecret,
|
||||||
|
code: totpCode,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivateTotp(): Observable<boolean> {
|
||||||
|
return this.http.delete<boolean>(
|
||||||
|
`${environment.apiBaseUrl}${this.endpoint}/totp/`,
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -160,6 +160,18 @@ const user = {
|
|||||||
commonAbstractNameFilterPaperlessServiceTests(endpoint, UserService)
|
commonAbstractNameFilterPaperlessServiceTests(endpoint, UserService)
|
||||||
|
|
||||||
describe('Additional service tests for UserService', () => {
|
describe('Additional service tests for UserService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Dont need to setup again
|
||||||
|
|
||||||
|
httpTestingController = TestBed.inject(HttpTestingController)
|
||||||
|
service = TestBed.inject(UserService)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
subscription?.unsubscribe()
|
||||||
|
httpTestingController.verify()
|
||||||
|
})
|
||||||
|
|
||||||
it('should retain permissions on update', () => {
|
it('should retain permissions on update', () => {
|
||||||
subscription = service.listAll().subscribe()
|
subscription = service.listAll().subscribe()
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
@ -179,15 +191,11 @@ describe('Additional service tests for UserService', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
beforeEach(() => {
|
it('should deactivate totp', () => {
|
||||||
// Dont need to setup again
|
subscription = service.deactivateTotp(user).subscribe()
|
||||||
|
const req = httpTestingController.expectOne(
|
||||||
httpTestingController = TestBed.inject(HttpTestingController)
|
`${environment.apiBaseUrl}${endpoint}/${user.id}/deactivate_totp/`
|
||||||
service = TestBed.inject(UserService)
|
)
|
||||||
})
|
expect(req.request.method).toEqual('POST')
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
subscription?.unsubscribe()
|
|
||||||
httpTestingController.verify()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -5,6 +5,7 @@ import { User } from 'src/app/data/user'
|
|||||||
import { PermissionsService } from '../permissions.service'
|
import { PermissionsService } from '../permissions.service'
|
||||||
import { AbstractNameFilterService } from './abstract-name-filter-service'
|
import { AbstractNameFilterService } from './abstract-name-filter-service'
|
||||||
|
|
||||||
|
const endpoint = 'users'
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
@ -13,7 +14,7 @@ export class UserService extends AbstractNameFilterService<User> {
|
|||||||
http: HttpClient,
|
http: HttpClient,
|
||||||
private permissionService: PermissionsService
|
private permissionService: PermissionsService
|
||||||
) {
|
) {
|
||||||
super(http, 'users')
|
super(http, endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
update(o: User): Observable<User> {
|
update(o: User): Observable<User> {
|
||||||
@ -31,4 +32,11 @@ export class UserService extends AbstractNameFilterService<User> {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deactivateTotp(u: User): Observable<boolean> {
|
||||||
|
return this.http.post<boolean>(
|
||||||
|
`${this.getResourceUrl(u.id, 'deactivate_totp')}`,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -17,7 +17,12 @@ import {
|
|||||||
hexToHsl,
|
hexToHsl,
|
||||||
} from 'src/app/utils/color'
|
} from 'src/app/utils/color'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { UiSettings, SETTINGS, SETTINGS_KEYS } from '../data/ui-settings'
|
import {
|
||||||
|
UiSettings,
|
||||||
|
SETTINGS,
|
||||||
|
SETTINGS_KEYS,
|
||||||
|
PAPERLESS_GREEN_HEX,
|
||||||
|
} from '../data/ui-settings'
|
||||||
import { User } from '../data/user'
|
import { User } from '../data/user'
|
||||||
import {
|
import {
|
||||||
PermissionAction,
|
PermissionAction,
|
||||||
@ -420,7 +425,7 @@ export class SettingsService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (themeColor) {
|
if (themeColor?.length) {
|
||||||
const hsl = hexToHsl(themeColor)
|
const hsl = hexToHsl(themeColor)
|
||||||
const bgBrightnessEstimate = estimateBrightnessForColor(themeColor)
|
const bgBrightnessEstimate = estimateBrightnessForColor(themeColor)
|
||||||
|
|
||||||
@ -445,6 +450,11 @@ export class SettingsService {
|
|||||||
document.documentElement.style.removeProperty('--pngx-primary')
|
document.documentElement.style.removeProperty('--pngx-primary')
|
||||||
document.documentElement.style.removeProperty('--pngx-primary-lightness')
|
document.documentElement.style.removeProperty('--pngx-primary-lightness')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.meta.updateTag({
|
||||||
|
name: 'theme-color',
|
||||||
|
content: themeColor?.length ? themeColor : PAPERLESS_GREEN_HEX,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
getLanguageOptions(): LanguageOption[] {
|
getLanguageOptions(): LanguageOption[] {
|
||||||
|
@ -48,7 +48,7 @@ describe('TasksService', () => {
|
|||||||
it('calls acknowledge_tasks api endpoint on dismiss and reloads', () => {
|
it('calls acknowledge_tasks api endpoint on dismiss and reloads', () => {
|
||||||
tasksService.dismissTasks(new Set([1, 2, 3]))
|
tasksService.dismissTasks(new Set([1, 2, 3]))
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}acknowledge_tasks/`
|
`${environment.apiBaseUrl}tasks/acknowledge/`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('POST')
|
expect(req.request.method).toEqual('POST')
|
||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
|
@ -64,7 +64,7 @@ export class TasksService {
|
|||||||
|
|
||||||
public dismissTasks(task_ids: Set<number>) {
|
public dismissTasks(task_ids: Set<number>) {
|
||||||
this.http
|
this.http
|
||||||
.post(`${this.baseUrl}acknowledge_tasks/`, {
|
.post(`${this.baseUrl}tasks/acknowledge/`, {
|
||||||
tasks: [...task_ids],
|
tasks: [...task_ids],
|
||||||
})
|
})
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
|
@ -3,9 +3,9 @@ const base_url = new URL(document.baseURI)
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: true,
|
production: true,
|
||||||
apiBaseUrl: document.baseURI + 'api/',
|
apiBaseUrl: document.baseURI + 'api/',
|
||||||
apiVersion: '5',
|
apiVersion: '6',
|
||||||
appTitle: 'Paperless-ngx',
|
appTitle: 'Paperless-ngx',
|
||||||
version: '2.13.4',
|
version: '2.13.5',
|
||||||
webSocketHost: window.location.host,
|
webSocketHost: window.location.host,
|
||||||
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
||||||
webSocketBaseUrl: base_url.pathname + 'ws/',
|
webSocketBaseUrl: base_url.pathname + 'ws/',
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: false,
|
production: false,
|
||||||
apiBaseUrl: 'http://localhost:8000/api/',
|
apiBaseUrl: 'http://localhost:8000/api/',
|
||||||
apiVersion: '5',
|
apiVersion: '6',
|
||||||
appTitle: 'Paperless-ngx',
|
appTitle: 'Paperless-ngx',
|
||||||
version: 'DEVELOPMENT',
|
version: 'DEVELOPMENT',
|
||||||
webSocketHost: 'localhost:8000',
|
webSocketHost: 'localhost:8000',
|
||||||
|
@ -564,11 +564,6 @@ table.table {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.popover-hidden .popover {
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tour
|
// Tour
|
||||||
.tour-active .popover {
|
.tour-active .popover {
|
||||||
min-width: 360px;
|
min-width: 360px;
|
||||||
@ -728,3 +723,27 @@ i-bs svg {
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fixes for buttons in preview popup
|
||||||
|
.btn-group pngx-preview-popup:not(:last-child) {
|
||||||
|
// Prevent double borders when buttons are next to each other
|
||||||
|
> .btn {
|
||||||
|
margin-left: calc(#{$btn-border-width} * -1);
|
||||||
|
}
|
||||||
|
> .btn {
|
||||||
|
@include border-end-radius(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.btn-group pngx-preview-popup:not(:first-child) {
|
||||||
|
> .btn {
|
||||||
|
@include border-start-radius(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.btn-group pngx-preview-popup {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
|
||||||
|
> .btn {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -24,7 +24,7 @@ from documents.models import StoragePath
|
|||||||
from documents.permissions import set_permissions_for_object
|
from documents.permissions import set_permissions_for_object
|
||||||
from documents.tasks import bulk_update_documents
|
from documents.tasks import bulk_update_documents
|
||||||
from documents.tasks import consume_file
|
from documents.tasks import consume_file
|
||||||
from documents.tasks import update_document_archive_file
|
from documents.tasks import update_document_content_maybe_archive_file
|
||||||
|
|
||||||
logger: logging.Logger = logging.getLogger("paperless.bulk_edit")
|
logger: logging.Logger = logging.getLogger("paperless.bulk_edit")
|
||||||
|
|
||||||
@ -191,7 +191,7 @@ def delete(doc_ids: list[int]) -> Literal["OK"]:
|
|||||||
|
|
||||||
def reprocess(doc_ids: list[int]) -> Literal["OK"]:
|
def reprocess(doc_ids: list[int]) -> Literal["OK"]:
|
||||||
for document_id in doc_ids:
|
for document_id in doc_ids:
|
||||||
update_document_archive_file.delay(
|
update_document_content_maybe_archive_file.delay(
|
||||||
document_id=document_id,
|
document_id=document_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -245,7 +245,7 @@ def rotate(doc_ids: list[int], degrees: int) -> Literal["OK"]:
|
|||||||
doc.checksum = hashlib.md5(doc.source_path.read_bytes()).hexdigest()
|
doc.checksum = hashlib.md5(doc.source_path.read_bytes()).hexdigest()
|
||||||
doc.save()
|
doc.save()
|
||||||
rotate_tasks.append(
|
rotate_tasks.append(
|
||||||
update_document_archive_file.s(
|
update_document_content_maybe_archive_file.s(
|
||||||
document_id=doc.id,
|
document_id=doc.id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -423,7 +423,7 @@ def delete_pages(doc_ids: list[int], pages: list[int]) -> Literal["OK"]:
|
|||||||
if doc.page_count is not None:
|
if doc.page_count is not None:
|
||||||
doc.page_count = doc.page_count - len(pages)
|
doc.page_count = doc.page_count - len(pages)
|
||||||
doc.save()
|
doc.save()
|
||||||
update_document_archive_file.delay(document_id=doc.id)
|
update_document_content_maybe_archive_file.delay(document_id=doc.id)
|
||||||
logger.info(f"Deleted pages {pages} from document {doc.id}")
|
logger.info(f"Deleted pages {pages} from document {doc.id}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Error deleting pages from document {doc.id}: {e}")
|
logger.exception(f"Error deleting pages from document {doc.id}: {e}")
|
||||||
|
@ -14,7 +14,7 @@ def settings(request):
|
|||||||
app_logo = (
|
app_logo = (
|
||||||
django_settings.APP_LOGO
|
django_settings.APP_LOGO
|
||||||
if general_config.app_logo is None or len(general_config.app_logo) == 0
|
if general_config.app_logo is None or len(general_config.app_logo) == 0
|
||||||
else general_config.app_logo
|
else django_settings.BASE_URL + general_config.app_logo.lstrip("/")
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -9,7 +9,7 @@ from django.core.management.base import BaseCommand
|
|||||||
from documents.management.commands.mixins import MultiProcessMixin
|
from documents.management.commands.mixins import MultiProcessMixin
|
||||||
from documents.management.commands.mixins import ProgressBarMixin
|
from documents.management.commands.mixins import ProgressBarMixin
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.tasks import update_document_archive_file
|
from documents.tasks import update_document_content_maybe_archive_file
|
||||||
|
|
||||||
logger = logging.getLogger("paperless.management.archiver")
|
logger = logging.getLogger("paperless.management.archiver")
|
||||||
|
|
||||||
@ -77,13 +77,13 @@ class Command(MultiProcessMixin, ProgressBarMixin, BaseCommand):
|
|||||||
|
|
||||||
if self.process_count == 1:
|
if self.process_count == 1:
|
||||||
for doc_id in document_ids:
|
for doc_id in document_ids:
|
||||||
update_document_archive_file(doc_id)
|
update_document_content_maybe_archive_file(doc_id)
|
||||||
else: # pragma: no cover
|
else: # pragma: no cover
|
||||||
with multiprocessing.Pool(self.process_count) as pool:
|
with multiprocessing.Pool(self.process_count) as pool:
|
||||||
list(
|
list(
|
||||||
tqdm.tqdm(
|
tqdm.tqdm(
|
||||||
pool.imap_unordered(
|
pool.imap_unordered(
|
||||||
update_document_archive_file,
|
update_document_content_maybe_archive_file,
|
||||||
document_ids,
|
document_ids,
|
||||||
),
|
),
|
||||||
total=len(document_ids),
|
total=len(document_ids),
|
||||||
|
@ -317,10 +317,8 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
# Check the files against the timeout
|
# Check the files against the timeout
|
||||||
still_waiting = {}
|
still_waiting = {}
|
||||||
for filepath in notified_files:
|
# last_event_time is time of the last inotify event for this file
|
||||||
# Time of the last inotify event for this file
|
for filepath, last_event_time in notified_files.items():
|
||||||
last_event_time = notified_files[filepath]
|
|
||||||
|
|
||||||
# Current time - last time over the configured timeout
|
# Current time - last time over the configured timeout
|
||||||
waited_long_enough = (
|
waited_long_enough = (
|
||||||
monotonic() - last_event_time
|
monotonic() - last_event_time
|
||||||
|
@ -8,6 +8,7 @@ from pathlib import Path
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import tqdm
|
import tqdm
|
||||||
|
from allauth.mfa.models import Authenticator
|
||||||
from allauth.socialaccount.models import SocialAccount
|
from allauth.socialaccount.models import SocialAccount
|
||||||
from allauth.socialaccount.models import SocialApp
|
from allauth.socialaccount.models import SocialApp
|
||||||
from allauth.socialaccount.models import SocialToken
|
from allauth.socialaccount.models import SocialToken
|
||||||
@ -81,6 +82,18 @@ class Command(CryptMixin, BaseCommand):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"-cj",
|
||||||
|
"--compare-json",
|
||||||
|
default=False,
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"Compare json file checksums when determining whether to "
|
||||||
|
"export a json file or not (manifest or metadata). "
|
||||||
|
"If not specified, the file is always exported."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-d",
|
"-d",
|
||||||
"--delete",
|
"--delete",
|
||||||
@ -177,6 +190,7 @@ class Command(CryptMixin, BaseCommand):
|
|||||||
self.target = Path(options["target"]).resolve()
|
self.target = Path(options["target"]).resolve()
|
||||||
self.split_manifest: bool = options["split_manifest"]
|
self.split_manifest: bool = options["split_manifest"]
|
||||||
self.compare_checksums: bool = options["compare_checksums"]
|
self.compare_checksums: bool = options["compare_checksums"]
|
||||||
|
self.compare_json: bool = options["compare_json"]
|
||||||
self.use_filename_format: bool = options["use_filename_format"]
|
self.use_filename_format: bool = options["use_filename_format"]
|
||||||
self.use_folder_prefix: bool = options["use_folder_prefix"]
|
self.use_folder_prefix: bool = options["use_folder_prefix"]
|
||||||
self.delete: bool = options["delete"]
|
self.delete: bool = options["delete"]
|
||||||
@ -270,6 +284,7 @@ class Command(CryptMixin, BaseCommand):
|
|||||||
"social_accounts": SocialAccount.objects.all(),
|
"social_accounts": SocialAccount.objects.all(),
|
||||||
"social_apps": SocialApp.objects.all(),
|
"social_apps": SocialApp.objects.all(),
|
||||||
"social_tokens": SocialToken.objects.all(),
|
"social_tokens": SocialToken.objects.all(),
|
||||||
|
"authenticators": Authenticator.objects.all(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if settings.AUDIT_LOG_ENABLED:
|
if settings.AUDIT_LOG_ENABLED:
|
||||||
@ -279,9 +294,9 @@ class Command(CryptMixin, BaseCommand):
|
|||||||
manifest_dict = {}
|
manifest_dict = {}
|
||||||
|
|
||||||
# Build an overall manifest
|
# Build an overall manifest
|
||||||
for key in manifest_key_to_object_query:
|
for key, object_query in manifest_key_to_object_query.items():
|
||||||
manifest_dict[key] = json.loads(
|
manifest_dict[key] = json.loads(
|
||||||
serializers.serialize("json", manifest_key_to_object_query[key]),
|
serializers.serialize("json", object_query),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.encrypt_secret_fields(manifest_dict)
|
self.encrypt_secret_fields(manifest_dict)
|
||||||
@ -341,12 +356,11 @@ class Command(CryptMixin, BaseCommand):
|
|||||||
manifest_dict["custom_field_instances"],
|
manifest_dict["custom_field_instances"],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
manifest_name.write_text(
|
|
||||||
json.dumps(content, indent=2, ensure_ascii=False),
|
self.check_and_write_json(
|
||||||
encoding="utf-8",
|
content,
|
||||||
|
manifest_name,
|
||||||
)
|
)
|
||||||
if manifest_name in self.files_in_export_dir:
|
|
||||||
self.files_in_export_dir.remove(manifest_name)
|
|
||||||
|
|
||||||
# These were exported already
|
# These were exported already
|
||||||
if self.split_manifest:
|
if self.split_manifest:
|
||||||
@ -356,15 +370,13 @@ class Command(CryptMixin, BaseCommand):
|
|||||||
|
|
||||||
# 4.1 write primary manifest to target folder
|
# 4.1 write primary manifest to target folder
|
||||||
manifest = []
|
manifest = []
|
||||||
for key in manifest_dict:
|
for key, item in manifest_dict.items():
|
||||||
manifest.extend(manifest_dict[key])
|
manifest.extend(item)
|
||||||
manifest_path = (self.target / "manifest.json").resolve()
|
manifest_path = (self.target / "manifest.json").resolve()
|
||||||
manifest_path.write_text(
|
self.check_and_write_json(
|
||||||
json.dumps(manifest, indent=2, ensure_ascii=False),
|
manifest,
|
||||||
encoding="utf-8",
|
manifest_path,
|
||||||
)
|
)
|
||||||
if manifest_path in self.files_in_export_dir:
|
|
||||||
self.files_in_export_dir.remove(manifest_path)
|
|
||||||
|
|
||||||
# 4.2 write version information to target folder
|
# 4.2 write version information to target folder
|
||||||
extra_metadata_path = (self.target / "metadata.json").resolve()
|
extra_metadata_path = (self.target / "metadata.json").resolve()
|
||||||
@ -376,16 +388,11 @@ class Command(CryptMixin, BaseCommand):
|
|||||||
# Django stores most of these in the field itself, we store them once here
|
# Django stores most of these in the field itself, we store them once here
|
||||||
if self.passphrase:
|
if self.passphrase:
|
||||||
metadata.update(self.get_crypt_params())
|
metadata.update(self.get_crypt_params())
|
||||||
extra_metadata_path.write_text(
|
|
||||||
json.dumps(
|
self.check_and_write_json(
|
||||||
metadata,
|
metadata,
|
||||||
indent=2,
|
extra_metadata_path,
|
||||||
ensure_ascii=False,
|
|
||||||
),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
)
|
||||||
if extra_metadata_path in self.files_in_export_dir:
|
|
||||||
self.files_in_export_dir.remove(extra_metadata_path)
|
|
||||||
|
|
||||||
if self.delete:
|
if self.delete:
|
||||||
# 5. Remove files which we did not explicitly export in this run
|
# 5. Remove files which we did not explicitly export in this run
|
||||||
@ -514,6 +521,35 @@ class Command(CryptMixin, BaseCommand):
|
|||||||
archive_target,
|
archive_target,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def check_and_write_json(
|
||||||
|
self,
|
||||||
|
content: list[dict] | dict,
|
||||||
|
target: Path,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Writes the source content to the target json file.
|
||||||
|
If --compare-json arg was used, don't write to target file if
|
||||||
|
the file exists and checksum is identical to content checksum.
|
||||||
|
This preserves the file timestamps when no changes are made.
|
||||||
|
"""
|
||||||
|
|
||||||
|
target = target.resolve()
|
||||||
|
perform_write = True
|
||||||
|
if target in self.files_in_export_dir:
|
||||||
|
self.files_in_export_dir.remove(target)
|
||||||
|
if self.compare_json:
|
||||||
|
target_checksum = hashlib.md5(target.read_bytes()).hexdigest()
|
||||||
|
src_str = json.dumps(content, indent=2, ensure_ascii=False)
|
||||||
|
src_checksum = hashlib.md5(src_str.encode("utf-8")).hexdigest()
|
||||||
|
if src_checksum == target_checksum:
|
||||||
|
perform_write = False
|
||||||
|
|
||||||
|
if perform_write:
|
||||||
|
target.write_text(
|
||||||
|
json.dumps(content, indent=2, ensure_ascii=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
def check_and_copy(
|
def check_and_copy(
|
||||||
self,
|
self,
|
||||||
source: Path,
|
source: Path,
|
||||||
|
@ -409,6 +409,7 @@ def document_matches_workflow(
|
|||||||
elif (
|
elif (
|
||||||
trigger_type == WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED
|
trigger_type == WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED
|
||||||
or trigger_type == WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED
|
or trigger_type == WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED
|
||||||
|
or trigger_type == WorkflowTrigger.WorkflowTriggerType.SCHEDULED
|
||||||
):
|
):
|
||||||
trigger_matched, reason = existing_document_matches_workflow(
|
trigger_matched, reason = existing_document_matches_workflow(
|
||||||
document,
|
document,
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
# Generated by Django 4.2.13 on 2024-06-28 17:57
|
# Generated by Django 4.2.13 on 2024-06-28 17:57
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
from django.db import migrations
|
from django.db import migrations
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
@ -43,7 +44,7 @@ class Migration(migrations.Migration):
|
|||||||
name="content",
|
name="content",
|
||||||
field=models.TextField(
|
field=models.TextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
db_index=True,
|
db_index=("mysql" not in settings.DATABASES["default"]["ENGINE"]),
|
||||||
help_text="The raw, text-only data of the document. This field is primarily used for searching.",
|
help_text="The raw, text-only data of the document. This field is primarily used for searching.",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
28
src/documents/migrations/1057_paperlesstask_owner.py
Normal file
28
src/documents/migrations/1057_paperlesstask_owner.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 5.1.1 on 2024-11-04 21:56
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("documents", "1056_customfieldinstance_deleted_at_and_more"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="paperlesstask",
|
||||||
|
name="owner",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
default=None,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name="owner",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -0,0 +1,143 @@
|
|||||||
|
# Generated by Django 5.1.1 on 2024-11-05 05:19
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("documents", "1057_paperlesstask_owner"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workflowtrigger",
|
||||||
|
name="schedule_date_custom_field",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to="documents.customfield",
|
||||||
|
verbose_name="schedule date custom field",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workflowtrigger",
|
||||||
|
name="schedule_date_field",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("added", "Added"),
|
||||||
|
("created", "Created"),
|
||||||
|
("modified", "Modified"),
|
||||||
|
("custom_field", "Custom Field"),
|
||||||
|
],
|
||||||
|
default="added",
|
||||||
|
help_text="The field to check for a schedule trigger.",
|
||||||
|
max_length=20,
|
||||||
|
verbose_name="schedule date field",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workflowtrigger",
|
||||||
|
name="schedule_is_recurring",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="If the schedule should be recurring.",
|
||||||
|
verbose_name="schedule is recurring",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workflowtrigger",
|
||||||
|
name="schedule_offset_days",
|
||||||
|
field=models.PositiveIntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text="The number of days to offset the schedule trigger by.",
|
||||||
|
verbose_name="schedule offset days",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workflowtrigger",
|
||||||
|
name="schedule_recurring_interval_days",
|
||||||
|
field=models.PositiveIntegerField(
|
||||||
|
default=1,
|
||||||
|
help_text="The number of days between recurring schedule triggers.",
|
||||||
|
validators=[django.core.validators.MinValueValidator(1)],
|
||||||
|
verbose_name="schedule recurring delay in days",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="workflowtrigger",
|
||||||
|
name="type",
|
||||||
|
field=models.PositiveIntegerField(
|
||||||
|
choices=[
|
||||||
|
(1, "Consumption Started"),
|
||||||
|
(2, "Document Added"),
|
||||||
|
(3, "Document Updated"),
|
||||||
|
(4, "Scheduled"),
|
||||||
|
],
|
||||||
|
default=1,
|
||||||
|
verbose_name="Workflow Trigger Type",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="WorkflowRun",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"type",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
choices=[
|
||||||
|
(1, "Consumption Started"),
|
||||||
|
(2, "Document Added"),
|
||||||
|
(3, "Document Updated"),
|
||||||
|
(4, "Scheduled"),
|
||||||
|
],
|
||||||
|
null=True,
|
||||||
|
verbose_name="workflow trigger type",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"run_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
db_index=True,
|
||||||
|
default=django.utils.timezone.now,
|
||||||
|
verbose_name="date run",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"document",
|
||||||
|
models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="workflow_runs",
|
||||||
|
to="documents.document",
|
||||||
|
verbose_name="document",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"workflow",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="runs",
|
||||||
|
to="documents.workflow",
|
||||||
|
verbose_name="workflow",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "workflow run",
|
||||||
|
"verbose_name_plural": "workflow runs",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
@ -641,7 +641,7 @@ class UiSettings(models.Model):
|
|||||||
return self.user.username
|
return self.user.username
|
||||||
|
|
||||||
|
|
||||||
class PaperlessTask(models.Model):
|
class PaperlessTask(ModelWithOwner):
|
||||||
ALL_STATES = sorted(states.ALL_STATES)
|
ALL_STATES = sorted(states.ALL_STATES)
|
||||||
TASK_STATE_CHOICES = sorted(zip(ALL_STATES, ALL_STATES))
|
TASK_STATE_CHOICES = sorted(zip(ALL_STATES, ALL_STATES))
|
||||||
|
|
||||||
@ -1020,12 +1020,19 @@ class WorkflowTrigger(models.Model):
|
|||||||
CONSUMPTION = 1, _("Consumption Started")
|
CONSUMPTION = 1, _("Consumption Started")
|
||||||
DOCUMENT_ADDED = 2, _("Document Added")
|
DOCUMENT_ADDED = 2, _("Document Added")
|
||||||
DOCUMENT_UPDATED = 3, _("Document Updated")
|
DOCUMENT_UPDATED = 3, _("Document Updated")
|
||||||
|
SCHEDULED = 4, _("Scheduled")
|
||||||
|
|
||||||
class DocumentSourceChoices(models.IntegerChoices):
|
class DocumentSourceChoices(models.IntegerChoices):
|
||||||
CONSUME_FOLDER = DocumentSource.ConsumeFolder.value, _("Consume Folder")
|
CONSUME_FOLDER = DocumentSource.ConsumeFolder.value, _("Consume Folder")
|
||||||
API_UPLOAD = DocumentSource.ApiUpload.value, _("Api Upload")
|
API_UPLOAD = DocumentSource.ApiUpload.value, _("Api Upload")
|
||||||
MAIL_FETCH = DocumentSource.MailFetch.value, _("Mail Fetch")
|
MAIL_FETCH = DocumentSource.MailFetch.value, _("Mail Fetch")
|
||||||
|
|
||||||
|
class ScheduleDateField(models.TextChoices):
|
||||||
|
ADDED = "added", _("Added")
|
||||||
|
CREATED = "created", _("Created")
|
||||||
|
MODIFIED = "modified", _("Modified")
|
||||||
|
CUSTOM_FIELD = "custom_field", _("Custom Field")
|
||||||
|
|
||||||
type = models.PositiveIntegerField(
|
type = models.PositiveIntegerField(
|
||||||
_("Workflow Trigger Type"),
|
_("Workflow Trigger Type"),
|
||||||
choices=WorkflowTriggerType.choices,
|
choices=WorkflowTriggerType.choices,
|
||||||
@ -1102,6 +1109,49 @@ class WorkflowTrigger(models.Model):
|
|||||||
verbose_name=_("has this correspondent"),
|
verbose_name=_("has this correspondent"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
schedule_offset_days = models.PositiveIntegerField(
|
||||||
|
_("schedule offset days"),
|
||||||
|
default=0,
|
||||||
|
help_text=_(
|
||||||
|
"The number of days to offset the schedule trigger by.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
schedule_is_recurring = models.BooleanField(
|
||||||
|
_("schedule is recurring"),
|
||||||
|
default=False,
|
||||||
|
help_text=_(
|
||||||
|
"If the schedule should be recurring.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
schedule_recurring_interval_days = models.PositiveIntegerField(
|
||||||
|
_("schedule recurring delay in days"),
|
||||||
|
default=1,
|
||||||
|
validators=[MinValueValidator(1)],
|
||||||
|
help_text=_(
|
||||||
|
"The number of days between recurring schedule triggers.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
schedule_date_field = models.CharField(
|
||||||
|
_("schedule date field"),
|
||||||
|
max_length=20,
|
||||||
|
choices=ScheduleDateField.choices,
|
||||||
|
default=ScheduleDateField.ADDED,
|
||||||
|
help_text=_(
|
||||||
|
"The field to check for a schedule trigger.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
schedule_date_custom_field = models.ForeignKey(
|
||||||
|
CustomField,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
verbose_name=_("schedule date custom field"),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("workflow trigger")
|
verbose_name = _("workflow trigger")
|
||||||
verbose_name_plural = _("workflow triggers")
|
verbose_name_plural = _("workflow triggers")
|
||||||
@ -1352,3 +1402,39 @@ class Workflow(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Workflow: {self.name}"
|
return f"Workflow: {self.name}"
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowRun(models.Model):
|
||||||
|
workflow = models.ForeignKey(
|
||||||
|
Workflow,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="runs",
|
||||||
|
verbose_name=_("workflow"),
|
||||||
|
)
|
||||||
|
|
||||||
|
type = models.PositiveIntegerField(
|
||||||
|
_("workflow trigger type"),
|
||||||
|
choices=WorkflowTrigger.WorkflowTriggerType.choices,
|
||||||
|
null=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
document = models.ForeignKey(
|
||||||
|
Document,
|
||||||
|
null=True,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="workflow_runs",
|
||||||
|
verbose_name=_("document"),
|
||||||
|
)
|
||||||
|
|
||||||
|
run_at = models.DateTimeField(
|
||||||
|
_("date run"),
|
||||||
|
default=timezone.now,
|
||||||
|
db_index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("workflow run")
|
||||||
|
verbose_name_plural = _("workflow runs")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"WorkflowRun of {self.workflow} at {self.run_at} on {self.document}"
|
||||||
|
@ -160,7 +160,7 @@ class SetPermissionsMixin:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
if set_permissions is not None:
|
if set_permissions is not None:
|
||||||
for action in permissions_dict:
|
for action, _ in permissions_dict.items():
|
||||||
if action in set_permissions:
|
if action in set_permissions:
|
||||||
users = set_permissions[action]["users"]
|
users = set_permissions[action]["users"]
|
||||||
permissions_dict[action]["users"] = self._validate_user_ids(users)
|
permissions_dict[action]["users"] = self._validate_user_ids(users)
|
||||||
@ -1578,7 +1578,7 @@ class UiSettingsViewSerializer(serializers.ModelSerializer):
|
|||||||
return ui_settings
|
return ui_settings
|
||||||
|
|
||||||
|
|
||||||
class TasksViewSerializer(serializers.ModelSerializer):
|
class TasksViewSerializer(OwnedObjectSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = PaperlessTask
|
model = PaperlessTask
|
||||||
depth = 1
|
depth = 1
|
||||||
@ -1593,6 +1593,7 @@ class TasksViewSerializer(serializers.ModelSerializer):
|
|||||||
"result",
|
"result",
|
||||||
"acknowledged",
|
"acknowledged",
|
||||||
"related_document",
|
"related_document",
|
||||||
|
"owner",
|
||||||
)
|
)
|
||||||
|
|
||||||
type = serializers.SerializerMethodField()
|
type = serializers.SerializerMethodField()
|
||||||
@ -1782,6 +1783,11 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer):
|
|||||||
"filter_has_tags",
|
"filter_has_tags",
|
||||||
"filter_has_correspondent",
|
"filter_has_correspondent",
|
||||||
"filter_has_document_type",
|
"filter_has_document_type",
|
||||||
|
"schedule_offset_days",
|
||||||
|
"schedule_is_recurring",
|
||||||
|
"schedule_recurring_interval_days",
|
||||||
|
"schedule_date_field",
|
||||||
|
"schedule_date_custom_field",
|
||||||
]
|
]
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
|
@ -37,6 +37,7 @@ from documents.models import PaperlessTask
|
|||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.models import Workflow
|
from documents.models import Workflow
|
||||||
from documents.models import WorkflowAction
|
from documents.models import WorkflowAction
|
||||||
|
from documents.models import WorkflowRun
|
||||||
from documents.models import WorkflowTrigger
|
from documents.models import WorkflowTrigger
|
||||||
from documents.permissions import get_objects_for_user_owner_aware
|
from documents.permissions import get_objects_for_user_owner_aware
|
||||||
from documents.permissions import set_permissions_for_object
|
from documents.permissions import set_permissions_for_object
|
||||||
@ -899,6 +900,7 @@ def run_workflows(
|
|||||||
"triggers",
|
"triggers",
|
||||||
)
|
)
|
||||||
.order_by("order")
|
.order_by("order")
|
||||||
|
.distinct()
|
||||||
)
|
)
|
||||||
|
|
||||||
for workflow in workflows:
|
for workflow in workflows:
|
||||||
@ -928,6 +930,12 @@ def run_workflows(
|
|||||||
document.save()
|
document.save()
|
||||||
document.tags.set(doc_tag_ids)
|
document.tags.set(doc_tag_ids)
|
||||||
|
|
||||||
|
WorkflowRun.objects.create(
|
||||||
|
workflow=workflow,
|
||||||
|
type=trigger_type,
|
||||||
|
document=document if not use_overrides else None,
|
||||||
|
)
|
||||||
|
|
||||||
if use_overrides:
|
if use_overrides:
|
||||||
return overrides, "\n".join(messages)
|
return overrides, "\n".join(messages)
|
||||||
|
|
||||||
@ -951,9 +959,10 @@ def before_task_publish_handler(sender=None, headers=None, body=None, **kwargs):
|
|||||||
close_old_connections()
|
close_old_connections()
|
||||||
|
|
||||||
task_args = body[0]
|
task_args = body[0]
|
||||||
input_doc, _ = task_args
|
input_doc, overrides = task_args
|
||||||
|
|
||||||
task_file_name = input_doc.original_file.name
|
task_file_name = input_doc.original_file.name
|
||||||
|
user_id = overrides.owner_id if overrides else None
|
||||||
|
|
||||||
PaperlessTask.objects.create(
|
PaperlessTask.objects.create(
|
||||||
task_id=headers["id"],
|
task_id=headers["id"],
|
||||||
@ -964,6 +973,7 @@ def before_task_publish_handler(sender=None, headers=None, body=None, **kwargs):
|
|||||||
date_created=timezone.now(),
|
date_created=timezone.now(),
|
||||||
date_started=None,
|
date_started=None,
|
||||||
date_done=None,
|
date_done=None,
|
||||||
|
owner_id=user_id,
|
||||||
)
|
)
|
||||||
except Exception: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
# Don't let an exception in the signal handlers prevent
|
# Don't let an exception in the signal handlers prevent
|
||||||
|
@ -31,10 +31,14 @@ from documents.double_sided import CollatePlugin
|
|||||||
from documents.file_handling import create_source_path_directory
|
from documents.file_handling import create_source_path_directory
|
||||||
from documents.file_handling import generate_unique_filename
|
from documents.file_handling import generate_unique_filename
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
|
from documents.models import CustomFieldInstance
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
|
from documents.models import Workflow
|
||||||
|
from documents.models import WorkflowRun
|
||||||
|
from documents.models import WorkflowTrigger
|
||||||
from documents.parsers import DocumentParser
|
from documents.parsers import DocumentParser
|
||||||
from documents.parsers import get_parser_class_for_mime_type
|
from documents.parsers import get_parser_class_for_mime_type
|
||||||
from documents.plugins.base import ConsumeTaskPlugin
|
from documents.plugins.base import ConsumeTaskPlugin
|
||||||
@ -44,6 +48,7 @@ from documents.plugins.helpers import ProgressStatusOptions
|
|||||||
from documents.sanity_checker import SanityCheckFailedException
|
from documents.sanity_checker import SanityCheckFailedException
|
||||||
from documents.signals import document_updated
|
from documents.signals import document_updated
|
||||||
from documents.signals.handlers import cleanup_document_deletion
|
from documents.signals.handlers import cleanup_document_deletion
|
||||||
|
from documents.signals.handlers import run_workflows
|
||||||
|
|
||||||
if settings.AUDIT_LOG_ENABLED:
|
if settings.AUDIT_LOG_ENABLED:
|
||||||
from auditlog.models import LogEntry
|
from auditlog.models import LogEntry
|
||||||
@ -206,9 +211,10 @@ def bulk_update_documents(document_ids):
|
|||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def update_document_archive_file(document_id):
|
def update_document_content_maybe_archive_file(document_id):
|
||||||
"""
|
"""
|
||||||
Re-creates the archive file of a document, including new OCR content and thumbnail
|
Re-creates OCR content and thumbnail for a document, and archive file if
|
||||||
|
it exists.
|
||||||
"""
|
"""
|
||||||
document = Document.objects.get(id=document_id)
|
document = Document.objects.get(id=document_id)
|
||||||
|
|
||||||
@ -234,8 +240,9 @@ def update_document_archive_file(document_id):
|
|||||||
document.get_public_filename(),
|
document.get_public_filename(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if parser.get_archive_path():
|
with transaction.atomic():
|
||||||
with transaction.atomic():
|
oldDocument = Document.objects.get(pk=document.pk)
|
||||||
|
if parser.get_archive_path():
|
||||||
with open(parser.get_archive_path(), "rb") as f:
|
with open(parser.get_archive_path(), "rb") as f:
|
||||||
checksum = hashlib.md5(f.read()).hexdigest()
|
checksum = hashlib.md5(f.read()).hexdigest()
|
||||||
# I'm going to save first so that in case the file move
|
# I'm going to save first so that in case the file move
|
||||||
@ -246,7 +253,6 @@ def update_document_archive_file(document_id):
|
|||||||
document,
|
document,
|
||||||
archive_filename=True,
|
archive_filename=True,
|
||||||
)
|
)
|
||||||
oldDocument = Document.objects.get(pk=document.pk)
|
|
||||||
Document.objects.filter(pk=document.pk).update(
|
Document.objects.filter(pk=document.pk).update(
|
||||||
archive_checksum=checksum,
|
archive_checksum=checksum,
|
||||||
content=parser.get_text(),
|
content=parser.get_text(),
|
||||||
@ -268,24 +274,41 @@ def update_document_archive_file(document_id):
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
additional_data={
|
additional_data={
|
||||||
"reason": "Update document archive file",
|
"reason": "Update document content",
|
||||||
|
},
|
||||||
|
action=LogEntry.Action.UPDATE,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
Document.objects.filter(pk=document.pk).update(
|
||||||
|
content=parser.get_text(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.AUDIT_LOG_ENABLED:
|
||||||
|
LogEntry.objects.log_create(
|
||||||
|
instance=oldDocument,
|
||||||
|
changes={
|
||||||
|
"content": [oldDocument.content, parser.get_text()],
|
||||||
|
},
|
||||||
|
additional_data={
|
||||||
|
"reason": "Update document content",
|
||||||
},
|
},
|
||||||
action=LogEntry.Action.UPDATE,
|
action=LogEntry.Action.UPDATE,
|
||||||
)
|
)
|
||||||
|
|
||||||
with FileLock(settings.MEDIA_LOCK):
|
with FileLock(settings.MEDIA_LOCK):
|
||||||
|
if parser.get_archive_path():
|
||||||
create_source_path_directory(document.archive_path)
|
create_source_path_directory(document.archive_path)
|
||||||
shutil.move(parser.get_archive_path(), document.archive_path)
|
shutil.move(parser.get_archive_path(), document.archive_path)
|
||||||
shutil.move(thumbnail, document.thumbnail_path)
|
shutil.move(thumbnail, document.thumbnail_path)
|
||||||
|
|
||||||
document.refresh_from_db()
|
document.refresh_from_db()
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Updating index for document {document_id} ({document.archive_checksum})",
|
f"Updating index for document {document_id} ({document.archive_checksum})",
|
||||||
)
|
)
|
||||||
with index.open_index_writer() as writer:
|
with index.open_index_writer() as writer:
|
||||||
index.update_document(writer, document)
|
index.update_document(writer, document)
|
||||||
|
|
||||||
clear_document_caches(document.pk)
|
clear_document_caches(document.pk)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
@ -319,3 +342,85 @@ def empty_trash(doc_ids=None):
|
|||||||
cleanup_document_deletion,
|
cleanup_document_deletion,
|
||||||
sender=Document,
|
sender=Document,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def check_scheduled_workflows():
|
||||||
|
scheduled_workflows: list[Workflow] = (
|
||||||
|
Workflow.objects.filter(
|
||||||
|
triggers__type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
.prefetch_related("triggers")
|
||||||
|
)
|
||||||
|
if scheduled_workflows.count() > 0:
|
||||||
|
logger.debug(f"Checking {len(scheduled_workflows)} scheduled workflows")
|
||||||
|
for workflow in scheduled_workflows:
|
||||||
|
schedule_triggers = workflow.triggers.filter(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
||||||
|
)
|
||||||
|
trigger: WorkflowTrigger
|
||||||
|
for trigger in schedule_triggers:
|
||||||
|
documents = Document.objects.none()
|
||||||
|
offset_td = timedelta(days=trigger.schedule_offset_days)
|
||||||
|
logger.debug(
|
||||||
|
f"Checking trigger {trigger} with offset {offset_td} against field: {trigger.schedule_date_field}",
|
||||||
|
)
|
||||||
|
match trigger.schedule_date_field:
|
||||||
|
case WorkflowTrigger.ScheduleDateField.ADDED:
|
||||||
|
documents = Document.objects.filter(
|
||||||
|
added__lt=timezone.now() - offset_td,
|
||||||
|
)
|
||||||
|
case WorkflowTrigger.ScheduleDateField.CREATED:
|
||||||
|
documents = Document.objects.filter(
|
||||||
|
created__lt=timezone.now() - offset_td,
|
||||||
|
)
|
||||||
|
case WorkflowTrigger.ScheduleDateField.MODIFIED:
|
||||||
|
documents = Document.objects.filter(
|
||||||
|
modified__lt=timezone.now() - offset_td,
|
||||||
|
)
|
||||||
|
case WorkflowTrigger.ScheduleDateField.CUSTOM_FIELD:
|
||||||
|
cf_instances = CustomFieldInstance.objects.filter(
|
||||||
|
field=trigger.schedule_date_custom_field,
|
||||||
|
value_date__lt=timezone.now() - offset_td,
|
||||||
|
)
|
||||||
|
documents = Document.objects.filter(
|
||||||
|
id__in=cf_instances.values_list("document", flat=True),
|
||||||
|
)
|
||||||
|
if documents.count() > 0:
|
||||||
|
logger.debug(
|
||||||
|
f"Found {documents.count()} documents for trigger {trigger}",
|
||||||
|
)
|
||||||
|
for document in documents:
|
||||||
|
workflow_runs = WorkflowRun.objects.filter(
|
||||||
|
document=document,
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
||||||
|
workflow=workflow,
|
||||||
|
).order_by("-run_at")
|
||||||
|
if not trigger.schedule_is_recurring and workflow_runs.exists():
|
||||||
|
# schedule is non-recurring and the workflow has already been run
|
||||||
|
logger.debug(
|
||||||
|
f"Skipping document {document} for non-recurring workflow {workflow} as it has already been run",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
elif (
|
||||||
|
trigger.schedule_is_recurring
|
||||||
|
and workflow_runs.exists()
|
||||||
|
and (
|
||||||
|
workflow_runs.last().run_at
|
||||||
|
> timezone.now()
|
||||||
|
- timedelta(
|
||||||
|
days=trigger.schedule_recurring_interval_days,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
):
|
||||||
|
# schedule is recurring but the last run was within the number of recurring interval days
|
||||||
|
logger.debug(
|
||||||
|
f"Skipping document {document} for recurring workflow {workflow} as the last run was within the recurring interval",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
run_workflows(
|
||||||
|
WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
||||||
|
document,
|
||||||
|
)
|
||||||
|
35
src/documents/templates/mfa/authenticate.html
Normal file
35
src/documents/templates/mfa/authenticate.html
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
{% extends "paperless-ngx/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load allauth %}
|
||||||
|
{% load allauth static %}
|
||||||
|
|
||||||
|
{% block head_title %}
|
||||||
|
{% trans "Paperless-ngx Two-Factor Authentication" %}
|
||||||
|
{% endblock head_title %}
|
||||||
|
|
||||||
|
{% block form_top_content %}
|
||||||
|
<p>
|
||||||
|
{% blocktranslate %}Your account is protected by two-factor authentication. Please enter an authenticator code:{% endblocktranslate %}
|
||||||
|
</p>
|
||||||
|
{% endblock form_top_content %}
|
||||||
|
|
||||||
|
{% block form_content %}
|
||||||
|
{% translate "Code" as i18n_code %}
|
||||||
|
<div class="form-floating">
|
||||||
|
<input type="code" name="code" id="inputCode" autocomplete="one-time-code" placeholder="{{ i18n_code }}" class="form-control" required>
|
||||||
|
<label for="inputCode">{{ i18n_code }}</label>
|
||||||
|
</div>
|
||||||
|
<div class="d-grid mt-3">
|
||||||
|
<button class="btn btn-lg btn-primary" type="submit">{% translate "Sign in" %}</button>
|
||||||
|
<button class="btn btn-lg btn-secondary mt-2" type="submit" form="logout-from-stage">{% translate "Cancel" %}</button>
|
||||||
|
</div>
|
||||||
|
{% endblock form_content %}
|
||||||
|
|
||||||
|
{% block after_form_content %}
|
||||||
|
<form id="logout-from-stage"
|
||||||
|
method="post"
|
||||||
|
action="{% url 'account_logout' %}">
|
||||||
|
<input type="hidden" name="next" value="{% url 'account_login' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
</form>
|
||||||
|
{% endblock after_form_content %}
|
@ -1,7 +1,9 @@
|
|||||||
import json
|
import json
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
from auditlog.models import LogEntry
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.test import override_settings
|
||||||
from guardian.shortcuts import assign_perm
|
from guardian.shortcuts import assign_perm
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
@ -51,8 +53,12 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.doc3.tags.add(self.t2)
|
self.doc3.tags.add(self.t2)
|
||||||
self.doc4.tags.add(self.t1, self.t2)
|
self.doc4.tags.add(self.t1, self.t2)
|
||||||
self.sp1 = StoragePath.objects.create(name="sp1", path="Something/{checksum}")
|
self.sp1 = StoragePath.objects.create(name="sp1", path="Something/{checksum}")
|
||||||
self.cf1 = CustomField.objects.create(name="cf1", data_type="text")
|
self.cf1 = CustomField.objects.create(name="cf1", data_type="string")
|
||||||
self.cf2 = CustomField.objects.create(name="cf2", data_type="text")
|
self.cf2 = CustomField.objects.create(name="cf2", data_type="string")
|
||||||
|
|
||||||
|
def setup_mock(self, m, method_name, return_value="OK"):
|
||||||
|
m.return_value = return_value
|
||||||
|
m.__name__ = method_name
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.bulk_update_documents.delay")
|
@mock.patch("documents.bulk_edit.bulk_update_documents.delay")
|
||||||
def test_api_set_correspondent(self, bulk_update_task_mock):
|
def test_api_set_correspondent(self, bulk_update_task_mock):
|
||||||
@ -178,7 +184,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.modify_tags")
|
@mock.patch("documents.serialisers.bulk_edit.modify_tags")
|
||||||
def test_api_modify_tags(self, m):
|
def test_api_modify_tags(self, m):
|
||||||
m.return_value = "OK"
|
self.setup_mock(m, "modify_tags")
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/bulk_edit/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
@ -211,7 +217,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
- API returns HTTP 400
|
- API returns HTTP 400
|
||||||
- modify_tags is not called
|
- modify_tags is not called
|
||||||
"""
|
"""
|
||||||
m.return_value = "OK"
|
self.setup_mock(m, "modify_tags")
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/bulk_edit/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
@ -230,7 +236,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.modify_custom_fields")
|
@mock.patch("documents.serialisers.bulk_edit.modify_custom_fields")
|
||||||
def test_api_modify_custom_fields(self, m):
|
def test_api_modify_custom_fields(self, m):
|
||||||
m.return_value = "OK"
|
self.setup_mock(m, "modify_custom_fields")
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/bulk_edit/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
@ -263,8 +269,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
- API returns HTTP 400
|
- API returns HTTP 400
|
||||||
- modify_custom_fields is not called
|
- modify_custom_fields is not called
|
||||||
"""
|
"""
|
||||||
m.return_value = "OK"
|
self.setup_mock(m, "modify_custom_fields")
|
||||||
|
|
||||||
# Missing add_custom_fields
|
# Missing add_custom_fields
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/bulk_edit/",
|
"/api/documents/bulk_edit/",
|
||||||
@ -359,7 +364,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.delete")
|
@mock.patch("documents.serialisers.bulk_edit.delete")
|
||||||
def test_api_delete(self, m):
|
def test_api_delete(self, m):
|
||||||
m.return_value = "OK"
|
self.setup_mock(m, "delete")
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/bulk_edit/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
@ -383,8 +388,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- set_storage_path is called with correct document IDs and storage_path ID
|
- set_storage_path is called with correct document IDs and storage_path ID
|
||||||
"""
|
"""
|
||||||
m.return_value = "OK"
|
self.setup_mock(m, "set_storage_path")
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/bulk_edit/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
@ -414,8 +418,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- set_storage_path is called with correct document IDs and None storage_path
|
- set_storage_path is called with correct document IDs and None storage_path
|
||||||
"""
|
"""
|
||||||
m.return_value = "OK"
|
self.setup_mock(m, "set_storage_path")
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/bulk_edit/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
@ -728,7 +731,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
||||||
def test_set_permissions(self, m):
|
def test_set_permissions(self, m):
|
||||||
m.return_value = "OK"
|
self.setup_mock(m, "set_permissions")
|
||||||
user1 = User.objects.create(username="user1")
|
user1 = User.objects.create(username="user1")
|
||||||
user2 = User.objects.create(username="user2")
|
user2 = User.objects.create(username="user2")
|
||||||
permissions = {
|
permissions = {
|
||||||
@ -763,7 +766,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
||||||
def test_set_permissions_merge(self, m):
|
def test_set_permissions_merge(self, m):
|
||||||
m.return_value = "OK"
|
self.setup_mock(m, "set_permissions")
|
||||||
user1 = User.objects.create(username="user1")
|
user1 = User.objects.create(username="user1")
|
||||||
user2 = User.objects.create(username="user2")
|
user2 = User.objects.create(username="user2")
|
||||||
permissions = {
|
permissions = {
|
||||||
@ -823,7 +826,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- User is not able to change permissions
|
- User is not able to change permissions
|
||||||
"""
|
"""
|
||||||
m.return_value = "OK"
|
self.setup_mock(m, "set_permissions")
|
||||||
self.doc1.owner = User.objects.get(username="temp_admin")
|
self.doc1.owner = User.objects.get(username="temp_admin")
|
||||||
self.doc1.save()
|
self.doc1.save()
|
||||||
user1 = User.objects.create(username="user1")
|
user1 = User.objects.create(username="user1")
|
||||||
@ -875,7 +878,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- set_storage_path only called if user can edit all docs
|
- set_storage_path only called if user can edit all docs
|
||||||
"""
|
"""
|
||||||
m.return_value = "OK"
|
self.setup_mock(m, "set_storage_path")
|
||||||
self.doc1.owner = User.objects.get(username="temp_admin")
|
self.doc1.owner = User.objects.get(username="temp_admin")
|
||||||
self.doc1.save()
|
self.doc1.save()
|
||||||
user1 = User.objects.create(username="user1")
|
user1 = User.objects.create(username="user1")
|
||||||
@ -919,8 +922,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.rotate")
|
@mock.patch("documents.serialisers.bulk_edit.rotate")
|
||||||
def test_rotate(self, m):
|
def test_rotate(self, m):
|
||||||
m.return_value = "OK"
|
self.setup_mock(m, "rotate")
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/bulk_edit/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
@ -974,8 +976,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.merge")
|
@mock.patch("documents.serialisers.bulk_edit.merge")
|
||||||
def test_merge(self, m):
|
def test_merge(self, m):
|
||||||
m.return_value = "OK"
|
self.setup_mock(m, "merge")
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/bulk_edit/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
@ -1003,8 +1004,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
user1 = User.objects.create(username="user1")
|
user1 = User.objects.create(username="user1")
|
||||||
self.client.force_authenticate(user=user1)
|
self.client.force_authenticate(user=user1)
|
||||||
|
|
||||||
m.return_value = "OK"
|
self.setup_mock(m, "merge")
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/bulk_edit/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
@ -1053,8 +1053,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- The API fails with a correct error code
|
- The API fails with a correct error code
|
||||||
"""
|
"""
|
||||||
m.return_value = "OK"
|
self.setup_mock(m, "merge")
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/bulk_edit/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
@ -1074,8 +1073,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.split")
|
@mock.patch("documents.serialisers.bulk_edit.split")
|
||||||
def test_split(self, m):
|
def test_split(self, m):
|
||||||
m.return_value = "OK"
|
self.setup_mock(m, "split")
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/bulk_edit/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
@ -1165,8 +1163,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.delete_pages")
|
@mock.patch("documents.serialisers.bulk_edit.delete_pages")
|
||||||
def test_delete_pages(self, m):
|
def test_delete_pages(self, m):
|
||||||
m.return_value = "OK"
|
self.setup_mock(m, "delete_pages")
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/bulk_edit/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
@ -1254,3 +1251,87 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn(b"pages must be a list of integers", response.content)
|
self.assertIn(b"pages must be a list of integers", response.content)
|
||||||
|
|
||||||
|
@override_settings(AUDIT_LOG_ENABLED=True)
|
||||||
|
def test_bulk_edit_audit_log_enabled_simple_field(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Audit log is enabled
|
||||||
|
WHEN:
|
||||||
|
- API to bulk edit documents is called
|
||||||
|
THEN:
|
||||||
|
- Audit log is created
|
||||||
|
"""
|
||||||
|
LogEntry.objects.all().delete()
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [self.doc1.id],
|
||||||
|
"method": "set_correspondent",
|
||||||
|
"parameters": {"correspondent": self.c2.id},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(LogEntry.objects.filter(object_pk=self.doc1.id).count(), 1)
|
||||||
|
|
||||||
|
@override_settings(AUDIT_LOG_ENABLED=True)
|
||||||
|
def test_bulk_edit_audit_log_enabled_tags(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Audit log is enabled
|
||||||
|
WHEN:
|
||||||
|
- API to bulk edit tags is called
|
||||||
|
THEN:
|
||||||
|
- Audit log is created
|
||||||
|
"""
|
||||||
|
LogEntry.objects.all().delete()
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [self.doc1.id],
|
||||||
|
"method": "modify_tags",
|
||||||
|
"parameters": {
|
||||||
|
"add_tags": [self.t1.id],
|
||||||
|
"remove_tags": [self.t2.id],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(LogEntry.objects.filter(object_pk=self.doc1.id).count(), 1)
|
||||||
|
|
||||||
|
@override_settings(AUDIT_LOG_ENABLED=True)
|
||||||
|
def test_bulk_edit_audit_log_enabled_custom_fields(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Audit log is enabled
|
||||||
|
WHEN:
|
||||||
|
- API to bulk edit custom fields is called
|
||||||
|
THEN:
|
||||||
|
- Audit log is created
|
||||||
|
"""
|
||||||
|
LogEntry.objects.all().delete()
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [self.doc1.id],
|
||||||
|
"method": "modify_custom_fields",
|
||||||
|
"parameters": {
|
||||||
|
"add_custom_fields": [self.cf1.id],
|
||||||
|
"remove_custom_fields": [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(LogEntry.objects.filter(object_pk=self.doc1.id).count(), 2)
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
|
from allauth.mfa.models import Authenticator
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
@ -601,6 +602,59 @@ class TestApiUser(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(returned_user2.first_name, "Updated Name 2")
|
self.assertEqual(returned_user2.first_name, "Updated Name 2")
|
||||||
self.assertNotEqual(returned_user2.password, initial_password)
|
self.assertNotEqual(returned_user2.password, initial_password)
|
||||||
|
|
||||||
|
def test_deactivate_totp(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Existing user account with TOTP enabled
|
||||||
|
WHEN:
|
||||||
|
- API request by a superuser is made to deactivate TOTP
|
||||||
|
- API request by a regular user is made to deactivate TOTP
|
||||||
|
THEN:
|
||||||
|
- TOTP is deactivated, if exists
|
||||||
|
- Regular user is forbidden from deactivating TOTP
|
||||||
|
"""
|
||||||
|
|
||||||
|
user1 = User.objects.create(
|
||||||
|
username="testuser",
|
||||||
|
password="test",
|
||||||
|
first_name="Test",
|
||||||
|
last_name="User",
|
||||||
|
)
|
||||||
|
Authenticator.objects.create(
|
||||||
|
user=user1,
|
||||||
|
type=Authenticator.Type.TOTP,
|
||||||
|
data={},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"{self.ENDPOINT}{user1.pk}/deactivate_totp/",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(Authenticator.objects.filter(user=user1).count(), 0)
|
||||||
|
|
||||||
|
# fail if already deactivated
|
||||||
|
response = self.client.post(
|
||||||
|
f"{self.ENDPOINT}{user1.pk}/deactivate_totp/",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
regular_user = User.objects.create_user(username="regular_user")
|
||||||
|
regular_user.user_permissions.add(
|
||||||
|
*Permission.objects.all(),
|
||||||
|
)
|
||||||
|
self.client.force_authenticate(regular_user)
|
||||||
|
Authenticator.objects.create(
|
||||||
|
user=user1,
|
||||||
|
type=Authenticator.Type.TOTP,
|
||||||
|
data={},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"{self.ENDPOINT}{user1.pk}/deactivate_totp/",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
|
||||||
class TestApiGroup(DirectoriesMixin, APITestCase):
|
class TestApiGroup(DirectoriesMixin, APITestCase):
|
||||||
ENDPOINT = "/api/groups/"
|
ENDPOINT = "/api/groups/"
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
from allauth.mfa.models import Authenticator
|
||||||
from allauth.socialaccount.models import SocialAccount
|
from allauth.socialaccount.models import SocialAccount
|
||||||
from allauth.socialaccount.models import SocialApp
|
from allauth.socialaccount.models import SocialApp
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
@ -299,3 +300,82 @@ class TestApiProfile(DirectoriesMixin, APITestCase):
|
|||||||
len(self.user.socialaccount_set.filter(pk=social_account_id)),
|
len(self.user.socialaccount_set.filter(pk=social_account_id)),
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestApiTOTPViews(APITestCase):
|
||||||
|
ENDPOINT = "/api/profile/totp/"
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
self.user = User.objects.create_superuser(username="temp_admin")
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
|
def test_get_totp(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Existing user account
|
||||||
|
WHEN:
|
||||||
|
- API request is made to TOTP endpoint
|
||||||
|
THEN:
|
||||||
|
- TOTP is generated
|
||||||
|
"""
|
||||||
|
response = self.client.get(
|
||||||
|
self.ENDPOINT,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertIn("qr_svg", response.data)
|
||||||
|
self.assertIn("secret", response.data)
|
||||||
|
|
||||||
|
@mock.patch("allauth.mfa.totp.internal.auth.validate_totp_code")
|
||||||
|
def test_activate_totp(self, mock_validate_totp_code):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Existing user account
|
||||||
|
WHEN:
|
||||||
|
- API request is made to activate TOTP
|
||||||
|
THEN:
|
||||||
|
- TOTP is activated, recovery codes are returned
|
||||||
|
"""
|
||||||
|
mock_validate_totp_code.return_value = True
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
data={
|
||||||
|
"secret": "123",
|
||||||
|
"code": "456",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertTrue(Authenticator.objects.filter(user=self.user).exists())
|
||||||
|
self.assertIn("recovery_codes", response.data)
|
||||||
|
|
||||||
|
def test_deactivate_totp(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Existing user account with TOTP enabled
|
||||||
|
WHEN:
|
||||||
|
- API request is made to deactivate TOTP
|
||||||
|
THEN:
|
||||||
|
- TOTP is deactivated
|
||||||
|
"""
|
||||||
|
Authenticator.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
type=Authenticator.Type.TOTP,
|
||||||
|
data={},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.delete(
|
||||||
|
self.ENDPOINT,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(Authenticator.objects.filter(user=self.user).count(), 0)
|
||||||
|
|
||||||
|
# test fails
|
||||||
|
response = self.client.delete(
|
||||||
|
self.ENDPOINT,
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import celery
|
import celery
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
@ -11,7 +12,6 @@ from documents.tests.utils import DirectoriesMixin
|
|||||||
|
|
||||||
class TestTasks(DirectoriesMixin, APITestCase):
|
class TestTasks(DirectoriesMixin, APITestCase):
|
||||||
ENDPOINT = "/api/tasks/"
|
ENDPOINT = "/api/tasks/"
|
||||||
ENDPOINT_ACKNOWLEDGE = "/api/acknowledge_tasks/"
|
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
@ -125,7 +125,7 @@ class TestTasks(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(len(response.data), 1)
|
self.assertEqual(len(response.data), 1)
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
self.ENDPOINT_ACKNOWLEDGE,
|
self.ENDPOINT + "acknowledge/",
|
||||||
{"tasks": [task.id]},
|
{"tasks": [task.id]},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
@ -133,6 +133,52 @@ class TestTasks(DirectoriesMixin, APITestCase):
|
|||||||
response = self.client.get(self.ENDPOINT)
|
response = self.client.get(self.ENDPOINT)
|
||||||
self.assertEqual(len(response.data), 0)
|
self.assertEqual(len(response.data), 0)
|
||||||
|
|
||||||
|
def test_tasks_owner_aware(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Existing PaperlessTasks with owner and with no owner
|
||||||
|
WHEN:
|
||||||
|
- API call is made to get tasks
|
||||||
|
THEN:
|
||||||
|
- Only tasks with no owner or request user are returned
|
||||||
|
"""
|
||||||
|
|
||||||
|
regular_user = User.objects.create_user(username="test")
|
||||||
|
regular_user.user_permissions.add(*Permission.objects.all())
|
||||||
|
self.client.logout()
|
||||||
|
self.client.force_authenticate(user=regular_user)
|
||||||
|
|
||||||
|
task1 = PaperlessTask.objects.create(
|
||||||
|
task_id=str(uuid.uuid4()),
|
||||||
|
task_file_name="task_one.pdf",
|
||||||
|
owner=self.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
task2 = PaperlessTask.objects.create(
|
||||||
|
task_id=str(uuid.uuid4()),
|
||||||
|
task_file_name="task_two.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
task3 = PaperlessTask.objects.create(
|
||||||
|
task_id=str(uuid.uuid4()),
|
||||||
|
task_file_name="task_three.pdf",
|
||||||
|
owner=regular_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(self.ENDPOINT)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(len(response.data), 2)
|
||||||
|
self.assertEqual(response.data[0]["task_id"], task3.task_id)
|
||||||
|
self.assertEqual(response.data[1]["task_id"], task2.task_id)
|
||||||
|
|
||||||
|
acknowledge_response = self.client.post(
|
||||||
|
self.ENDPOINT + "acknowledge/",
|
||||||
|
{"tasks": [task1.id, task2.id, task3.id]},
|
||||||
|
)
|
||||||
|
self.assertEqual(acknowledge_response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(acknowledge_response.data, {"result": 2})
|
||||||
|
|
||||||
def test_task_result_no_error(self):
|
def test_task_result_no_error(self):
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
@ -607,7 +607,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
mock_consume_file.assert_not_called()
|
mock_consume_file.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.tasks.bulk_update_documents.si")
|
@mock.patch("documents.tasks.bulk_update_documents.si")
|
||||||
@mock.patch("documents.tasks.update_document_archive_file.s")
|
@mock.patch("documents.tasks.update_document_content_maybe_archive_file.s")
|
||||||
@mock.patch("celery.chord.delay")
|
@mock.patch("celery.chord.delay")
|
||||||
def test_rotate(self, mock_chord, mock_update_document, mock_update_documents):
|
def test_rotate(self, mock_chord, mock_update_document, mock_update_documents):
|
||||||
"""
|
"""
|
||||||
@ -626,7 +626,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
|
|
||||||
@mock.patch("documents.tasks.bulk_update_documents.si")
|
@mock.patch("documents.tasks.bulk_update_documents.si")
|
||||||
@mock.patch("documents.tasks.update_document_archive_file.s")
|
@mock.patch("documents.tasks.update_document_content_maybe_archive_file.s")
|
||||||
@mock.patch("pikepdf.Pdf.save")
|
@mock.patch("pikepdf.Pdf.save")
|
||||||
def test_rotate_with_error(
|
def test_rotate_with_error(
|
||||||
self,
|
self,
|
||||||
@ -654,7 +654,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
mock_update_archive_file.assert_not_called()
|
mock_update_archive_file.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.tasks.bulk_update_documents.si")
|
@mock.patch("documents.tasks.bulk_update_documents.si")
|
||||||
@mock.patch("documents.tasks.update_document_archive_file.s")
|
@mock.patch("documents.tasks.update_document_content_maybe_archive_file.s")
|
||||||
@mock.patch("celery.chord.delay")
|
@mock.patch("celery.chord.delay")
|
||||||
def test_rotate_non_pdf(
|
def test_rotate_non_pdf(
|
||||||
self,
|
self,
|
||||||
@ -680,7 +680,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
mock_chord.assert_called_once()
|
mock_chord.assert_called_once()
|
||||||
self.assertEqual(result, "OK")
|
self.assertEqual(result, "OK")
|
||||||
|
|
||||||
@mock.patch("documents.tasks.update_document_archive_file.delay")
|
@mock.patch("documents.tasks.update_document_content_maybe_archive_file.delay")
|
||||||
@mock.patch("pikepdf.Pdf.save")
|
@mock.patch("pikepdf.Pdf.save")
|
||||||
def test_delete_pages(self, mock_pdf_save, mock_update_archive_file):
|
def test_delete_pages(self, mock_pdf_save, mock_update_archive_file):
|
||||||
"""
|
"""
|
||||||
@ -705,7 +705,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
self.doc2.refresh_from_db()
|
self.doc2.refresh_from_db()
|
||||||
self.assertEqual(self.doc2.page_count, expected_page_count)
|
self.assertEqual(self.doc2.page_count, expected_page_count)
|
||||||
|
|
||||||
@mock.patch("documents.tasks.update_document_archive_file.delay")
|
@mock.patch("documents.tasks.update_document_content_maybe_archive_file.delay")
|
||||||
@mock.patch("pikepdf.Pdf.save")
|
@mock.patch("pikepdf.Pdf.save")
|
||||||
def test_delete_pages_with_error(self, mock_pdf_save, mock_update_archive_file):
|
def test_delete_pages_with_error(self, mock_pdf_save, mock_update_archive_file):
|
||||||
"""
|
"""
|
||||||
|
@ -13,7 +13,7 @@ from django.test import override_settings
|
|||||||
|
|
||||||
from documents.file_handling import generate_filename
|
from documents.file_handling import generate_filename
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.tasks import update_document_archive_file
|
from documents.tasks import update_document_content_maybe_archive_file
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import DirectoriesMixin
|
||||||
from documents.tests.utils import FileSystemAssertsMixin
|
from documents.tests.utils import FileSystemAssertsMixin
|
||||||
|
|
||||||
@ -46,7 +46,7 @@ class TestArchiver(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
|||||||
os.path.join(self.dirs.originals_dir, f"{doc.id:07}.pdf"),
|
os.path.join(self.dirs.originals_dir, f"{doc.id:07}.pdf"),
|
||||||
)
|
)
|
||||||
|
|
||||||
update_document_archive_file(doc.pk)
|
update_document_content_maybe_archive_file(doc.pk)
|
||||||
|
|
||||||
doc = Document.objects.get(id=doc.id)
|
doc = Document.objects.get(id=doc.id)
|
||||||
|
|
||||||
@ -63,7 +63,7 @@ class TestArchiver(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
|||||||
doc.save()
|
doc.save()
|
||||||
shutil.copy(sample_file, doc.source_path)
|
shutil.copy(sample_file, doc.source_path)
|
||||||
|
|
||||||
update_document_archive_file(doc.pk)
|
update_document_content_maybe_archive_file(doc.pk)
|
||||||
|
|
||||||
doc = Document.objects.get(id=doc.id)
|
doc = Document.objects.get(id=doc.id)
|
||||||
|
|
||||||
@ -94,8 +94,8 @@ class TestArchiver(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
|||||||
os.path.join(self.dirs.originals_dir, "document_01.pdf"),
|
os.path.join(self.dirs.originals_dir, "document_01.pdf"),
|
||||||
)
|
)
|
||||||
|
|
||||||
update_document_archive_file(doc2.pk)
|
update_document_content_maybe_archive_file(doc2.pk)
|
||||||
update_document_archive_file(doc1.pk)
|
update_document_content_maybe_archive_file(doc1.pk)
|
||||||
|
|
||||||
doc1 = Document.objects.get(id=doc1.id)
|
doc1 = Document.objects.get(id=doc1.id)
|
||||||
doc2 = Document.objects.get(id=doc2.id)
|
doc2 = Document.objects.get(id=doc2.id)
|
||||||
|
@ -153,6 +153,7 @@ class TestExportImport(
|
|||||||
*,
|
*,
|
||||||
use_filename_format=False,
|
use_filename_format=False,
|
||||||
compare_checksums=False,
|
compare_checksums=False,
|
||||||
|
compare_json=False,
|
||||||
delete=False,
|
delete=False,
|
||||||
no_archive=False,
|
no_archive=False,
|
||||||
no_thumbnail=False,
|
no_thumbnail=False,
|
||||||
@ -165,6 +166,8 @@ class TestExportImport(
|
|||||||
args += ["--use-filename-format"]
|
args += ["--use-filename-format"]
|
||||||
if compare_checksums:
|
if compare_checksums:
|
||||||
args += ["--compare-checksums"]
|
args += ["--compare-checksums"]
|
||||||
|
if compare_json:
|
||||||
|
args += ["--compare-json"]
|
||||||
if delete:
|
if delete:
|
||||||
args += ["--delete"]
|
args += ["--delete"]
|
||||||
if no_archive:
|
if no_archive:
|
||||||
@ -340,6 +343,10 @@ class TestExportImport(
|
|||||||
self.assertNotEqual(st_mtime_1, st_mtime_2)
|
self.assertNotEqual(st_mtime_1, st_mtime_2)
|
||||||
self.assertNotEqual(st_mtime_2, st_mtime_3)
|
self.assertNotEqual(st_mtime_2, st_mtime_3)
|
||||||
|
|
||||||
|
self._do_export(compare_json=True)
|
||||||
|
st_mtime_4 = os.stat(os.path.join(self.target, "manifest.json")).st_mtime
|
||||||
|
self.assertEqual(st_mtime_3, st_mtime_4)
|
||||||
|
|
||||||
def test_update_export_changed_checksum(self):
|
def test_update_export_changed_checksum(self):
|
||||||
shutil.rmtree(os.path.join(self.dirs.media_dir, "documents"))
|
shutil.rmtree(os.path.join(self.dirs.media_dir, "documents"))
|
||||||
shutil.copytree(
|
shutil.copytree(
|
||||||
|
@ -5,6 +5,7 @@ import celery
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from documents.data_models import ConsumableDocument
|
from documents.data_models import ConsumableDocument
|
||||||
|
from documents.data_models import DocumentMetadataOverrides
|
||||||
from documents.data_models import DocumentSource
|
from documents.data_models import DocumentSource
|
||||||
from documents.models import PaperlessTask
|
from documents.models import PaperlessTask
|
||||||
from documents.signals.handlers import before_task_publish_handler
|
from documents.signals.handlers import before_task_publish_handler
|
||||||
@ -48,7 +49,10 @@ class TestTaskSignalHandler(DirectoriesMixin, TestCase):
|
|||||||
source=DocumentSource.ConsumeFolder,
|
source=DocumentSource.ConsumeFolder,
|
||||||
original_file="/consume/hello-999.pdf",
|
original_file="/consume/hello-999.pdf",
|
||||||
),
|
),
|
||||||
None,
|
DocumentMetadataOverrides(
|
||||||
|
title="Hello world",
|
||||||
|
owner_id=1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
# kwargs
|
# kwargs
|
||||||
{},
|
{},
|
||||||
@ -65,6 +69,7 @@ class TestTaskSignalHandler(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(headers["id"], task.task_id)
|
self.assertEqual(headers["id"], task.task_id)
|
||||||
self.assertEqual("hello-999.pdf", task.task_file_name)
|
self.assertEqual("hello-999.pdf", task.task_file_name)
|
||||||
self.assertEqual("documents.tasks.consume_file", task.task_name)
|
self.assertEqual("documents.tasks.consume_file", task.task_name)
|
||||||
|
self.assertEqual(1, task.owner_id)
|
||||||
self.assertEqual(celery.states.PENDING, task.status)
|
self.assertEqual(celery.states.PENDING, task.status)
|
||||||
|
|
||||||
def test_task_prerun_handler(self):
|
def test_task_prerun_handler(self):
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@ -184,3 +186,75 @@ class TestEmptyTrashTask(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
|||||||
|
|
||||||
tasks.empty_trash()
|
tasks.empty_trash()
|
||||||
self.assertEqual(Document.global_objects.count(), 0)
|
self.assertEqual(Document.global_objects.count(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateContent(DirectoriesMixin, TestCase):
|
||||||
|
def test_update_content_maybe_archive_file(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Existing document with archive file
|
||||||
|
WHEN:
|
||||||
|
- Update content task is called
|
||||||
|
THEN:
|
||||||
|
- Document is reprocessed, content and checksum are updated
|
||||||
|
"""
|
||||||
|
sample1 = self.dirs.scratch_dir / "sample.pdf"
|
||||||
|
shutil.copy(
|
||||||
|
Path(__file__).parent
|
||||||
|
/ "samples"
|
||||||
|
/ "documents"
|
||||||
|
/ "originals"
|
||||||
|
/ "0000001.pdf",
|
||||||
|
sample1,
|
||||||
|
)
|
||||||
|
sample1_archive = self.dirs.archive_dir / "sample_archive.pdf"
|
||||||
|
shutil.copy(
|
||||||
|
Path(__file__).parent
|
||||||
|
/ "samples"
|
||||||
|
/ "documents"
|
||||||
|
/ "originals"
|
||||||
|
/ "0000001.pdf",
|
||||||
|
sample1_archive,
|
||||||
|
)
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="test",
|
||||||
|
content="my document",
|
||||||
|
checksum="wow",
|
||||||
|
archive_checksum="wow",
|
||||||
|
filename=sample1,
|
||||||
|
mime_type="application/pdf",
|
||||||
|
archive_filename=sample1_archive,
|
||||||
|
)
|
||||||
|
|
||||||
|
tasks.update_document_content_maybe_archive_file(doc.pk)
|
||||||
|
self.assertNotEqual(Document.objects.get(pk=doc.pk).content, "test")
|
||||||
|
self.assertNotEqual(Document.objects.get(pk=doc.pk).archive_checksum, "wow")
|
||||||
|
|
||||||
|
def test_update_content_maybe_archive_file_no_archive(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Existing document without archive file
|
||||||
|
WHEN:
|
||||||
|
- Update content task is called
|
||||||
|
THEN:
|
||||||
|
- Document is reprocessed, content is updated
|
||||||
|
"""
|
||||||
|
sample1 = self.dirs.scratch_dir / "sample.pdf"
|
||||||
|
shutil.copy(
|
||||||
|
Path(__file__).parent
|
||||||
|
/ "samples"
|
||||||
|
/ "documents"
|
||||||
|
/ "originals"
|
||||||
|
/ "0000001.pdf",
|
||||||
|
sample1,
|
||||||
|
)
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="test",
|
||||||
|
content="my document",
|
||||||
|
checksum="wow",
|
||||||
|
filename=sample1,
|
||||||
|
mime_type="application/pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
tasks.update_document_content_maybe_archive_file(doc.pk)
|
||||||
|
self.assertNotEqual(Document.objects.get(pk=doc.pk).content, "test")
|
||||||
|
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