mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-31 12:05:45 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9989847d1 | ||
|
|
b68ce4b977 |
No files matched your search
@@ -1,53 +1,53 @@
|
|||||||
name: Install apt packages
|
name: Install apt packages
|
||||||
description: >
|
description: >
|
||||||
Installs apt packages with a per-attempt timeout and retries. The hosted runner's Azure apt mirror intermittently serves archives at a crawl, which apt's own timeout does not catch. A stuck attempt is killed and retried, which usually lands on a healthy mirror node.
|
Installs apt packages using Ubuntu mirrors and bounded download retries. Caches downloaded packages as an optimization; apt resolves dependencies on every run.
|
||||||
|
|
||||||
inputs:
|
inputs:
|
||||||
packages:
|
packages:
|
||||||
description: Space separated list of packages to install
|
description: Space separated list of package names to install
|
||||||
required: true
|
required: true
|
||||||
attempts:
|
attempts:
|
||||||
description: Maximum number of attempts
|
description: Maximum number of download attempts
|
||||||
default: "3"
|
default: "3"
|
||||||
attempt-timeout:
|
attempt-timeout:
|
||||||
description: Seconds before a single attempt is killed
|
description: Seconds before a single download attempt is killed
|
||||||
default: "180"
|
default: "180"
|
||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
|
- name: Identify apt cache
|
||||||
|
id: cache-key
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PACKAGES: ${{ inputs.packages }}
|
||||||
|
run: |
|
||||||
|
. /etc/os-release
|
||||||
|
packages_hash=$(printf '%s' "$PACKAGES" | sha256sum | cut -d ' ' -f 1)
|
||||||
|
# Cached downloads are optional: apt fetches any missing/newer packages.
|
||||||
|
# Refresh weekly, including inside job containers without ImageVersion.
|
||||||
|
echo "key=apt-v2-${ID}-${VERSION_ID}-$(dpkg --print-architecture)-$(date -u +%G-%V)-${packages_hash}" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Restore apt packages
|
||||||
|
id: cache
|
||||||
|
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ runner.temp }}/apt-packages/*.deb
|
||||||
|
${{ runner.temp }}/apt-packages/.keep
|
||||||
|
key: ${{ steps.cache-key.outputs.key }}
|
||||||
- name: Install ${{ inputs.packages }}
|
- name: Install ${{ inputs.packages }}
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
PACKAGES: ${{ inputs.packages }}
|
PACKAGES: ${{ inputs.packages }}
|
||||||
ATTEMPTS: ${{ inputs.attempts }}
|
ATTEMPTS: ${{ inputs.attempts }}
|
||||||
ATTEMPT_TIMEOUT: ${{ inputs.attempt-timeout }}
|
ATTEMPT_TIMEOUT: ${{ inputs.attempt-timeout }}
|
||||||
run: |
|
CACHE_DIR: ${{ runner.temp }}/apt-packages
|
||||||
SUDO=""
|
run: bash "$GITHUB_ACTION_PATH/install.sh"
|
||||||
if [ "$(id -u)" -ne 0 ]; then
|
# Save immediately: a later test failure must not discard downloaded packages.
|
||||||
SUDO="sudo"
|
- name: Save apt packages
|
||||||
fi
|
if: steps.cache.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
attempt_install() {
|
with:
|
||||||
$SUDO apt-get update -q
|
path: |
|
||||||
# Intentionally unquoted, this is a list of package names
|
${{ runner.temp }}/apt-packages/*.deb
|
||||||
$SUDO apt-get install -q --yes --no-install-recommends $PACKAGES
|
${{ runner.temp }}/apt-packages/.keep
|
||||||
}
|
key: ${{ steps.cache-key.outputs.key }}
|
||||||
export -f attempt_install
|
|
||||||
export SUDO PACKAGES
|
|
||||||
|
|
||||||
for attempt in $(seq 1 "${ATTEMPTS}"); do
|
|
||||||
echo "::group::apt install attempt ${attempt}/${ATTEMPTS}"
|
|
||||||
start=$(date +%s)
|
|
||||||
if timeout --kill-after=10 "${ATTEMPT_TIMEOUT}" bash -c attempt_install; then
|
|
||||||
echo "::endgroup::"
|
|
||||||
echo "Installed in $(( $(date +%s) - start ))s"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "::endgroup::"
|
|
||||||
echo "::warning::apt install attempt ${attempt}/${ATTEMPTS} failed or exceeded ${ATTEMPT_TIMEOUT}s"
|
|
||||||
# A killed attempt can leave dpkg half configured
|
|
||||||
$SUDO dpkg --configure -a || true
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "::error::apt install failed after ${ATTEMPTS} attempts"
|
|
||||||
exit 1
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Run as root in the timed child process. Only downloads may be interrupted.
|
||||||
|
if [ "${1:-}" = download ]; then
|
||||||
|
download_archive_dir=$2
|
||||||
|
shift 2
|
||||||
|
apt-get update -q
|
||||||
|
apt-get install -q --yes --no-install-recommends --download-only \
|
||||||
|
-o "Dir::Cache::archives=$download_archive_dir" "$@"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
SUDO=()
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
SUDO=(sudo)
|
||||||
|
fi
|
||||||
|
read -r -a requested_packages <<< "$PACKAGES"
|
||||||
|
mkdir -p "$CACHE_DIR"
|
||||||
|
touch "$CACHE_DIR/.keep"
|
||||||
|
|
||||||
|
# Hosted runners use a mirror list; job containers can point directly at Azure.
|
||||||
|
# Handle both layouts, leaving other repositories and signature checks intact.
|
||||||
|
if [ -f /etc/apt/apt-mirrors.txt ]; then
|
||||||
|
"${SUDO[@]}" sed -i '\|azure\.archive\.ubuntu\.com|d' /etc/apt/apt-mirrors.txt
|
||||||
|
fi
|
||||||
|
for source in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do
|
||||||
|
if [ -f "$source" ]; then
|
||||||
|
"${SUDO[@]}" sed -i 's|azure\.archive\.ubuntu\.com|archive.ubuntu.com|g' "$source"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
for attempt in $(seq 1 "$ATTEMPTS"); do
|
||||||
|
echo "::group::apt download attempt ${attempt}/${ATTEMPTS}"
|
||||||
|
if timeout --kill-after=10 "$ATTEMPT_TIMEOUT" "${SUDO[@]}" bash "$0" download "$CACHE_DIR" "${requested_packages[@]}"; then
|
||||||
|
echo "::endgroup::"
|
||||||
|
# Apt handles aliases (e.g. libmagic1 -> libmagic1t64) and dependencies.
|
||||||
|
"${SUDO[@]}" apt-get install -q --yes --no-install-recommends --no-download \
|
||||||
|
-o "Dir::Cache::archives=$CACHE_DIR" "${requested_packages[@]}"
|
||||||
|
# Allow the unprivileged cache action to read archives downloaded as root.
|
||||||
|
find "$CACHE_DIR" -maxdepth 1 -name '*.deb' -exec "${SUDO[@]}" chmod a+r {} +
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "::endgroup::"
|
||||||
|
echo "::warning::apt download attempt ${attempt}/${ATTEMPTS} failed or exceeded ${ATTEMPT_TIMEOUT}s"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "::error::apt download failed after ${ATTEMPTS} attempts"
|
||||||
|
exit 1
|
||||||
@@ -76,6 +76,7 @@ jobs:
|
|||||||
- 'uv.lock'
|
- 'uv.lock'
|
||||||
- 'docker/compose/docker-compose.ci-test.yml'
|
- 'docker/compose/docker-compose.ci-test.yml'
|
||||||
- '.github/workflows/ci-backend.yml'
|
- '.github/workflows/ci-backend.yml'
|
||||||
|
- '.github/actions/apt-install/**'
|
||||||
test:
|
test:
|
||||||
needs: changes
|
needs: changes
|
||||||
if: needs.changes.outputs.backend_changed == 'true'
|
if: needs.changes.outputs.backend_changed == 'true'
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ jobs:
|
|||||||
frontend:
|
frontend:
|
||||||
- 'src-ui/**'
|
- 'src-ui/**'
|
||||||
- '.github/workflows/ci-frontend.yml'
|
- '.github/workflows/ci-frontend.yml'
|
||||||
|
- '.github/actions/apt-install/**'
|
||||||
install-dependencies:
|
install-dependencies:
|
||||||
needs: changes
|
needs: changes
|
||||||
if: needs.changes.outputs.frontend_changed == 'true'
|
if: needs.changes.outputs.frontend_changed == 'true'
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ The following are not generally considered vulnerabilities unless accompanied by
|
|||||||
- claims based solely on the presence of a library, framework feature or code pattern without a working exploit
|
- claims based solely on the presence of a library, framework feature or code pattern without a working exploit
|
||||||
- pickle deserialization of internal data from trusted components such as the Redis-compatible broker or Paperless-ngx data directory
|
- pickle deserialization of internal data from trusted components such as the Redis-compatible broker or Paperless-ngx data directory
|
||||||
- users with permission to edit users granting themselves additional privileges; this is expected behavior for that trusted permission
|
- users with permission to edit users granting themselves additional privileges; this is expected behavior for that trusted permission
|
||||||
- users with change permission on a mail account redirecting stored credentials to another mail server, including through connectivity tests. Mail account editing is a trusted permission, and UI password masking does not imply credential isolation from account editors
|
|
||||||
- reports that rely on admin-level access, application-configuration access, workflow-editing privileges, shell access, or other high-trust roles unless they demonstrate an unintended privilege boundary bypass
|
- reports that rely on admin-level access, application-configuration access, workflow-editing privileges, shell access, or other high-trust roles unless they demonstrate an unintended privilege boundary bypass
|
||||||
- optional webhook, mail, AI, OCR, or integration behavior described without a product-level vulnerability
|
- optional webhook, mail, AI, OCR, or integration behavior described without a product-level vulnerability
|
||||||
- missing limits or hardening settings presented without concrete impact
|
- missing limits or hardening settings presented without concrete impact
|
||||||
|
|||||||
-11
@@ -162,17 +162,6 @@ including `gt` (>), `gte` (>=), `lt` (<), `lte` (<=), and `range`.
|
|||||||
Lastly, document link fields support a `contains` operator that behaves
|
Lastly, document link fields support a `contains` operator that behaves
|
||||||
like a "is superset of" check.
|
like a "is superset of" check.
|
||||||
|
|
||||||
Date fields also accept relative expressions supported by
|
|
||||||
[dateparser](https://dateparser.readthedocs.io/en/stable/), such as `today`,
|
|
||||||
`in 30 days`, or `1 month ago`. For example, dates within the next 30 days:
|
|
||||||
|
|
||||||
```json
|
|
||||||
["expiry", "range", ["today", "in 30 days"]]
|
|
||||||
```
|
|
||||||
|
|
||||||
Expressions resolve each time the query runs, using `PAPERLESS_TIME_ZONE` and
|
|
||||||
`PAPERLESS_DATE_PARSER_LANGUAGES` (automatic language detection when unset).
|
|
||||||
|
|
||||||
### `/api/search/autocomplete/`
|
### `/api/search/autocomplete/`
|
||||||
|
|
||||||
Get auto completions for a partial search term.
|
Get auto completions for a partial search term.
|
||||||
|
|||||||
@@ -1,91 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## paperless-ngx 3.4.0
|
|
||||||
|
|
||||||
### Features / Enhancements
|
|
||||||
|
|
||||||
- Enhancement: support custom fields in workflow title templates [@shamoon](https://github.com/shamoon) ([#14564](https://github.com/paperless-ngx/paperless-ngx/pull/14564))
|
|
||||||
- Enhancement: relative date custom field queries [@shamoon](https://github.com/shamoon) ([#14549](https://github.com/paperless-ngx/paperless-ngx/pull/14549))
|
|
||||||
- Performance: reduce tasks badge load times [@JanKleine](https://github.com/JanKleine) ([#14552](https://github.com/paperless-ngx/paperless-ngx/pull/14552))
|
|
||||||
- Enhancement: skip local 2FA for trusted OIDC AMR [@shamoon](https://github.com/shamoon) ([#14403](https://github.com/paperless-ngx/paperless-ngx/pull/14403))
|
|
||||||
- Feature: Generate PDF thumbnails via poppler utils [@stumpylog](https://github.com/stumpylog) ([#14420](https://github.com/paperless-ngx/paperless-ngx/pull/14420))
|
|
||||||
- Enhancement: add upload count to button, link to tray, reduce notifications [@shamoon](https://github.com/shamoon) ([#14501](https://github.com/paperless-ngx/paperless-ngx/pull/14501))
|
|
||||||
- Enhancement: notifications improvements [@shamoon](https://github.com/shamoon) ([#14500](https://github.com/paperless-ngx/paperless-ngx/pull/14500))
|
|
||||||
- Enhancement: doc details ux improvements [@shamoon](https://github.com/shamoon) ([#14485](https://github.com/paperless-ngx/paperless-ngx/pull/14485))
|
|
||||||
- Enhancement (QoL): allow independently toggling child + parent tags in WF tag fields [@shamoon](https://github.com/shamoon) ([#14400](https://github.com/paperless-ngx/paperless-ngx/pull/14400))
|
|
||||||
- Tweak: drop the user dropdown caret [@shamoon](https://github.com/shamoon) ([#14444](https://github.com/paperless-ngx/paperless-ngx/pull/14444))
|
|
||||||
- Enhancement: navbar upload button [@shamoon](https://github.com/shamoon) ([#14442](https://github.com/paperless-ngx/paperless-ngx/pull/14442))
|
|
||||||
- Enhancement: AI chat UX tweaks [@shamoon](https://github.com/shamoon) ([#14415](https://github.com/paperless-ngx/paperless-ngx/pull/14415))
|
|
||||||
- Enhancement: support markdown in AI chat [@shamoon](https://github.com/shamoon) ([#14409](https://github.com/paperless-ngx/paperless-ngx/pull/14409))
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Fix: truncate long version labels in dropdown [@shamoon](https://github.com/shamoon) ([#14578](https://github.com/paperless-ngx/paperless-ngx/pull/14578))
|
|
||||||
- Fixhancement: show better result for revoked / expired tasks [@shamoon](https://github.com/shamoon) ([#14566](https://github.com/paperless-ngx/paperless-ngx/pull/14566))
|
|
||||||
- Fix: move re-index after workflow runs on doc edit [@shamoon](https://github.com/shamoon) ([#14558](https://github.com/paperless-ngx/paperless-ngx/pull/14558))
|
|
||||||
- Fix: folder export with --delete fails on a stale manifest.json.tmp [@benpetty](https://github.com/benpetty) ([#14526](https://github.com/paperless-ngx/paperless-ngx/pull/14526))
|
|
||||||
- Fix: fix list type for api schema document object filters [@shamoon](https://github.com/shamoon) ([#14524](https://github.com/paperless-ngx/paperless-ngx/pull/14524))
|
|
||||||
- Fix: standardize some dropdown sizing [@shamoon](https://github.com/shamoon) ([#14510](https://github.com/paperless-ngx/paperless-ngx/pull/14510))
|
|
||||||
- Fix: prevent doc permissions queries on schema view [@shamoon](https://github.com/shamoon) ([#14503](https://github.com/paperless-ngx/paperless-ngx/pull/14503))
|
|
||||||
- Fix: dont defer tag assignment when barcode will not result in a split [@shamoon](https://github.com/shamoon) ([#14497](https://github.com/paperless-ngx/paperless-ngx/pull/14497))
|
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>6 changes</summary>
|
|
||||||
|
|
||||||
- Chore: bump some frontend deps for security fixes [@shamoon](https://github.com/shamoon) ([#14581](https://github.com/paperless-ngx/paperless-ngx/pull/14581))
|
|
||||||
- Chore: bump brotli, djangorestframework, filelock, msgpack, setuptools for security fixes [@stumpylog](https://github.com/stumpylog) ([#14575](https://github.com/paperless-ngx/paperless-ngx/pull/14575))
|
|
||||||
- Chore: bump django-allauth to 65.20 and oauthlib to 4.0 [@stumpylog](https://github.com/stumpylog) ([#14570](https://github.com/paperless-ngx/paperless-ngx/pull/14570))
|
|
||||||
- Chore(deps): Bump the uv group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14517](https://github.com/paperless-ngx/paperless-ngx/pull/14517))
|
|
||||||
- Chore(deps): Bump the uv group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14493](https://github.com/paperless-ngx/paperless-ngx/pull/14493))
|
|
||||||
- Chore: Drop ImageMagik as a dependency [@stumpylog](https://github.com/stumpylog) ([#14440](https://github.com/paperless-ngx/paperless-ngx/pull/14440))
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
### All App Changes
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>35 changes</summary>
|
|
||||||
|
|
||||||
- Chore: Consolidate test sample files [@stumpylog](https://github.com/stumpylog) ([#14430](https://github.com/paperless-ngx/paperless-ngx/pull/14430))
|
|
||||||
- Chore: bump some frontend deps for security fixes [@shamoon](https://github.com/shamoon) ([#14581](https://github.com/paperless-ngx/paperless-ngx/pull/14581))
|
|
||||||
- Enhancement: support custom fields in workflow title templates [@shamoon](https://github.com/shamoon) ([#14564](https://github.com/paperless-ngx/paperless-ngx/pull/14564))
|
|
||||||
- Enhancement: relative date custom field queries [@shamoon](https://github.com/shamoon) ([#14549](https://github.com/paperless-ngx/paperless-ngx/pull/14549))
|
|
||||||
- Fix: truncate long version labels in dropdown [@shamoon](https://github.com/shamoon) ([#14578](https://github.com/paperless-ngx/paperless-ngx/pull/14578))
|
|
||||||
- Chore: bump brotli, djangorestframework, filelock, msgpack, setuptools for security fixes [@stumpylog](https://github.com/stumpylog) ([#14575](https://github.com/paperless-ngx/paperless-ngx/pull/14575))
|
|
||||||
- Chore: bump django-allauth to 65.20 and oauthlib to 4.0 [@stumpylog](https://github.com/stumpylog) ([#14570](https://github.com/paperless-ngx/paperless-ngx/pull/14570))
|
|
||||||
- Fixhancement: show better result for revoked / expired tasks [@shamoon](https://github.com/shamoon) ([#14566](https://github.com/paperless-ngx/paperless-ngx/pull/14566))
|
|
||||||
- Fix: move re-index after workflow runs on doc edit [@shamoon](https://github.com/shamoon) ([#14558](https://github.com/paperless-ngx/paperless-ngx/pull/14558))
|
|
||||||
- Performance: reduce tasks badge load times [@JanKleine](https://github.com/JanKleine) ([#14552](https://github.com/paperless-ngx/paperless-ngx/pull/14552))
|
|
||||||
- Enhancement: skip local 2FA for trusted OIDC AMR [@shamoon](https://github.com/shamoon) ([#14403](https://github.com/paperless-ngx/paperless-ngx/pull/14403))
|
|
||||||
- Chore: consolidate direct permission grant lookups into one helper [@stumpylog](https://github.com/stumpylog) ([#14456](https://github.com/paperless-ngx/paperless-ngx/pull/14456))
|
|
||||||
- Chore: drop get\_groups\_with\_only\_permission [@stumpylog](https://github.com/stumpylog) ([#14454](https://github.com/paperless-ngx/paperless-ngx/pull/14454))
|
|
||||||
- Chore: Consolidate to a single way to set permission on an object [@stumpylog](https://github.com/stumpylog) ([#14453](https://github.com/paperless-ngx/paperless-ngx/pull/14453))
|
|
||||||
- Chore: Refactor bulk edit to remove PDF knowledge [@stumpylog](https://github.com/stumpylog) ([#14445](https://github.com/paperless-ngx/paperless-ngx/pull/14445))
|
|
||||||
- Fix: folder export with --delete fails on a stale manifest.json.tmp [@benpetty](https://github.com/benpetty) ([#14526](https://github.com/paperless-ngx/paperless-ngx/pull/14526))
|
|
||||||
- Chore(deps): Bump the uv group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14517](https://github.com/paperless-ngx/paperless-ngx/pull/14517))
|
|
||||||
- Fix: fix list type for api schema document object filters [@shamoon](https://github.com/shamoon) ([#14524](https://github.com/paperless-ngx/paperless-ngx/pull/14524))
|
|
||||||
- Chore(deps): Bump the uv group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14493](https://github.com/paperless-ngx/paperless-ngx/pull/14493))
|
|
||||||
- Chore: drop get\_objects\_for\_user\_owner\_aware [@stumpylog](https://github.com/stumpylog) ([#14457](https://github.com/paperless-ngx/paperless-ngx/pull/14457))
|
|
||||||
- Chore: update Angular components to 22.2.2 [@shamoon](https://github.com/shamoon) ([#14515](https://github.com/paperless-ngx/paperless-ngx/pull/14515))
|
|
||||||
- Chore: share one base class for the method-mapped permission classes [@stumpylog](https://github.com/stumpylog) ([#14458](https://github.com/paperless-ngx/paperless-ngx/pull/14458))
|
|
||||||
- Fix: standardize some dropdown sizing [@shamoon](https://github.com/shamoon) ([#14510](https://github.com/paperless-ngx/paperless-ngx/pull/14510))
|
|
||||||
- Chore: Drop ImageMagik as a dependency [@stumpylog](https://github.com/stumpylog) ([#14440](https://github.com/paperless-ngx/paperless-ngx/pull/14440))
|
|
||||||
- Feature: Generate PDF thumbnails via poppler utils [@stumpylog](https://github.com/stumpylog) ([#14420](https://github.com/paperless-ngx/paperless-ngx/pull/14420))
|
|
||||||
- Fix: prevent doc permissions queries on schema view [@shamoon](https://github.com/shamoon) ([#14503](https://github.com/paperless-ngx/paperless-ngx/pull/14503))
|
|
||||||
- Enhancement: add upload count to button, link to tray, reduce notifications [@shamoon](https://github.com/shamoon) ([#14501](https://github.com/paperless-ngx/paperless-ngx/pull/14501))
|
|
||||||
- Enhancement: notifications improvements [@shamoon](https://github.com/shamoon) ([#14500](https://github.com/paperless-ngx/paperless-ngx/pull/14500))
|
|
||||||
- Enhancement: doc details ux improvements [@shamoon](https://github.com/shamoon) ([#14485](https://github.com/paperless-ngx/paperless-ngx/pull/14485))
|
|
||||||
- Enhancement (QoL): allow independently toggling child + parent tags in WF tag fields [@shamoon](https://github.com/shamoon) ([#14400](https://github.com/paperless-ngx/paperless-ngx/pull/14400))
|
|
||||||
- Tweak: drop the user dropdown caret [@shamoon](https://github.com/shamoon) ([#14444](https://github.com/paperless-ngx/paperless-ngx/pull/14444))
|
|
||||||
- Enhancement: navbar upload button [@shamoon](https://github.com/shamoon) ([#14442](https://github.com/paperless-ngx/paperless-ngx/pull/14442))
|
|
||||||
- Enhancement: AI chat UX tweaks [@shamoon](https://github.com/shamoon) ([#14415](https://github.com/paperless-ngx/paperless-ngx/pull/14415))
|
|
||||||
- Enhancement: support markdown in AI chat [@shamoon](https://github.com/shamoon) ([#14409](https://github.com/paperless-ngx/paperless-ngx/pull/14409))
|
|
||||||
- Fix: dont defer tag assignment when barcode will not result in a split [@shamoon](https://github.com/shamoon) ([#14497](https://github.com/paperless-ngx/paperless-ngx/pull/14497))
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## paperless-ngx 3.3.4
|
## paperless-ngx 3.3.4
|
||||||
|
|
||||||
### All App Changes
|
### All App Changes
|
||||||
|
|||||||
@@ -803,17 +803,6 @@ system. See the corresponding
|
|||||||
|
|
||||||
Defaults to None
|
Defaults to None
|
||||||
|
|
||||||
#### [`PAPERLESS_SOCIAL_ACCOUNT_MFA_TRUSTED_AMR=<comma-separated-list>`](#PAPERLESS_SOCIAL_ACCOUNT_MFA_TRUSTED_AMR) {#PAPERLESS_SOCIAL_ACCOUNT_MFA_TRUSTED_AMR}
|
|
||||||
|
|
||||||
: A list of authentication method values which, if present in the `amr` claim of the ID token sent by an OpenID Connect provider, cause Paperless-ngx to skip its own two-factor authentication prompt for that login. This avoids users having to enter a second factor twice when the identity provider already performed multi-factor authentication. Logins with a username and password, or via a provider which does not send a matching `amr` value, still require the Paperless-ngx code.
|
|
||||||
|
|
||||||
Which values to trust depends entirely on your identity provider, e.g. Authelia sends `mfa` and Pocket ID sends `phr` for passkey logins (but `otp` for single-factor email codes, which should _not_ be trusted). Some providers, such as Keycloak, do not send an `amr` claim by default and need to be configured to do so. You can see what your provider sends from the Django admin as a superuser by checking `id_token` → `amr` in the "Extra data" of the social account.
|
|
||||||
|
|
||||||
!!! warning
|
|
||||||
Only list values that mean a second factor was actually used for the login.
|
|
||||||
|
|
||||||
Defaults to `mfa`, the standard value for multi-factor authentication ([RFC 8176](https://www.rfc-editor.org/rfc/rfc8176.html)).
|
|
||||||
|
|
||||||
#### [`PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS=<comma-separated-list>`](#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS}
|
#### [`PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS=<comma-separated-list>`](#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS}
|
||||||
|
|
||||||
: A list of group names that users who signup via social accounts will be added to upon signup. Groups listed here must already exist.
|
: A list of group names that users who signup via social accounts will be added to upon signup. Groups listed here must already exist.
|
||||||
|
|||||||
@@ -472,8 +472,6 @@ Users can enable two-factor authentication (2FA) for their accounts from the 'My
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
If users log in via an OpenID Connect provider which already enforces multi-factor authentication, see [`PAPERLESS_SOCIAL_ACCOUNT_MFA_TRUSTED_AMR`](configuration.md#PAPERLESS_SOCIAL_ACCOUNT_MFA_TRUSTED_AMR) to skip the Paperless-ngx prompt for those logins.
|
|
||||||
|
|
||||||
## Workflows
|
## Workflows
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
@@ -726,7 +724,6 @@ applied. You can use the following placeholders in the template with any trigger
|
|||||||
- `{{added_time}}`: added time in HH:MM format
|
- `{{added_time}}`: added time in HH:MM format
|
||||||
- `{{original_filename}}`: original file name without extension
|
- `{{original_filename}}`: original file name without extension
|
||||||
- `{{filename}}`: current file name without extension (for "added" workflows this may not be final yet, you can use `{{original_filename}}`)
|
- `{{filename}}`: current file name without extension (for "added" workflows this may not be final yet, you can use `{{original_filename}}`)
|
||||||
- `{{custom_fields}}`: Custom field names mapped to their type and value, as in [file name templates](advanced_usage.md#additional-variables). For example, `{{ custom_fields.Invoice.value }}` or `{{ custom_fields | get_cf_value("Invoice", "") }}` for a fallback when the field is missing. In title assignments, fields assigned by the same action are available.
|
|
||||||
- `{{doc_title}}`: current document title (cannot be used in title assignment)
|
- `{{doc_title}}`: current document title (cannot be used in title assignment)
|
||||||
|
|
||||||
The following placeholders are only available for "added" or "updated" triggers
|
The following placeholders are only available for "added" or "updated" triggers
|
||||||
|
|||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "paperless-ngx"
|
name = "paperless-ngx"
|
||||||
version = "3.4.0"
|
version = "3.3.4"
|
||||||
description = """\
|
description = """\
|
||||||
A community-supported supercharged document management system: scan, index and archive all your physical documents\
|
A community-supported supercharged document management system: scan, index and archive all your physical documents\
|
||||||
"""
|
"""
|
||||||
@@ -27,7 +27,7 @@ dependencies = [
|
|||||||
# 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.2.13",
|
"django~=5.2.13",
|
||||||
"django-allauth[mfa,socialaccount]~=65.20.0",
|
"django-allauth[mfa,socialaccount]~=65.16.0",
|
||||||
"django-auditlog~=3.4.1",
|
"django-auditlog~=3.4.1",
|
||||||
"django-cachalot~=2.9.0",
|
"django-cachalot~=2.9.0",
|
||||||
"django-compression-middleware~=0.5.0",
|
"django-compression-middleware~=0.5.0",
|
||||||
@@ -43,7 +43,7 @@ dependencies = [
|
|||||||
"drf-spectacular~=0.30",
|
"drf-spectacular~=0.30",
|
||||||
"drf-spectacular-sidecar>=2026.7.1,<2026.10",
|
"drf-spectacular-sidecar>=2026.7.1,<2026.10",
|
||||||
"drf-writable-nested~=0.7.1",
|
"drf-writable-nested~=0.7.1",
|
||||||
"filelock>=3.32,<4.3",
|
"filelock>=3.32,<4.1",
|
||||||
"flower>=2.0.1,<2.3",
|
"flower>=2.0.1,<2.3",
|
||||||
"gotenberg-client[httpx]~=1.0",
|
"gotenberg-client[httpx]~=1.0",
|
||||||
"httpx-oauth~=0.17",
|
"httpx-oauth~=0.17",
|
||||||
@@ -256,8 +256,8 @@ isort.force-single-line = true
|
|||||||
[tool.codespell]
|
[tool.codespell]
|
||||||
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish,NIN,nin,reprot"
|
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish,NIN,nin,reprot"
|
||||||
skip = """\
|
skip = """\
|
||||||
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless/tests/samples/mail/*,src/documents/tests/samples\
|
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples\
|
||||||
/*,src/paperless_testing/sample_files/*,*.po,*.json\
|
/mail/*,src/documents/tests/samples/*,*.po,*.json\
|
||||||
"""
|
"""
|
||||||
write-changes = true
|
write-changes = true
|
||||||
|
|
||||||
|
|||||||
@@ -134,9 +134,7 @@ def seed_database() -> None:
|
|||||||
originals.mkdir(parents=True)
|
originals.mkdir(parents=True)
|
||||||
thumbnails = Path(os.environ["PAPERLESS_MEDIA_ROOT"]) / "documents" / "thumbnails"
|
thumbnails = Path(os.environ["PAPERLESS_MEDIA_ROOT"]) / "documents" / "thumbnails"
|
||||||
thumbnails.mkdir(parents=True)
|
thumbnails.mkdir(parents=True)
|
||||||
sample_pdf = (
|
sample_pdf = SOURCE_ROOT / "documents" / "tests" / "samples" / "simple.pdf"
|
||||||
SOURCE_ROOT / "paperless_testing" / "sample_files" / "simple-digital.pdf"
|
|
||||||
)
|
|
||||||
for document in documents:
|
for document in documents:
|
||||||
shutil.copyfile(sample_pdf, originals / document.filename)
|
shutil.copyfile(sample_pdf, originals / document.filename)
|
||||||
shutil.copyfile(
|
shutil.copyfile(
|
||||||
|
|||||||
+18
-142
@@ -2048,7 +2048,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||||
<context context-type="linenumber">201,202</context>
|
<context context-type="linenumber">167,168</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.html</context>
|
||||||
@@ -4414,68 +4414,11 @@
|
|||||||
<context context-type="linenumber">96</context>
|
<context context-type="linenumber">96</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3625677668275605418" datatype="html">
|
|
||||||
<source>Date mode</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
|
||||||
<context context-type="linenumber">38</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="7572800523712857519" datatype="html">
|
|
||||||
<source>Fixed date</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
|
||||||
<context context-type="linenumber">39</context>
|
|
||||||
</context-group>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
|
||||||
<context context-type="linenumber">40</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="4108478337308251505" datatype="html">
|
|
||||||
<source>Fixed</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
|
||||||
<context context-type="linenumber">41,42</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="8283919074858117329" datatype="html">
|
|
||||||
<source>Relative date</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
|
||||||
<context context-type="linenumber">43</context>
|
|
||||||
</context-group>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
|
||||||
<context context-type="linenumber">44</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="2731996625798485199" datatype="html">
|
|
||||||
<source>Relative</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
|
||||||
<context context-type="linenumber">45,46</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="6533918067030990396" datatype="html">
|
|
||||||
<source>Amount</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
|
||||||
<context context-type="linenumber">51</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="8932917860978648376" datatype="html">
|
|
||||||
<source>Unit</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
|
||||||
<context context-type="linenumber">56,57</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="9138811584797805840" datatype="html">
|
<trans-unit id="9138811584797805840" datatype="html">
|
||||||
<source>Open date picker</source>
|
<source>Open date picker</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||||
<context context-type="linenumber">72</context>
|
<context context-type="linenumber">42</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/input/date/date.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/input/date/date.component.html</context>
|
||||||
@@ -4486,11 +4429,7 @@
|
|||||||
<source>Today</source>
|
<source>Today</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||||
<context context-type="linenumber">77,78</context>
|
<context context-type="linenumber">47,48</context>
|
||||||
</context-group>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts</context>
|
|
||||||
<context context-type="linenumber">329</context>
|
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context>
|
||||||
@@ -4521,7 +4460,7 @@
|
|||||||
<source>Close</source>
|
<source>Close</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||||
<context context-type="linenumber">78,79</context>
|
<context context-type="linenumber">48,49</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context>
|
||||||
@@ -4560,62 +4499,55 @@
|
|||||||
<source>True</source>
|
<source>True</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||||
<context context-type="linenumber">88,89</context>
|
<context context-type="linenumber">55,56</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||||
<context context-type="linenumber">141,142</context>
|
<context context-type="linenumber">107,108</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||||
<context context-type="linenumber">147,148</context>
|
<context context-type="linenumber">113,114</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3800326155195149498" datatype="html">
|
<trans-unit id="3800326155195149498" datatype="html">
|
||||||
<source>False</source>
|
<source>False</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||||
<context context-type="linenumber">89,90</context>
|
<context context-type="linenumber">56,57</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||||
<context context-type="linenumber">142,143</context>
|
<context context-type="linenumber">108,109</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||||
<context context-type="linenumber">148,149</context>
|
<context context-type="linenumber">114,115</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7551700625201096185" datatype="html">
|
<trans-unit id="7551700625201096185" datatype="html">
|
||||||
<source>Search docs...</source>
|
<source>Search docs...</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||||
<context context-type="linenumber">106</context>
|
<context context-type="linenumber">73</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||||
<context context-type="linenumber">164</context>
|
<context context-type="linenumber">130</context>
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="1179184907489210406" datatype="html">
|
|
||||||
<source>Operator</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
|
||||||
<context context-type="linenumber">119,120</context>
|
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2328549728463836983" datatype="html">
|
<trans-unit id="2328549728463836983" datatype="html">
|
||||||
<source>Remove query</source>
|
<source>Remove query</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||||
<context context-type="linenumber">189</context>
|
<context context-type="linenumber">154</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3184700926171002527" datatype="html">
|
<trans-unit id="3184700926171002527" datatype="html">
|
||||||
<source>Any</source>
|
<source>Any</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||||
<context context-type="linenumber">199,200</context>
|
<context context-type="linenumber">165,166</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.html</context>
|
||||||
@@ -4626,84 +4558,28 @@
|
|||||||
<source>Not</source>
|
<source>Not</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||||
<context context-type="linenumber">204,205</context>
|
<context context-type="linenumber">170,171</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6548676277933116532" datatype="html">
|
<trans-unit id="6548676277933116532" datatype="html">
|
||||||
<source>Add query</source>
|
<source>Add query</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||||
<context context-type="linenumber">223</context>
|
<context context-type="linenumber">189</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5599577087865387184" datatype="html">
|
<trans-unit id="5599577087865387184" datatype="html">
|
||||||
<source>Add expression</source>
|
<source>Add expression</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||||
<context context-type="linenumber">226</context>
|
<context context-type="linenumber">192</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7394978038852129121" datatype="html">
|
<trans-unit id="7394978038852129121" datatype="html">
|
||||||
<source>Remove expression</source>
|
<source>Remove expression</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
|
||||||
<context context-type="linenumber">230</context>
|
<context context-type="linenumber">196</context>
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="5620397708418210833" datatype="html">
|
|
||||||
<source>days ago</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts</context>
|
|
||||||
<context context-type="linenumber">330</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="2871318661796659216" datatype="html">
|
|
||||||
<source>weeks ago</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts</context>
|
|
||||||
<context context-type="linenumber">331</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="6041340836190906216" datatype="html">
|
|
||||||
<source>months ago</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts</context>
|
|
||||||
<context context-type="linenumber">332</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="963494111451627204" datatype="html">
|
|
||||||
<source>years ago</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts</context>
|
|
||||||
<context context-type="linenumber">333</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="5555905617885007774" datatype="html">
|
|
||||||
<source>days from now</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts</context>
|
|
||||||
<context context-type="linenumber">334</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="4298377021616940216" datatype="html">
|
|
||||||
<source>weeks from now</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts</context>
|
|
||||||
<context context-type="linenumber">335</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="2772874767134148305" datatype="html">
|
|
||||||
<source>months from now</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts</context>
|
|
||||||
<context context-type="linenumber">336</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="6363533917780225967" datatype="html">
|
|
||||||
<source>years from now</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts</context>
|
|
||||||
<context context-type="linenumber">337</context>
|
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6630533861307510614" datatype="html">
|
<trans-unit id="6630533861307510614" datatype="html">
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paperless-ngx-ui",
|
"name": "paperless-ngx-ui",
|
||||||
"version": "3.4.0",
|
"version": "3.3.4",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
|
|||||||
Generated
+35
-228
@@ -4,10 +4,6 @@ settings:
|
|||||||
autoInstallPeers: false
|
autoInstallPeers: false
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
overrides:
|
|
||||||
vite@>=8.3.0 <8.3.3: ^8.3.3
|
|
||||||
zod@<4.6.6: ^4.6.6
|
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
@@ -1654,9 +1650,6 @@ packages:
|
|||||||
'@oxc-project/types@0.150.0':
|
'@oxc-project/types@0.150.0':
|
||||||
resolution: {integrity: sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==}
|
resolution: {integrity: sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==}
|
||||||
|
|
||||||
'@oxc-project/types@0.155.0':
|
|
||||||
resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==}
|
|
||||||
|
|
||||||
'@parcel/watcher-android-arm64@2.6.0':
|
'@parcel/watcher-android-arm64@2.6.0':
|
||||||
resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==}
|
resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
@@ -1755,85 +1748,42 @@ packages:
|
|||||||
'@popperjs/core@2.11.8':
|
'@popperjs/core@2.11.8':
|
||||||
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
|
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
|
||||||
|
|
||||||
'@rolldown/binding-android-arm-eabi@1.2.15':
|
|
||||||
resolution: {integrity: sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==}
|
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
|
||||||
cpu: [arm]
|
|
||||||
os: [android]
|
|
||||||
|
|
||||||
'@rolldown/binding-android-arm-eabi@1.2.8':
|
'@rolldown/binding-android-arm-eabi@1.2.8':
|
||||||
resolution: {integrity: sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==}
|
resolution: {integrity: sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [android]
|
os: [android]
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.2.15':
|
|
||||||
resolution: {integrity: sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==}
|
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
|
||||||
cpu: [arm64]
|
|
||||||
os: [android]
|
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.2.8':
|
'@rolldown/binding-android-arm64@1.2.8':
|
||||||
resolution: {integrity: sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==}
|
resolution: {integrity: sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [android]
|
os: [android]
|
||||||
|
|
||||||
'@rolldown/binding-darwin-arm64@1.2.15':
|
|
||||||
resolution: {integrity: sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==}
|
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
|
||||||
cpu: [arm64]
|
|
||||||
os: [darwin]
|
|
||||||
|
|
||||||
'@rolldown/binding-darwin-arm64@1.2.8':
|
'@rolldown/binding-darwin-arm64@1.2.8':
|
||||||
resolution: {integrity: sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==}
|
resolution: {integrity: sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@rolldown/binding-darwin-x64@1.2.15':
|
|
||||||
resolution: {integrity: sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==}
|
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
|
||||||
cpu: [x64]
|
|
||||||
os: [darwin]
|
|
||||||
|
|
||||||
'@rolldown/binding-darwin-x64@1.2.8':
|
'@rolldown/binding-darwin-x64@1.2.8':
|
||||||
resolution: {integrity: sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==}
|
resolution: {integrity: sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@rolldown/binding-freebsd-x64@1.2.15':
|
|
||||||
resolution: {integrity: sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==}
|
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
|
||||||
cpu: [x64]
|
|
||||||
os: [freebsd]
|
|
||||||
|
|
||||||
'@rolldown/binding-freebsd-x64@1.2.8':
|
'@rolldown/binding-freebsd-x64@1.2.8':
|
||||||
resolution: {integrity: sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==}
|
resolution: {integrity: sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [freebsd]
|
os: [freebsd]
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm-gnueabihf@1.2.15':
|
|
||||||
resolution: {integrity: sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==}
|
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
|
||||||
cpu: [arm]
|
|
||||||
os: [linux]
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm-gnueabihf@1.2.8':
|
'@rolldown/binding-linux-arm-gnueabihf@1.2.8':
|
||||||
resolution: {integrity: sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==}
|
resolution: {integrity: sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm64-gnu@1.2.15':
|
|
||||||
resolution: {integrity: sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==}
|
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
|
||||||
cpu: [arm64]
|
|
||||||
os: [linux]
|
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm64-gnu@1.2.8':
|
'@rolldown/binding-linux-arm64-gnu@1.2.8':
|
||||||
resolution: {integrity: sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==}
|
resolution: {integrity: sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -1841,13 +1791,6 @@ packages:
|
|||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm64-musl@1.2.15':
|
|
||||||
resolution: {integrity: sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==}
|
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
|
||||||
cpu: [arm64]
|
|
||||||
os: [linux]
|
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm64-musl@1.2.8':
|
'@rolldown/binding-linux-arm64-musl@1.2.8':
|
||||||
resolution: {integrity: sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==}
|
resolution: {integrity: sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -1855,13 +1798,6 @@ packages:
|
|||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@rolldown/binding-linux-ppc64-gnu@1.2.15':
|
|
||||||
resolution: {integrity: sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==}
|
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
|
||||||
cpu: [ppc64]
|
|
||||||
os: [linux]
|
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-ppc64-gnu@1.2.8':
|
'@rolldown/binding-linux-ppc64-gnu@1.2.8':
|
||||||
resolution: {integrity: sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==}
|
resolution: {integrity: sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -1869,13 +1805,6 @@ packages:
|
|||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@rolldown/binding-linux-s390x-gnu@1.2.15':
|
|
||||||
resolution: {integrity: sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==}
|
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
|
||||||
cpu: [s390x]
|
|
||||||
os: [linux]
|
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-s390x-gnu@1.2.8':
|
'@rolldown/binding-linux-s390x-gnu@1.2.8':
|
||||||
resolution: {integrity: sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==}
|
resolution: {integrity: sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -1883,13 +1812,6 @@ packages:
|
|||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@rolldown/binding-linux-x64-gnu@1.2.15':
|
|
||||||
resolution: {integrity: sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==}
|
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
|
||||||
cpu: [x64]
|
|
||||||
os: [linux]
|
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-x64-gnu@1.2.8':
|
'@rolldown/binding-linux-x64-gnu@1.2.8':
|
||||||
resolution: {integrity: sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==}
|
resolution: {integrity: sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -1897,13 +1819,6 @@ packages:
|
|||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@rolldown/binding-linux-x64-musl@1.2.15':
|
|
||||||
resolution: {integrity: sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==}
|
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
|
||||||
cpu: [x64]
|
|
||||||
os: [linux]
|
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-x64-musl@1.2.8':
|
'@rolldown/binding-linux-x64-musl@1.2.8':
|
||||||
resolution: {integrity: sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==}
|
resolution: {integrity: sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -1911,36 +1826,18 @@ packages:
|
|||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@rolldown/binding-openharmony-arm64@1.2.15':
|
|
||||||
resolution: {integrity: sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==}
|
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
|
||||||
cpu: [arm64]
|
|
||||||
os: [openharmony]
|
|
||||||
|
|
||||||
'@rolldown/binding-openharmony-arm64@1.2.8':
|
'@rolldown/binding-openharmony-arm64@1.2.8':
|
||||||
resolution: {integrity: sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==}
|
resolution: {integrity: sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [openharmony]
|
os: [openharmony]
|
||||||
|
|
||||||
'@rolldown/binding-win32-arm64-msvc@1.2.15':
|
|
||||||
resolution: {integrity: sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==}
|
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
|
||||||
cpu: [arm64]
|
|
||||||
os: [win32]
|
|
||||||
|
|
||||||
'@rolldown/binding-win32-arm64-msvc@1.2.8':
|
'@rolldown/binding-win32-arm64-msvc@1.2.8':
|
||||||
resolution: {integrity: sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==}
|
resolution: {integrity: sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@rolldown/binding-win32-x64-msvc@1.2.15':
|
|
||||||
resolution: {integrity: sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==}
|
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
|
||||||
cpu: [x64]
|
|
||||||
os: [win32]
|
|
||||||
|
|
||||||
'@rolldown/binding-win32-x64-msvc@1.2.8':
|
'@rolldown/binding-win32-x64-msvc@1.2.8':
|
||||||
resolution: {integrity: sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==}
|
resolution: {integrity: sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -2235,7 +2132,7 @@ packages:
|
|||||||
resolution: {integrity: sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==}
|
resolution: {integrity: sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==}
|
||||||
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
vite: ^8.3.3
|
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||||
|
|
||||||
acorn-jsx@5.3.2:
|
acorn-jsx@5.3.2:
|
||||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||||
@@ -2364,8 +2261,8 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@popperjs/core': ^2.11.8
|
'@popperjs/core': ^2.11.8
|
||||||
|
|
||||||
brace-expansion@2.1.7:
|
brace-expansion@2.1.4:
|
||||||
resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==}
|
resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
|
||||||
|
|
||||||
brace-expansion@5.0.12:
|
brace-expansion@5.0.12:
|
||||||
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
|
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
|
||||||
@@ -2700,8 +2597,8 @@ packages:
|
|||||||
fast-string-width@3.0.2:
|
fast-string-width@3.0.2:
|
||||||
resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==}
|
resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==}
|
||||||
|
|
||||||
fast-uri@3.1.9:
|
fast-uri@3.1.8:
|
||||||
resolution: {integrity: sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==}
|
resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==}
|
||||||
|
|
||||||
fast-wrap-ansi@0.2.2:
|
fast-wrap-ansi@0.2.2:
|
||||||
resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==}
|
resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==}
|
||||||
@@ -3132,8 +3029,8 @@ packages:
|
|||||||
js-tokens@4.0.0:
|
js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
js-yaml@3.15.2:
|
js-yaml@3.15.1:
|
||||||
resolution: {integrity: sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==}
|
resolution: {integrity: sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
jsdom@26.1.0:
|
jsdom@26.1.0:
|
||||||
@@ -3383,11 +3280,6 @@ packages:
|
|||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
nanoid@3.3.20:
|
|
||||||
resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==}
|
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
napi-postinstall@0.3.4:
|
napi-postinstall@0.3.4:
|
||||||
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
|
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
|
||||||
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
||||||
@@ -3618,10 +3510,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
|
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
postcss@8.5.31:
|
|
||||||
resolution: {integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==}
|
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
|
||||||
|
|
||||||
prelude-ls@1.2.1:
|
prelude-ls@1.2.1:
|
||||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -3713,11 +3601,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
|
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
rolldown@1.2.15:
|
|
||||||
resolution: {integrity: sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==}
|
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
rolldown@1.2.8:
|
rolldown@1.2.8:
|
||||||
resolution: {integrity: sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==}
|
resolution: {integrity: sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -3890,8 +3773,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-aBY19bn/XA+hKOKX0qL7Z0UfdBsbQvc9hn993U8ALUjzxCvDcuZqLoRXjGJrUARWAlwMnsRVD9sw2AFNOYvalA==}
|
resolution: {integrity: sha512-aBY19bn/XA+hKOKX0qL7Z0UfdBsbQvc9hn993U8ALUjzxCvDcuZqLoRXjGJrUARWAlwMnsRVD9sw2AFNOYvalA==}
|
||||||
engines: {node: '>=22'}
|
engines: {node: '>=22'}
|
||||||
|
|
||||||
source-map-js@1.2.2:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
source-map-support@0.5.21:
|
source-map-support@0.5.21:
|
||||||
@@ -4122,13 +4005,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==}
|
resolution: {integrity: sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==}
|
||||||
engines: {node: '>=18.12.0'}
|
engines: {node: '>=18.12.0'}
|
||||||
|
|
||||||
vite@8.3.5:
|
vite@8.3.0:
|
||||||
resolution: {integrity: sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==}
|
resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@types/node': ^20.19.0 || >=22.12.0
|
'@types/node': ^20.19.0 || >=22.12.0
|
||||||
'@vitejs/devtools': ^0.7.1 || ^0.8.0
|
'@vitejs/devtools': ^0.7.1
|
||||||
esbuild: ^0.27.0 || ^0.28.0
|
esbuild: ^0.27.0 || ^0.28.0
|
||||||
jiti: '>=1.21.0'
|
jiti: '>=1.21.0'
|
||||||
less: ^4.0.0
|
less: ^4.0.0
|
||||||
@@ -4285,8 +4168,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==}
|
resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
zod@4.6.7:
|
zod@4.6.5:
|
||||||
resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==}
|
resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==}
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
@@ -4486,7 +4369,7 @@ snapshots:
|
|||||||
'@babel/core': 8.0.5
|
'@babel/core': 8.0.5
|
||||||
'@inquirer/confirm': 6.3.2(@types/node@26.6.2)
|
'@inquirer/confirm': 6.3.2(@types/node@26.6.2)
|
||||||
'@parcel/watcher': 2.6.0
|
'@parcel/watcher': 2.6.0
|
||||||
'@vitejs/plugin-basic-ssl': 2.3.0(vite@8.3.5(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1))
|
'@vitejs/plugin-basic-ssl': 2.3.0(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1))
|
||||||
beasties: 0.5.4
|
beasties: 0.5.4
|
||||||
browserslist: 4.29.1
|
browserslist: 4.29.1
|
||||||
chokidar: 5.0.0
|
chokidar: 5.0.0
|
||||||
@@ -4508,7 +4391,7 @@ snapshots:
|
|||||||
tinyglobby: 0.2.17
|
tinyglobby: 0.2.17
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
typescript: 6.0.3
|
typescript: 6.0.3
|
||||||
vite: 8.3.5(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)
|
vite: 8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)
|
||||||
xxhash-wasm: 1.1.0
|
xxhash-wasm: 1.1.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@angular/core': 22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)
|
'@angular/core': 22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)
|
||||||
@@ -4554,7 +4437,7 @@ snapshots:
|
|||||||
parse5-html-rewriting-stream: 8.0.1
|
parse5-html-rewriting-stream: 8.0.1
|
||||||
semver: 7.8.5
|
semver: 7.8.5
|
||||||
yargs: 18.1.0
|
yargs: 18.1.0
|
||||||
zod: 4.6.7
|
zod: 4.6.5
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@types/node'
|
- '@types/node'
|
||||||
- chokidar
|
- chokidar
|
||||||
@@ -4598,7 +4481,7 @@ snapshots:
|
|||||||
'@standard-schema/spec': 1.1.0
|
'@standard-schema/spec': 1.1.0
|
||||||
rxjs: 7.8.2
|
rxjs: 7.8.2
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
zod: 4.6.7
|
zod: 4.6.5
|
||||||
|
|
||||||
'@angular/localize@22.2.2(@angular/compiler-cli@22.2.2(@angular/compiler@22.2.2)(typescript@6.0.3))(@angular/compiler@22.2.2)':
|
'@angular/localize@22.2.2(@angular/compiler-cli@22.2.2(@angular/compiler@22.2.2)(typescript@6.0.3))(@angular/compiler@22.2.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -5235,7 +5118,7 @@ snapshots:
|
|||||||
camelcase: 5.3.1
|
camelcase: 5.3.1
|
||||||
find-up: 4.1.0
|
find-up: 4.1.0
|
||||||
get-package-type: 0.1.0
|
get-package-type: 0.1.0
|
||||||
js-yaml: 3.15.2
|
js-yaml: 3.15.1
|
||||||
resolve-from: 5.0.0
|
resolve-from: 5.0.0
|
||||||
|
|
||||||
'@istanbuljs/schema@0.1.6': {}
|
'@istanbuljs/schema@0.1.6': {}
|
||||||
@@ -5529,12 +5412,12 @@ snapshots:
|
|||||||
|
|
||||||
'@modelcontextprotocol/core@2.3.1':
|
'@modelcontextprotocol/core@2.3.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
zod: 4.6.7
|
zod: 4.6.5
|
||||||
|
|
||||||
'@modelcontextprotocol/server@2.3.1':
|
'@modelcontextprotocol/server@2.3.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@modelcontextprotocol/core': 2.3.1
|
'@modelcontextprotocol/core': 2.3.1
|
||||||
zod: 4.6.7
|
zod: 4.6.5
|
||||||
|
|
||||||
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4':
|
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4':
|
||||||
optional: true
|
optional: true
|
||||||
@@ -5769,8 +5652,6 @@ snapshots:
|
|||||||
|
|
||||||
'@oxc-project/types@0.150.0': {}
|
'@oxc-project/types@0.150.0': {}
|
||||||
|
|
||||||
'@oxc-project/types@0.155.0': {}
|
|
||||||
|
|
||||||
'@parcel/watcher-android-arm64@2.6.0':
|
'@parcel/watcher-android-arm64@2.6.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -5838,93 +5719,48 @@ snapshots:
|
|||||||
|
|
||||||
'@popperjs/core@2.11.8': {}
|
'@popperjs/core@2.11.8': {}
|
||||||
|
|
||||||
'@rolldown/binding-android-arm-eabi@1.2.15':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@rolldown/binding-android-arm-eabi@1.2.8':
|
'@rolldown/binding-android-arm-eabi@1.2.8':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.2.15':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.2.8':
|
'@rolldown/binding-android-arm64@1.2.8':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-darwin-arm64@1.2.15':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@rolldown/binding-darwin-arm64@1.2.8':
|
'@rolldown/binding-darwin-arm64@1.2.8':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-darwin-x64@1.2.15':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@rolldown/binding-darwin-x64@1.2.8':
|
'@rolldown/binding-darwin-x64@1.2.8':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-freebsd-x64@1.2.15':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@rolldown/binding-freebsd-x64@1.2.8':
|
'@rolldown/binding-freebsd-x64@1.2.8':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm-gnueabihf@1.2.15':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm-gnueabihf@1.2.8':
|
'@rolldown/binding-linux-arm-gnueabihf@1.2.8':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm64-gnu@1.2.15':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm64-gnu@1.2.8':
|
'@rolldown/binding-linux-arm64-gnu@1.2.8':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm64-musl@1.2.15':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm64-musl@1.2.8':
|
'@rolldown/binding-linux-arm64-musl@1.2.8':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-linux-ppc64-gnu@1.2.15':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-ppc64-gnu@1.2.8':
|
'@rolldown/binding-linux-ppc64-gnu@1.2.8':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-linux-s390x-gnu@1.2.15':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-s390x-gnu@1.2.8':
|
'@rolldown/binding-linux-s390x-gnu@1.2.8':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-linux-x64-gnu@1.2.15':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-x64-gnu@1.2.8':
|
'@rolldown/binding-linux-x64-gnu@1.2.8':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-linux-x64-musl@1.2.15':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@rolldown/binding-linux-x64-musl@1.2.8':
|
'@rolldown/binding-linux-x64-musl@1.2.8':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-openharmony-arm64@1.2.15':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@rolldown/binding-openharmony-arm64@1.2.8':
|
'@rolldown/binding-openharmony-arm64@1.2.8':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-win32-arm64-msvc@1.2.15':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@rolldown/binding-win32-arm64-msvc@1.2.8':
|
'@rolldown/binding-win32-arm64-msvc@1.2.8':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-win32-x64-msvc@1.2.15':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@rolldown/binding-win32-x64-msvc@1.2.8':
|
'@rolldown/binding-win32-x64-msvc@1.2.8':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -6215,9 +6051,9 @@ snapshots:
|
|||||||
'@unrs/resolver-binding-win32-x64-msvc@1.12.2':
|
'@unrs/resolver-binding-win32-x64-msvc@1.12.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@vitejs/plugin-basic-ssl@2.3.0(vite@8.3.5(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1))':
|
'@vitejs/plugin-basic-ssl@2.3.0(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
vite: 8.3.5(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)
|
vite: 8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)
|
||||||
|
|
||||||
acorn-jsx@5.3.2(acorn@8.18.0):
|
acorn-jsx@5.3.2(acorn@8.18.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -6247,7 +6083,7 @@ snapshots:
|
|||||||
ajv@8.20.0:
|
ajv@8.20.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-deep-equal: 3.1.3
|
fast-deep-equal: 3.1.3
|
||||||
fast-uri: 3.1.9
|
fast-uri: 3.1.8
|
||||||
json-schema-traverse: 1.0.0
|
json-schema-traverse: 1.0.0
|
||||||
require-from-string: 2.0.2
|
require-from-string: 2.0.2
|
||||||
|
|
||||||
@@ -6362,7 +6198,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@popperjs/core': 2.11.8
|
'@popperjs/core': 2.11.8
|
||||||
|
|
||||||
brace-expansion@2.1.7:
|
brace-expansion@2.1.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: 1.0.2
|
balanced-match: 1.0.2
|
||||||
|
|
||||||
@@ -6707,7 +6543,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
fast-string-truncated-width: 3.0.3
|
fast-string-truncated-width: 3.0.3
|
||||||
|
|
||||||
fast-uri@3.1.9: {}
|
fast-uri@3.1.8: {}
|
||||||
|
|
||||||
fast-wrap-ansi@0.2.2:
|
fast-wrap-ansi@0.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -7357,7 +7193,7 @@ snapshots:
|
|||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
js-yaml@3.15.2:
|
js-yaml@3.15.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
argparse: 1.0.10
|
argparse: 1.0.10
|
||||||
esprima: 4.0.1
|
esprima: 4.0.1
|
||||||
@@ -7557,7 +7393,7 @@ snapshots:
|
|||||||
|
|
||||||
minimatch@9.0.9:
|
minimatch@9.0.9:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 2.1.7
|
brace-expansion: 2.1.4
|
||||||
|
|
||||||
minimist@1.2.8: {}
|
minimist@1.2.8: {}
|
||||||
|
|
||||||
@@ -7592,8 +7428,6 @@ snapshots:
|
|||||||
|
|
||||||
nanoid@3.3.18: {}
|
nanoid@3.3.18: {}
|
||||||
|
|
||||||
nanoid@3.3.20: {}
|
|
||||||
|
|
||||||
napi-postinstall@0.3.4: {}
|
napi-postinstall@0.3.4: {}
|
||||||
|
|
||||||
natural-compare@1.4.0: {}
|
natural-compare@1.4.0: {}
|
||||||
@@ -7840,13 +7674,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
nanoid: 3.3.18
|
nanoid: 3.3.18
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.2
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
postcss@8.5.31:
|
|
||||||
dependencies:
|
|
||||||
nanoid: 3.3.20
|
|
||||||
picocolors: 1.1.1
|
|
||||||
source-map-js: 1.2.2
|
|
||||||
|
|
||||||
prelude-ls@1.2.1: {}
|
prelude-ls@1.2.1: {}
|
||||||
|
|
||||||
@@ -7914,27 +7742,6 @@ snapshots:
|
|||||||
onetime: 7.0.0
|
onetime: 7.0.0
|
||||||
signal-exit: 4.1.0
|
signal-exit: 4.1.0
|
||||||
|
|
||||||
rolldown@1.2.15:
|
|
||||||
dependencies:
|
|
||||||
'@oxc-project/types': 0.155.0
|
|
||||||
'@rolldown/pluginutils': 1.0.1
|
|
||||||
optionalDependencies:
|
|
||||||
'@rolldown/binding-android-arm-eabi': 1.2.15
|
|
||||||
'@rolldown/binding-android-arm64': 1.2.15
|
|
||||||
'@rolldown/binding-darwin-arm64': 1.2.15
|
|
||||||
'@rolldown/binding-darwin-x64': 1.2.15
|
|
||||||
'@rolldown/binding-freebsd-x64': 1.2.15
|
|
||||||
'@rolldown/binding-linux-arm-gnueabihf': 1.2.15
|
|
||||||
'@rolldown/binding-linux-arm64-gnu': 1.2.15
|
|
||||||
'@rolldown/binding-linux-arm64-musl': 1.2.15
|
|
||||||
'@rolldown/binding-linux-ppc64-gnu': 1.2.15
|
|
||||||
'@rolldown/binding-linux-s390x-gnu': 1.2.15
|
|
||||||
'@rolldown/binding-linux-x64-gnu': 1.2.15
|
|
||||||
'@rolldown/binding-linux-x64-musl': 1.2.15
|
|
||||||
'@rolldown/binding-openharmony-arm64': 1.2.15
|
|
||||||
'@rolldown/binding-win32-arm64-msvc': 1.2.15
|
|
||||||
'@rolldown/binding-win32-x64-msvc': 1.2.15
|
|
||||||
|
|
||||||
rolldown@1.2.8:
|
rolldown@1.2.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@oxc-project/types': 0.149.0
|
'@oxc-project/types': 0.149.0
|
||||||
@@ -8055,7 +7862,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
chokidar: 5.0.0
|
chokidar: 5.0.0
|
||||||
immutable: 5.1.9
|
immutable: 5.1.9
|
||||||
source-map-js: 1.2.2
|
source-map-js: 1.2.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@parcel/watcher': 2.6.0
|
'@parcel/watcher': 2.6.0
|
||||||
|
|
||||||
@@ -8084,7 +7891,7 @@ snapshots:
|
|||||||
ansi-styles: 6.2.3
|
ansi-styles: 6.2.3
|
||||||
is-fullwidth-code-point: 5.1.0
|
is-fullwidth-code-point: 5.1.0
|
||||||
|
|
||||||
source-map-js@1.2.2: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
source-map-support@0.5.21:
|
source-map-support@0.5.21:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -8311,12 +8118,12 @@ snapshots:
|
|||||||
|
|
||||||
verkit@0.3.2: {}
|
verkit@0.3.2: {}
|
||||||
|
|
||||||
vite@8.3.5(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1):
|
vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
lightningcss: 1.33.0
|
lightningcss: 1.33.0
|
||||||
picomatch: 4.0.7
|
picomatch: 4.0.7
|
||||||
postcss: 8.5.31
|
postcss: 8.5.28
|
||||||
rolldown: 1.2.15
|
rolldown: 1.2.8
|
||||||
tinyglobby: 0.2.17
|
tinyglobby: 0.2.17
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 26.6.2
|
'@types/node': 26.6.2
|
||||||
@@ -8434,4 +8241,4 @@ snapshots:
|
|||||||
|
|
||||||
yoctocolors@2.2.0: {}
|
yoctocolors@2.2.0: {}
|
||||||
|
|
||||||
zod@4.6.7: {}
|
zod@4.6.5: {}
|
||||||
@@ -24,6 +24,3 @@ onlyBuiltDependencies:
|
|||||||
ignoredBuiltDependencies:
|
ignoredBuiltDependencies:
|
||||||
- normalize-diacritics
|
- normalize-diacritics
|
||||||
- unrs-resolver
|
- unrs-resolver
|
||||||
overrides:
|
|
||||||
"vite@>=8.3.0 <8.3.3": "^8.3.3"
|
|
||||||
"zod@<4.6.6": "^4.6.6"
|
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
PaperlessTaskTriggerSource,
|
PaperlessTaskTriggerSource,
|
||||||
PaperlessTaskType,
|
PaperlessTaskType,
|
||||||
} from 'src/app/data/paperless-task'
|
} from 'src/app/data/paperless-task'
|
||||||
|
import { Results } from 'src/app/data/results'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
@@ -152,6 +153,11 @@ const tasks: PaperlessTask[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const paginatedTasks: Results<PaperlessTask> = {
|
||||||
|
count: tasks.length,
|
||||||
|
results: tasks,
|
||||||
|
}
|
||||||
|
|
||||||
const sectionCountResponse = {
|
const sectionCountResponse = {
|
||||||
all: 7,
|
all: 7,
|
||||||
needs_attention: 2,
|
needs_attention: 2,
|
||||||
@@ -198,7 +204,7 @@ describe('TasksComponent', () => {
|
|||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
tasksService = TestBed.inject(TasksService)
|
tasksService = TestBed.inject(TasksService)
|
||||||
reloadSpy = jest.spyOn(tasksService, 'reload').mockImplementation(() => {})
|
reloadSpy = jest.spyOn(tasksService, 'reload')
|
||||||
httpTestingController = TestBed.inject(HttpTestingController)
|
httpTestingController = TestBed.inject(HttpTestingController)
|
||||||
modalService = TestBed.inject(NgbModal)
|
modalService = TestBed.inject(NgbModal)
|
||||||
router = TestBed.inject(Router)
|
router = TestBed.inject(Router)
|
||||||
@@ -208,6 +214,15 @@ describe('TasksComponent', () => {
|
|||||||
jest.useFakeTimers()
|
jest.useFakeTimers()
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
httpTestingController
|
||||||
|
.expectOne(
|
||||||
|
(req) =>
|
||||||
|
req.url === `${environment.apiBaseUrl}tasks/` &&
|
||||||
|
req.params.get('acknowledged') === 'false' &&
|
||||||
|
req.params.get('page_size') === '1000'
|
||||||
|
)
|
||||||
|
.flush(paginatedTasks)
|
||||||
|
|
||||||
httpTestingController
|
httpTestingController
|
||||||
.expectOne(
|
.expectOne(
|
||||||
(req) =>
|
(req) =>
|
||||||
|
|||||||
@@ -344,11 +344,11 @@
|
|||||||
ngbPopover="Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="list-task"></i-bs><span class="nav-link-label"><ng-container i18n>Tasks</ng-container></span>
|
<i-bs class="me-2" name="list-task"></i-bs><span class="nav-link-label"><ng-container i18n>Tasks</ng-container></span>
|
||||||
@if (tasksService.needsAttentionTasksCount > 0 && !slimSidebarEnabled) {
|
@if (tasksService.needsAttentionTasks.length > 0 && !slimSidebarEnabled) {
|
||||||
<span class="badge bg-danger ms-2 d-inline flex-shrink-0">{{tasksService.needsAttentionTasksCount}}</span>
|
<span class="badge bg-danger ms-2 d-inline flex-shrink-0">{{tasksService.needsAttentionTasks.length}}</span>
|
||||||
}
|
}
|
||||||
@if (tasksService.needsAttentionTasksCount > 0 && slimSidebarEnabled) {
|
@if (tasksService.needsAttentionTasks.length > 0 && slimSidebarEnabled) {
|
||||||
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.needsAttentionTasksCount}}</span>
|
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.needsAttentionTasks.length}}</span>
|
||||||
}
|
}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -588,8 +588,8 @@ describe('AppFrameComponent', () => {
|
|||||||
|
|
||||||
it('should show tasks badge for needs-attention tasks', () => {
|
it('should show tasks badge for needs-attention tasks', () => {
|
||||||
jest
|
jest
|
||||||
.spyOn(tasksService, 'needsAttentionTasksCount', 'get')
|
.spyOn(tasksService, 'needsAttentionTasks', 'get')
|
||||||
.mockReturnValue(2)
|
.mockReturnValue([{} as any, {} as any])
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
|||||||
+7
@@ -1,3 +1,10 @@
|
|||||||
.custom-fields-dropdown {
|
.custom-fields-dropdown {
|
||||||
min-width: 300px;
|
min-width: 300px;
|
||||||
|
|
||||||
|
// correct position on mobile
|
||||||
|
@media (max-width: 575.98px) {
|
||||||
|
&.show {
|
||||||
|
margin-left: -245px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+18
-52
@@ -33,54 +33,21 @@
|
|||||||
|
|
||||||
<ng-template #comparisonValueTemplate let-atom="atom">
|
<ng-template #comparisonValueTemplate let-atom="atom">
|
||||||
@if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Date) {
|
@if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Date) {
|
||||||
@let relativeDate = getRelativeDate(atom);
|
<input class="form-control" placeholder="yyyy-mm-dd"
|
||||||
<div class="d-flex gap-2 w-100 ms-0 mt-1">
|
[(ngModel)]="atom.value"
|
||||||
<fieldset class="btn-group btn-group-sm flex-shrink-0 date-mode" aria-label="Date mode" i18n-aria-label>
|
[ngModelOptions]="{standalone: true}"
|
||||||
<input type="radio" class="btn-check" [id]="atom.id + '_fixed'" [name]="atom.id + '_dateMode'" [checked]="!relativeDate" (change)="toggleRelativeDate(atom)" [disabled]="disabled" aria-label="Fixed date" i18n-aria-label>
|
ngbDatepicker
|
||||||
<label class="btn btn-outline-secondary text-nowrap" [for]="atom.id + '_fixed'" title="Fixed date" i18n-title>
|
#d="ngbDatepicker"
|
||||||
<i-bs name="calendar"></i-bs><span class="d-none d-md-inline ms-2" i18n>Fixed</span>
|
[footerTemplate]="datePickerFooterTemplate" />
|
||||||
</label>
|
<button class="btn btn-sm btn-outline-secondary rounded-end" (click)="d.toggle()" type="button" aria-label="Open date picker" i18n-aria-label>
|
||||||
<input type="radio" class="btn-check" [id]="atom.id + '_relative'" [name]="atom.id + '_dateMode'" [checked]="!!relativeDate" (change)="toggleRelativeDate(atom)" [disabled]="disabled" aria-label="Relative date" i18n-aria-label>
|
<i-bs name="calendar-event"></i-bs>
|
||||||
<label class="btn btn-outline-secondary text-nowrap" [for]="atom.id + '_relative'" title="Relative date" i18n-title>
|
</button>
|
||||||
<i-bs name="clock-history"></i-bs><span class="d-none d-md-inline ms-2" i18n>Relative</span>
|
<ng-template #datePickerFooterTemplate>
|
||||||
</label>
|
<div class="btn-group-xs border-top p-2 d-flex">
|
||||||
</fieldset>
|
<button type="button" class="btn btn-primary" (click)="atom.value = today; d.close()" i18n>Today</button>
|
||||||
<div class="input-group input-group-sm flex-grow-1">
|
<button type="button" class="btn btn-secondary ms-auto" (click)="d.close()" i18n>Close</button>
|
||||||
@if (relativeDate) {
|
|
||||||
@if (relativeDate.unit.includes('{n}')) {
|
|
||||||
<input class="form-control w-25 flex-grow-0" type="number" min="0" aria-label="Amount" i18n-aria-label
|
|
||||||
[ngModel]="relativeDate.amount"
|
|
||||||
(ngModelChange)="updateRelativeDate(atom, { amount: $event })"
|
|
||||||
[ngModelOptions]="{standalone: true}" />
|
|
||||||
}
|
|
||||||
<label class="visually-hidden" [for]="atom.id + '_unit'" i18n>Unit</label>
|
|
||||||
<select class="form-select" [id]="atom.id + '_unit'"
|
|
||||||
[ngModel]="relativeDate.unit"
|
|
||||||
(ngModelChange)="updateRelativeDate(atom, { unit: $event })"
|
|
||||||
[ngModelOptions]="{standalone: true}">
|
|
||||||
@for (unit of relativeDateUnits; track unit.id) {
|
|
||||||
<option [ngValue]="unit.id">{{unit.label}}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
} @else {
|
|
||||||
<input class="form-control" placeholder="yyyy-mm-dd"
|
|
||||||
[(ngModel)]="atom.value"
|
|
||||||
[ngModelOptions]="{standalone: true}"
|
|
||||||
ngbDatepicker
|
|
||||||
#d="ngbDatepicker"
|
|
||||||
[footerTemplate]="datePickerFooterTemplate" />
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" (click)="d.toggle()" type="button" aria-label="Open date picker" i18n-aria-label>
|
|
||||||
<i-bs name="calendar-event"></i-bs>
|
|
||||||
</button>
|
|
||||||
<ng-template #datePickerFooterTemplate>
|
|
||||||
<div class="btn-group-xs border-top p-2 d-flex">
|
|
||||||
<button type="button" class="btn btn-primary" (click)="atom.value = today; d.close()" i18n>Today</button>
|
|
||||||
<button type="button" class="btn btn-secondary ms-auto" (click)="d.close()" i18n>Close</button>
|
|
||||||
</div>
|
|
||||||
</ng-template>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ng-template>
|
||||||
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Float || getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Integer) {
|
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Float || getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Integer) {
|
||||||
<input class="w-25 form-control rounded-end" type="number" [(ngModel)]="atom.value" [ngModelOptions]="{standalone: true}" [disabled]="disabled">
|
<input class="w-25 form-control rounded-end" type="number" [(ngModel)]="atom.value" [ngModelOptions]="{standalone: true}" [disabled]="disabled">
|
||||||
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Boolean) {
|
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Boolean) {
|
||||||
@@ -116,7 +83,6 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<ng-template #queryAtom let-atom="atom">
|
<ng-template #queryAtom let-atom="atom">
|
||||||
<label class="visually-hidden" [for]="atom.id + '_operator'" i18n>Operator</label>
|
|
||||||
<div class="input-group input-group-sm">
|
<div class="input-group input-group-sm">
|
||||||
<ng-select #fieldSelects
|
<ng-select #fieldSelects
|
||||||
class="paperless-input-select"
|
class="paperless-input-select"
|
||||||
@@ -130,7 +96,7 @@
|
|||||||
[appendTo]="selectAppendTo"
|
[appendTo]="selectAppendTo"
|
||||||
(mousedown)="$event.stopImmediatePropagation()"
|
(mousedown)="$event.stopImmediatePropagation()"
|
||||||
></ng-select>
|
></ng-select>
|
||||||
<select class="w-25 form-select" [id]="atom.id + '_operator'" [class.rounded-end]="hasDateValueRow(atom)" [(ngModel)]="atom.operator" [ngModelOptions]="{standalone: true}" [disabled]="disabled">
|
<select class="w-25 form-select" [(ngModel)]="atom.operator" [ngModelOptions]="{standalone: true}" [disabled]="disabled">
|
||||||
@for (operator of getOperatorsForField(atom.field); track operator.label) {
|
@for (operator of getOperatorsForField(atom.field); track operator.label) {
|
||||||
<option [ngValue]="operator.value">{{operator.label}}</option>
|
<option [ngValue]="operator.value">{{operator.label}}</option>
|
||||||
}
|
}
|
||||||
@@ -185,10 +151,10 @@
|
|||||||
<input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [ngModelOptions]="{standalone: true}" [disabled]="disabled">
|
<input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [ngModelOptions]="{standalone: true}" [disabled]="disabled">
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
<button class="btn btn-link btn-sm text-danger pe-0" type="button" (click)="removeElement(atom)" [disabled]="disabled" aria-label="Remove query" i18n-aria-label>
|
||||||
|
<i-bs name="x-circle"></i-bs>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-link btn-sm text-danger pe-0 align-self-center" type="button" (click)="removeElement(atom)" [disabled]="disabled" aria-label="Remove query" i18n-aria-label>
|
|
||||||
<i-bs name="x-circle"></i-bs>
|
|
||||||
</button>
|
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<ng-template #queryExpression let-expression="expression">
|
<ng-template #queryExpression let-expression="expression">
|
||||||
|
|||||||
-15
@@ -5,11 +5,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:host {
|
|
||||||
--ng-select-border-radius: var(--bs-border-radius);
|
|
||||||
--ng-select-height: calc(1.5em + 0.5rem + calc(var(--bs-border-width) * 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
:host ::ng-deep .ng-select-container {
|
:host ::ng-deep .ng-select-container {
|
||||||
border-top-right-radius: 0 !important;
|
border-top-right-radius: 0 !important;
|
||||||
border-bottom-right-radius: 0 !important;
|
border-bottom-right-radius: 0 !important;
|
||||||
@@ -46,13 +41,3 @@
|
|||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-mode .btn {
|
|
||||||
--bs-btn-color: var(--bs-secondary-color);
|
|
||||||
--bs-btn-hover-bg: transparent;
|
|
||||||
--bs-btn-hover-color: var(--bs-body-color);
|
|
||||||
--bs-btn-hover-border-color: var(--bs-border-color);
|
|
||||||
--bs-btn-active-bg: transparent;
|
|
||||||
--bs-btn-active-color: var(--bs-primary);
|
|
||||||
--bs-btn-active-border-color: var(--bs-border-color);
|
|
||||||
}
|
|
||||||
+1
-78
@@ -3,7 +3,7 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'
|
|||||||
import { LOCALE_ID } from '@angular/core'
|
import { LOCALE_ID } from '@angular/core'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NgbDateAdapter, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { of } from 'rxjs'
|
import { of } from 'rxjs'
|
||||||
@@ -11,7 +11,6 @@ import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
|||||||
import {
|
import {
|
||||||
CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP,
|
CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP,
|
||||||
CustomFieldQueryLogicalOperator,
|
CustomFieldQueryLogicalOperator,
|
||||||
CustomFieldQueryOperator,
|
|
||||||
CustomFieldQueryOperatorGroups,
|
CustomFieldQueryOperatorGroups,
|
||||||
} from 'src/app/data/custom-field-query'
|
} from 'src/app/data/custom-field-query'
|
||||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||||
@@ -20,7 +19,6 @@ import {
|
|||||||
CustomFieldQueryElement,
|
CustomFieldQueryElement,
|
||||||
CustomFieldQueryExpression,
|
CustomFieldQueryExpression,
|
||||||
} from 'src/app/utils/custom-field-query-element'
|
} from 'src/app/utils/custom-field-query-element'
|
||||||
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
|
|
||||||
import {
|
import {
|
||||||
CustomFieldQueriesModel,
|
CustomFieldQueriesModel,
|
||||||
CustomFieldsQueryDropdownComponent,
|
CustomFieldsQueryDropdownComponent,
|
||||||
@@ -50,12 +48,6 @@ const customFields = [
|
|||||||
data_type: CustomFieldDataType.Monetary,
|
data_type: CustomFieldDataType.Monetary,
|
||||||
extra_data: { default_currency: 'EUR' },
|
extra_data: { default_currency: 'EUR' },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
name: 'Test Date Field',
|
|
||||||
data_type: CustomFieldDataType.Date,
|
|
||||||
extra_data: {},
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
describe('CustomFieldsQueryDropdownComponent', () => {
|
describe('CustomFieldsQueryDropdownComponent', () => {
|
||||||
@@ -77,7 +69,6 @@ describe('CustomFieldsQueryDropdownComponent', () => {
|
|||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
{ provide: LOCALE_ID, useValue: 'de' },
|
{ provide: LOCALE_ID, useValue: 'de' },
|
||||||
{ provide: NgbDateAdapter, useClass: ISODateAdapter },
|
|
||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
@@ -246,74 +237,6 @@ describe('CustomFieldsQueryDropdownComponent', () => {
|
|||||||
expect(component.selectionModel.queries.length).toBe(1)
|
expect(component.selectionModel.queries.length).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should build relative date values from amount and unit', async () => {
|
|
||||||
component.useDropdown = false
|
|
||||||
const atom = new CustomFieldQueryAtom([
|
|
||||||
4,
|
|
||||||
CustomFieldQueryOperator.GreaterThanOrEqual,
|
|
||||||
'2026-10-22',
|
|
||||||
])
|
|
||||||
component.selectionModel.addAtom(atom)
|
|
||||||
fixture.detectChanges()
|
|
||||||
await fixture.whenStable()
|
|
||||||
const query = (selector: string) =>
|
|
||||||
fixture.nativeElement.querySelector(selector)
|
|
||||||
expect(query('[ngbDatepicker]')).not.toBeNull()
|
|
||||||
expect(query('input[id$="_fixed"]').checked).toBe(true)
|
|
||||||
|
|
||||||
query('input[id$="_relative"]').click()
|
|
||||||
fixture.detectChanges()
|
|
||||||
await fixture.whenStable()
|
|
||||||
expect(atom.value).toBeNull()
|
|
||||||
const amount: HTMLInputElement = query('input[aria-label="Amount"]')
|
|
||||||
const unit: HTMLSelectElement = query('select[id$="_unit"]')
|
|
||||||
amount.value = '30'
|
|
||||||
amount.dispatchEvent(new Event('input'))
|
|
||||||
expect(atom.serialize()).toEqual([4, 'gte', '30 days ago'])
|
|
||||||
unit.selectedIndex = 6
|
|
||||||
unit.dispatchEvent(new Event('change'))
|
|
||||||
expect(atom.serialize()).toEqual([4, 'gte', 'in 30 weeks'])
|
|
||||||
unit.selectedIndex = 0
|
|
||||||
unit.dispatchEvent(new Event('change'))
|
|
||||||
fixture.detectChanges()
|
|
||||||
expect(atom.serialize()).toEqual([4, 'gte', 'today'])
|
|
||||||
expect(query('input[aria-label="Amount"]')).toBeNull()
|
|
||||||
|
|
||||||
query('input[id$="_fixed"]').click()
|
|
||||||
fixture.detectChanges()
|
|
||||||
expect(atom.value).toBeNull()
|
|
||||||
expect(query('[ngbDatepicker]')).not.toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should restore a saved relative date value', async () => {
|
|
||||||
component.useDropdown = false
|
|
||||||
const atom = new CustomFieldQueryAtom([
|
|
||||||
4,
|
|
||||||
CustomFieldQueryOperator.Exact,
|
|
||||||
'in 2 months',
|
|
||||||
])
|
|
||||||
component.selectionModel.addAtom(atom)
|
|
||||||
fixture.detectChanges()
|
|
||||||
await fixture.whenStable()
|
|
||||||
expect(component.getRelativeDate(atom)).toEqual({
|
|
||||||
amount: 2,
|
|
||||||
unit: 'in {n} months',
|
|
||||||
})
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelector('input[aria-label="Amount"]').value
|
|
||||||
).toBe('2')
|
|
||||||
|
|
||||||
const today = new CustomFieldQueryAtom([
|
|
||||||
4,
|
|
||||||
CustomFieldQueryOperator.Exact,
|
|
||||||
'today',
|
|
||||||
])
|
|
||||||
expect(component.getRelativeDate(today)).toEqual({
|
|
||||||
amount: null,
|
|
||||||
unit: 'today',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('CustomFieldQueriesModel', () => {
|
describe('CustomFieldQueriesModel', () => {
|
||||||
let model: CustomFieldQueriesModel
|
let model: CustomFieldQueriesModel
|
||||||
|
|
||||||
|
|||||||
-81
@@ -49,26 +49,6 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
|
|||||||
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
|
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
|
||||||
import { DocumentLinkComponent } from '../input/document-link/document-link.component'
|
import { DocumentLinkComponent } from '../input/document-link/document-link.component'
|
||||||
|
|
||||||
interface RelativeDate {
|
|
||||||
// The expression sent to the API, with {n} standing in for the amount
|
|
||||||
amount: number | null
|
|
||||||
unit: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseRelativeDate(
|
|
||||||
value: string,
|
|
||||||
unit: string
|
|
||||||
): RelativeDate | undefined {
|
|
||||||
const [prefix, suffix] = unit.split('{n}')
|
|
||||||
if (suffix === undefined) {
|
|
||||||
return value === unit ? { amount: null, unit } : undefined
|
|
||||||
}
|
|
||||||
if (!value.startsWith(prefix) || !value.endsWith(suffix)) return undefined
|
|
||||||
const amount = value.slice(prefix.length, value.length - suffix.length)
|
|
||||||
if (!amount || !Number.isInteger(+amount) || +amount < 0) return undefined
|
|
||||||
return { amount: +amount, unit }
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CustomFieldQueriesModel {
|
export class CustomFieldQueriesModel {
|
||||||
private readonly _queries = signal<CustomFieldQueryElement[]>([])
|
private readonly _queries = signal<CustomFieldQueryElement[]>([])
|
||||||
private rootSubscriptions: Subscription[] = []
|
private rootSubscriptions: Subscription[] = []
|
||||||
@@ -325,67 +305,6 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
|||||||
|
|
||||||
public readonly today: string = new Date().toLocaleDateString('en-CA')
|
public readonly today: string = new Date().toLocaleDateString('en-CA')
|
||||||
|
|
||||||
public readonly relativeDateUnits = [
|
|
||||||
{ id: 'today', label: $localize`Today` },
|
|
||||||
{ id: '{n} days ago', label: $localize`days ago` },
|
|
||||||
{ id: '{n} weeks ago', label: $localize`weeks ago` },
|
|
||||||
{ id: '{n} months ago', label: $localize`months ago` },
|
|
||||||
{ id: '{n} years ago', label: $localize`years ago` },
|
|
||||||
{ id: 'in {n} days', label: $localize`days from now` },
|
|
||||||
{ id: 'in {n} weeks', label: $localize`weeks from now` },
|
|
||||||
{ id: 'in {n} months', label: $localize`months from now` },
|
|
||||||
{ id: 'in {n} years', label: $localize`years from now` },
|
|
||||||
]
|
|
||||||
|
|
||||||
private readonly relativeDates = new Map<string, RelativeDate>()
|
|
||||||
|
|
||||||
getRelativeDate(atom: CustomFieldQueryAtom): RelativeDate | undefined {
|
|
||||||
if (!this.relativeDates.has(atom.id) && typeof atom.value === 'string') {
|
|
||||||
for (const { id } of this.relativeDateUnits) {
|
|
||||||
const relativeDate = parseRelativeDate(atom.value, id)
|
|
||||||
if (relativeDate) {
|
|
||||||
this.relativeDates.set(atom.id, relativeDate)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this.relativeDates.get(atom.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
hasDateValueRow(atom: CustomFieldQueryAtom): boolean {
|
|
||||||
return (
|
|
||||||
this.getCustomFieldByID(atom.field)?.data_type ===
|
|
||||||
CustomFieldDataType.Date &&
|
|
||||||
atom.operator !== CustomFieldQueryOperator.Exists &&
|
|
||||||
atom.operator !== CustomFieldQueryOperator.IsNull
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleRelativeDate(atom: CustomFieldQueryAtom) {
|
|
||||||
if (this.getRelativeDate(atom)) {
|
|
||||||
this.relativeDates.delete(atom.id)
|
|
||||||
atom.value = null
|
|
||||||
} else {
|
|
||||||
this.relativeDates.set(atom.id, { amount: null, unit: '{n} days ago' })
|
|
||||||
this.updateRelativeDate(atom, {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateRelativeDate(
|
|
||||||
atom: CustomFieldQueryAtom,
|
|
||||||
change: Partial<RelativeDate>
|
|
||||||
) {
|
|
||||||
const { amount, unit } = Object.assign(
|
|
||||||
this.relativeDates.get(atom.id),
|
|
||||||
change
|
|
||||||
)
|
|
||||||
if (!unit.includes('{n}')) {
|
|
||||||
atom.value = unit
|
|
||||||
} else {
|
|
||||||
atom.value = amount === null ? null : unit.replace('{n}', `${amount}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public customFieldSearchFn = (term: string, field: CustomField): boolean =>
|
public customFieldSearchFn = (term: string, field: CustomField): boolean =>
|
||||||
matchesSearchText(field?.name, term)
|
matchesSearchText(field?.name, term)
|
||||||
|
|
||||||
|
|||||||
@@ -308,7 +308,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr class="align-middle">
|
<tr class="align-middle">
|
||||||
<td i18n>Original SHA256 checksum</td>
|
<td i18n>Original SHA256 checksum</td>
|
||||||
<td><span class="badge bg-light text-dark font-monospace text-wrap text-break text-start">{{metadata()?.original_checksum}}</span></td>
|
<td><span class="badge bg-light font-monospace text-wrap text-break text-start">{{metadata()?.original_checksum}}</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td i18n>Original file size</td>
|
<td i18n>Original file size</td>
|
||||||
|
|||||||
+1
-1
@@ -105,7 +105,7 @@
|
|||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
/>
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
<span class="input-group-text overflow-hidden version-label">
|
<span class="input-group-text version-label">
|
||||||
@if (version.version_label) {
|
@if (version.version_label) {
|
||||||
{{ version.version_label }}
|
{{ version.version_label }}
|
||||||
} @else {
|
} @else {
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import {
|
|||||||
} from '@angular/common/http/testing'
|
} from '@angular/common/http/testing'
|
||||||
import { TestBed } from '@angular/core/testing'
|
import { TestBed } from '@angular/core/testing'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { PaperlessTaskType } from '../data/paperless-task'
|
import {
|
||||||
|
PaperlessTaskStatus,
|
||||||
|
PaperlessTaskTriggerSource,
|
||||||
|
PaperlessTaskType,
|
||||||
|
} from '../data/paperless-task'
|
||||||
import { TasksService } from './tasks.service'
|
import { TasksService } from './tasks.service'
|
||||||
|
|
||||||
describe('TasksService', () => {
|
describe('TasksService', () => {
|
||||||
@@ -34,23 +38,23 @@ describe('TasksService', () => {
|
|||||||
httpTestingController.verify()
|
httpTestingController.verify()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls tasks status counts api endpoint on reload', () => {
|
it('calls tasks api endpoint on reload', () => {
|
||||||
tasksService.reload()
|
tasksService.reload()
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
(req: HttpRequest<unknown>) =>
|
(req: HttpRequest<unknown>) =>
|
||||||
req.url === `${environment.apiBaseUrl}tasks/status_counts/` &&
|
req.url === `${environment.apiBaseUrl}tasks/` &&
|
||||||
req.params.get('acknowledged') === 'false'
|
req.params.get('acknowledged') === 'false' &&
|
||||||
|
req.params.get('page_size') === '1000'
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush({ all: 5, needs_attention: 3, in_progress: 1, completed: 1 })
|
req.flush({ count: 0, results: [] })
|
||||||
expect(tasksService.needsAttentionTasksCount).toEqual(3)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('cancels an in-progress reload when reloading again', () => {
|
it('cancels an in-progress reload when reloading again', () => {
|
||||||
tasksService.reload()
|
tasksService.reload()
|
||||||
const staleReload = httpTestingController.expectOne(
|
const staleReload = httpTestingController.expectOne(
|
||||||
(req: HttpRequest<unknown>) =>
|
(req: HttpRequest<unknown>) =>
|
||||||
req.url === `${environment.apiBaseUrl}tasks/status_counts/`
|
req.url === `${environment.apiBaseUrl}tasks/`
|
||||||
)
|
)
|
||||||
tasksService.reload()
|
tasksService.reload()
|
||||||
|
|
||||||
@@ -58,9 +62,9 @@ describe('TasksService', () => {
|
|||||||
httpTestingController
|
httpTestingController
|
||||||
.expectOne(
|
.expectOne(
|
||||||
(req: HttpRequest<unknown>) =>
|
(req: HttpRequest<unknown>) =>
|
||||||
req.url === `${environment.apiBaseUrl}tasks/status_counts/`
|
req.url === `${environment.apiBaseUrl}tasks/`
|
||||||
)
|
)
|
||||||
.flush({ all: 0, needs_attention: 0, in_progress: 0, completed: 0 })
|
.flush({ count: 0, results: [] })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('continues reloading after a reload request fails', () => {
|
it('continues reloading after a reload request fails', () => {
|
||||||
@@ -68,7 +72,7 @@ describe('TasksService', () => {
|
|||||||
httpTestingController
|
httpTestingController
|
||||||
.expectOne(
|
.expectOne(
|
||||||
(req: HttpRequest<unknown>) =>
|
(req: HttpRequest<unknown>) =>
|
||||||
req.url === `${environment.apiBaseUrl}tasks/status_counts/`
|
req.url === `${environment.apiBaseUrl}tasks/`
|
||||||
)
|
)
|
||||||
.flush('error', { status: 500, statusText: 'error' })
|
.flush('error', { status: 500, statusText: 'error' })
|
||||||
|
|
||||||
@@ -78,16 +82,16 @@ describe('TasksService', () => {
|
|||||||
httpTestingController
|
httpTestingController
|
||||||
.expectOne(
|
.expectOne(
|
||||||
(req: HttpRequest<unknown>) =>
|
(req: HttpRequest<unknown>) =>
|
||||||
req.url === `${environment.apiBaseUrl}tasks/status_counts/`
|
req.url === `${environment.apiBaseUrl}tasks/`
|
||||||
)
|
)
|
||||||
.flush({ all: 0, needs_attention: 0, in_progress: 0, completed: 0 })
|
.flush({ count: 0, results: [] })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reloads after dismissing a task while a reload is already in progress', () => {
|
it('reloads after dismissing a task while a reload is already in progress', () => {
|
||||||
tasksService.reload()
|
tasksService.reload()
|
||||||
const staleReload = httpTestingController.expectOne(
|
const staleReload = httpTestingController.expectOne(
|
||||||
(req: HttpRequest<unknown>) =>
|
(req: HttpRequest<unknown>) =>
|
||||||
req.url === `${environment.apiBaseUrl}tasks/status_counts/` &&
|
req.url === `${environment.apiBaseUrl}tasks/` &&
|
||||||
req.params.get('acknowledged') === 'false'
|
req.params.get('acknowledged') === 'false'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -100,12 +104,12 @@ describe('TasksService', () => {
|
|||||||
httpTestingController
|
httpTestingController
|
||||||
.expectOne(
|
.expectOne(
|
||||||
(req: HttpRequest<unknown>) =>
|
(req: HttpRequest<unknown>) =>
|
||||||
req.url === `${environment.apiBaseUrl}tasks/status_counts/` &&
|
req.url === `${environment.apiBaseUrl}tasks/` &&
|
||||||
req.params.get('acknowledged') === 'false'
|
req.params.get('acknowledged') === 'false'
|
||||||
)
|
)
|
||||||
.flush({ all: 50, needs_attention: 42, in_progress: 3, completed: 5 })
|
.flush({ count: 0, results: [] })
|
||||||
|
|
||||||
expect(tasksService.needsAttentionTasksCount).toEqual(42)
|
expect(tasksService.needsAttentionTasks).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls acknowledge_tasks api endpoint on dismiss and reloads', () => {
|
it('calls acknowledge_tasks api endpoint on dismiss and reloads', () => {
|
||||||
@@ -122,10 +126,11 @@ describe('TasksService', () => {
|
|||||||
httpTestingController
|
httpTestingController
|
||||||
.expectOne(
|
.expectOne(
|
||||||
(req: HttpRequest<unknown>) =>
|
(req: HttpRequest<unknown>) =>
|
||||||
req.url === `${environment.apiBaseUrl}tasks/status_counts/` &&
|
req.url === `${environment.apiBaseUrl}tasks/` &&
|
||||||
req.params.get('acknowledged') === 'false'
|
req.params.get('acknowledged') === 'false' &&
|
||||||
|
req.params.get('page_size') === '1000'
|
||||||
)
|
)
|
||||||
.flush({ all: 0, needs_attention: 0, in_progress: 0, completed: 0 })
|
.flush({ count: 0, results: [] })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls acknowledge_tasks api endpoint on dismiss all and reloads', () => {
|
it('calls acknowledge_tasks api endpoint on dismiss all and reloads', () => {
|
||||||
@@ -142,10 +147,65 @@ describe('TasksService', () => {
|
|||||||
httpTestingController
|
httpTestingController
|
||||||
.expectOne(
|
.expectOne(
|
||||||
(req: HttpRequest<unknown>) =>
|
(req: HttpRequest<unknown>) =>
|
||||||
req.url === `${environment.apiBaseUrl}tasks/status_counts/` &&
|
req.url === `${environment.apiBaseUrl}tasks/` &&
|
||||||
req.params.get('acknowledged') === 'false'
|
req.params.get('acknowledged') === 'false' &&
|
||||||
|
req.params.get('page_size') === '1000'
|
||||||
)
|
)
|
||||||
.flush({ all: 0, needs_attention: 0, in_progress: 0, completed: 0 })
|
.flush({ count: 0, results: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes revoked tasks in needs attention', () => {
|
||||||
|
const mockTasks = [
|
||||||
|
{
|
||||||
|
task_type: PaperlessTaskType.SanityCheck,
|
||||||
|
trigger_source: PaperlessTaskTriggerSource.System,
|
||||||
|
status: PaperlessTaskStatus.Failure,
|
||||||
|
acknowledged: false,
|
||||||
|
task_id: '1235',
|
||||||
|
input_data: {},
|
||||||
|
date_created: new Date(),
|
||||||
|
related_document_ids: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
task_type: PaperlessTaskType.MailFetch,
|
||||||
|
trigger_source: PaperlessTaskTriggerSource.Scheduled,
|
||||||
|
status: PaperlessTaskStatus.Revoked,
|
||||||
|
acknowledged: false,
|
||||||
|
task_id: '1236',
|
||||||
|
input_data: {},
|
||||||
|
date_created: new Date(),
|
||||||
|
related_document_ids: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
task_type: PaperlessTaskType.EmptyTrash,
|
||||||
|
trigger_source: PaperlessTaskTriggerSource.Manual,
|
||||||
|
status: PaperlessTaskStatus.Success,
|
||||||
|
acknowledged: false,
|
||||||
|
task_id: '1238',
|
||||||
|
input_data: {},
|
||||||
|
date_created: new Date(),
|
||||||
|
related_document_ids: [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
tasksService.reload()
|
||||||
|
|
||||||
|
const req = httpTestingController.expectOne(
|
||||||
|
(req: HttpRequest<unknown>) =>
|
||||||
|
req.url === `${environment.apiBaseUrl}tasks/` &&
|
||||||
|
req.params.get('acknowledged') === 'false' &&
|
||||||
|
req.params.get('page_size') === '1000'
|
||||||
|
)
|
||||||
|
|
||||||
|
req.flush({ count: mockTasks.length, results: mockTasks })
|
||||||
|
|
||||||
|
expect(tasksService.needsAttentionTasks).toHaveLength(2)
|
||||||
|
expect(tasksService.needsAttentionTasks.map((task) => task.status)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
PaperlessTaskStatus.Failure,
|
||||||
|
PaperlessTaskStatus.Revoked,
|
||||||
|
])
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('supports running tasks', () => {
|
it('supports running tasks', () => {
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import {
|
|||||||
catchError,
|
catchError,
|
||||||
finalize,
|
finalize,
|
||||||
first,
|
first,
|
||||||
|
map,
|
||||||
switchMap,
|
switchMap,
|
||||||
takeUntil,
|
takeUntil,
|
||||||
tap,
|
tap,
|
||||||
} from 'rxjs/operators'
|
} from 'rxjs/operators'
|
||||||
import {
|
import {
|
||||||
PaperlessTask,
|
PaperlessTask,
|
||||||
|
PaperlessTaskStatus,
|
||||||
PaperlessTaskStatusCounts,
|
PaperlessTaskStatusCounts,
|
||||||
PaperlessTaskType,
|
PaperlessTaskType,
|
||||||
} from 'src/app/data/paperless-task'
|
} from 'src/app/data/paperless-task'
|
||||||
@@ -25,42 +27,50 @@ export class TasksService {
|
|||||||
|
|
||||||
private baseUrl: string = environment.apiBaseUrl
|
private baseUrl: string = environment.apiBaseUrl
|
||||||
private endpoint: string = 'tasks'
|
private endpoint: string = 'tasks'
|
||||||
|
private readonly defaultReloadPageSize = 1000
|
||||||
|
|
||||||
public loading: boolean = false
|
public loading: boolean = false
|
||||||
|
|
||||||
private readonly tasksStatusCounts = signal<PaperlessTaskStatusCounts>({
|
private readonly tasks = signal<PaperlessTask[]>([])
|
||||||
all: 0,
|
|
||||||
needs_attention: 0,
|
|
||||||
in_progress: 0,
|
|
||||||
completed: 0,
|
|
||||||
})
|
|
||||||
private readonly reloadNotifier = new Subject<void>()
|
private readonly reloadNotifier = new Subject<void>()
|
||||||
|
|
||||||
private unsubscribeNotifer: Subject<any> = new Subject()
|
private unsubscribeNotifer: Subject<any> = new Subject()
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.reloadNotifier
|
this.reloadNotifier
|
||||||
.pipe(switchMap(() => this.fetchUnacknowledgedTasksCounts()))
|
.pipe(
|
||||||
.subscribe((taskStatusCounts) => {
|
switchMap(() => {
|
||||||
this.tasksStatusCounts.set(taskStatusCounts)
|
this.loading = true
|
||||||
|
return this.http
|
||||||
|
.get<Results<PaperlessTask>>(`${this.baseUrl}${this.endpoint}/`, {
|
||||||
|
params: {
|
||||||
|
acknowledged: 'false',
|
||||||
|
page_size: this.defaultReloadPageSize,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
map((response) => response.results),
|
||||||
|
takeUntil(this.unsubscribeNotifer),
|
||||||
|
catchError(() => EMPTY),
|
||||||
|
finalize(() => {
|
||||||
|
this.loading = false
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe((tasks) => {
|
||||||
|
this.tasks.set(tasks)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private fetchUnacknowledgedTasksCounts(): Observable<PaperlessTaskStatusCounts> {
|
public get needsAttentionTasks(): PaperlessTask[] {
|
||||||
this.loading = true
|
return this.tasks().filter((t) =>
|
||||||
return this.statusCounts({ acknowledged: false }).pipe(
|
[PaperlessTaskStatus.Failure, PaperlessTaskStatus.Revoked].includes(
|
||||||
takeUntil(this.unsubscribeNotifer),
|
t.status
|
||||||
catchError(() => EMPTY),
|
)
|
||||||
finalize(() => {
|
|
||||||
this.loading = false
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public get needsAttentionTasksCount(): number {
|
|
||||||
return this.tasksStatusCounts().needs_attention
|
|
||||||
}
|
|
||||||
|
|
||||||
public reload() {
|
public reload() {
|
||||||
this.reloadNotifier.next()
|
this.reloadNotifier.next()
|
||||||
}
|
}
|
||||||
@@ -95,7 +105,7 @@ export class TasksService {
|
|||||||
|
|
||||||
public dismissTasks(task_ids: Set<number>): Observable<any> {
|
public dismissTasks(task_ids: Set<number>): Observable<any> {
|
||||||
return this.http
|
return this.http
|
||||||
.post(`${this.baseUrl}${this.endpoint}/acknowledge/`, {
|
.post(`${this.baseUrl}tasks/acknowledge/`, {
|
||||||
tasks: [...task_ids],
|
tasks: [...task_ids],
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
@@ -109,7 +119,7 @@ export class TasksService {
|
|||||||
|
|
||||||
public dismissAllTasks(): Observable<any> {
|
public dismissAllTasks(): Observable<any> {
|
||||||
return this.http
|
return this.http
|
||||||
.post(`${this.baseUrl}${this.endpoint}/acknowledge/`, {
|
.post(`${this.baseUrl}tasks/acknowledge/`, {
|
||||||
all: true,
|
all: true,
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const environment = {
|
|||||||
apiVersion: '10', // match src/paperless/settings.py
|
apiVersion: '10', // match src/paperless/settings.py
|
||||||
appTitle: DEFAULT_APP_TITLE,
|
appTitle: DEFAULT_APP_TITLE,
|
||||||
tag: 'prod',
|
tag: 'prod',
|
||||||
version: '3.4.0',
|
version: '3.3.4',
|
||||||
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/',
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -11,8 +11,6 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from paperless_testing import samples
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -77,30 +75,6 @@ def _clear_django_caches() -> None:
|
|||||||
cache.clear()
|
cache.clear()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _keep_connection_open_in_atomic(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""Make ``close_old_connections()`` a no-op inside the test's transaction.
|
|
||||||
|
|
||||||
The Celery signal handlers call it, as a worker would between tasks. Tests
|
|
||||||
run inside an open atomic block, where Postgres really closes the
|
|
||||||
connection, taking the test transaction and every later test on the
|
|
||||||
worker with it. SQLite in-memory ignores the close, which hid this.
|
|
||||||
"""
|
|
||||||
from django.db.backends.base.base import BaseDatabaseWrapper
|
|
||||||
|
|
||||||
original = BaseDatabaseWrapper.close_if_unusable_or_obsolete
|
|
||||||
|
|
||||||
def close_unless_atomic(self: BaseDatabaseWrapper) -> None:
|
|
||||||
if not self.in_atomic_block:
|
|
||||||
original(self)
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
BaseDatabaseWrapper,
|
|
||||||
"close_if_unusable_or_obsolete",
|
|
||||||
close_unless_atomic,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def paperless_dirs(
|
def paperless_dirs(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
@@ -175,39 +149,3 @@ def fake_progress_manager(
|
|||||||
|
|
||||||
monkeypatch.setattr("documents.tasks.ProgressManager", FakeProgressManager)
|
monkeypatch.setattr("documents.tasks.ProgressManager", FakeProgressManager)
|
||||||
return FakeProgressManager
|
return FakeProgressManager
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def shared_samples_dir() -> Path:
|
|
||||||
"""Directory of sample files used by more than one app's tests."""
|
|
||||||
return samples.SHARED_SAMPLES_DIR
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def simple_digital_pdf_file() -> Path:
|
|
||||||
"""One-page PDF with a text layer."""
|
|
||||||
return samples.SIMPLE_DIGITAL_PDF
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def multi_page_digital_pdf_file() -> Path:
|
|
||||||
"""Three-page PDF with a text layer."""
|
|
||||||
return samples.MULTI_PAGE_DIGITAL_PDF
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def with_form_pdf_file() -> Path:
|
|
||||||
"""PDF containing a fillable form."""
|
|
||||||
return samples.WITH_FORM_PDF
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def multi_page_images_pdf_file() -> Path:
|
|
||||||
"""Multi-page PDF of scanned images, no text layer."""
|
|
||||||
return samples.MULTI_PAGE_IMAGES_PDF
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def thumbnail_webp_file() -> Path:
|
|
||||||
"""Small WebP thumbnail."""
|
|
||||||
return samples.THUMBNAIL_WEBP
|
|
||||||
@@ -36,7 +36,6 @@ from documents.models import DocumentType
|
|||||||
from documents.models import PaperlessTask
|
from documents.models import PaperlessTask
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.permissions import PermissionsSpec
|
|
||||||
from documents.permissions import set_permissions_for_objects
|
from documents.permissions import set_permissions_for_objects
|
||||||
from documents.plugins.helpers import DocumentsStatusManager
|
from documents.plugins.helpers import DocumentsStatusManager
|
||||||
from documents.tasks import bulk_update_documents
|
from documents.tasks import bulk_update_documents
|
||||||
@@ -453,7 +452,7 @@ def reprocess(doc_ids: list[int], *, remote_ocr: bool = False) -> Literal["OK"]:
|
|||||||
|
|
||||||
def set_permissions(
|
def set_permissions(
|
||||||
doc_ids: list[int],
|
doc_ids: list[int],
|
||||||
set_permissions: PermissionsSpec,
|
set_permissions: dict,
|
||||||
*,
|
*,
|
||||||
owner: User | None = None,
|
owner: User | None = None,
|
||||||
merge: bool = False,
|
merge: bool = False,
|
||||||
|
|||||||
@@ -38,8 +38,7 @@ from documents.models import StoragePath
|
|||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.models import WorkflowTrigger
|
from documents.models import WorkflowTrigger
|
||||||
from documents.parsers import ParseError
|
from documents.parsers import ParseError
|
||||||
from documents.permissions import PermissionsSpec
|
from documents.permissions import set_permissions_for_object
|
||||||
from documents.permissions import set_permissions_for_objects
|
|
||||||
from documents.plugins.base import AlwaysRunPluginMixin
|
from documents.plugins.base import AlwaysRunPluginMixin
|
||||||
from documents.plugins.base import ConsumeTaskPlugin
|
from documents.plugins.base import ConsumeTaskPlugin
|
||||||
from documents.plugins.base import NoCleanupPluginMixin
|
from documents.plugins.base import NoCleanupPluginMixin
|
||||||
@@ -847,19 +846,6 @@ class ConsumerPlugin(
|
|||||||
local_added,
|
local_added,
|
||||||
self.filename,
|
self.filename,
|
||||||
self.filename,
|
self.filename,
|
||||||
custom_fields=[
|
|
||||||
CustomFieldInstance(
|
|
||||||
field=field,
|
|
||||||
**{
|
|
||||||
CustomFieldInstance.get_value_field_name(
|
|
||||||
field.data_type,
|
|
||||||
): self.metadata.custom_fields[field.pk],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
for field in CustomField.objects.filter(
|
|
||||||
pk__in=self.metadata.custom_fields or {},
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _store(
|
def _store(
|
||||||
@@ -962,7 +948,7 @@ class ConsumerPlugin(
|
|||||||
or self.metadata.change_users is not None
|
or self.metadata.change_users is not None
|
||||||
or self.metadata.change_groups is not None
|
or self.metadata.change_groups is not None
|
||||||
):
|
):
|
||||||
permissions: PermissionsSpec = {
|
permissions = {
|
||||||
"view": {
|
"view": {
|
||||||
"users": self.metadata.view_users or [],
|
"users": self.metadata.view_users or [],
|
||||||
"groups": self.metadata.view_groups or [],
|
"groups": self.metadata.view_groups or [],
|
||||||
@@ -972,7 +958,7 @@ class ConsumerPlugin(
|
|||||||
"groups": self.metadata.change_groups or [],
|
"groups": self.metadata.change_groups or [],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
set_permissions_for_objects(permissions, Document, [document.pk])
|
set_permissions_for_object(permissions=permissions, object=document)
|
||||||
|
|
||||||
if self.metadata.custom_fields:
|
if self.metadata.custom_fields:
|
||||||
for field in CustomField.objects.filter(
|
for field in CustomField.objects.filter(
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ from pathlib import Path
|
|||||||
from typing import TypedDict
|
from typing import TypedDict
|
||||||
|
|
||||||
import magic
|
import magic
|
||||||
|
from guardian.shortcuts import get_groups_with_perms
|
||||||
|
from guardian.shortcuts import get_users_with_perms
|
||||||
|
|
||||||
|
|
||||||
class StoredBarcode(TypedDict):
|
class StoredBarcode(TypedDict):
|
||||||
@@ -115,9 +117,6 @@ class DocumentMetadataOverrides:
|
|||||||
"""
|
"""
|
||||||
Fills in the overrides from a document object
|
Fills in the overrides from a document object
|
||||||
"""
|
"""
|
||||||
# documents.models imports this module, so this can't be a top-level import
|
|
||||||
from documents.permissions import get_permission_grantees
|
|
||||||
|
|
||||||
overrides = DocumentMetadataOverrides()
|
overrides = DocumentMetadataOverrides()
|
||||||
overrides.title = doc.title
|
overrides.title = doc.title
|
||||||
overrides.correspondent_id = doc.correspondent.id if doc.correspondent else None
|
overrides.correspondent_id = doc.correspondent.id if doc.correspondent else None
|
||||||
@@ -127,16 +126,40 @@ class DocumentMetadataOverrides:
|
|||||||
overrides.tag_ids = list(doc.tags.values_list("id", flat=True))
|
overrides.tag_ids = list(doc.tags.values_list("id", flat=True))
|
||||||
overrides.created = doc.created
|
overrides.created = doc.created
|
||||||
|
|
||||||
grantees = get_permission_grantees(type(doc), [doc.pk])[doc.pk]
|
overrides.view_users = list(
|
||||||
overrides.view_users = grantees["view"]["users"]
|
get_users_with_perms(
|
||||||
overrides.view_groups = grantees["view"]["groups"]
|
doc,
|
||||||
overrides.change_users = grantees["change"]["users"]
|
only_with_perms_in=["view_document"],
|
||||||
overrides.change_groups = grantees["change"]["groups"]
|
with_group_users=False,
|
||||||
|
).values_list("id", flat=True),
|
||||||
|
)
|
||||||
|
overrides.change_users = list(
|
||||||
|
get_users_with_perms(
|
||||||
|
doc,
|
||||||
|
only_with_perms_in=["change_document"],
|
||||||
|
with_group_users=False,
|
||||||
|
).values_list("id", flat=True),
|
||||||
|
)
|
||||||
overrides.custom_fields = {
|
overrides.custom_fields = {
|
||||||
custom_field.field.id: custom_field.value
|
custom_field.field.id: custom_field.value
|
||||||
for custom_field in doc.custom_fields.all()
|
for custom_field in doc.custom_fields.all()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
groups_with_perms = get_groups_with_perms(
|
||||||
|
doc,
|
||||||
|
attach_perms=True,
|
||||||
|
)
|
||||||
|
overrides.view_groups = [
|
||||||
|
group.id
|
||||||
|
for group in groups_with_perms
|
||||||
|
if "view_document" in groups_with_perms[group]
|
||||||
|
]
|
||||||
|
overrides.change_groups = [
|
||||||
|
group.id
|
||||||
|
for group in groups_with_perms
|
||||||
|
if "change_document" in groups_with_perms[group]
|
||||||
|
]
|
||||||
|
|
||||||
return overrides
|
return overrides
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ from decimal import InvalidOperation
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import dateparser
|
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db.models import Case
|
from django.db.models import Case
|
||||||
from django.db.models import CharField
|
from django.db.models import CharField
|
||||||
@@ -27,7 +25,6 @@ from django.db.models import Value
|
|||||||
from django.db.models import When
|
from django.db.models import When
|
||||||
from django.db.models.functions import Cast
|
from django.db.models.functions import Cast
|
||||||
from django.db.models.functions import NullIf
|
from django.db.models.functions import NullIf
|
||||||
from django.utils import timezone
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_filters import DateFilter
|
from django_filters import DateFilter
|
||||||
from django_filters.rest_framework import BooleanFilter
|
from django_filters.rest_framework import BooleanFilter
|
||||||
@@ -60,7 +57,6 @@ from documents.versioning import annotate_effective_content
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
CHAR_KWARGS = ["istartswith", "iendswith", "icontains", "iexact"]
|
CHAR_KWARGS = ["istartswith", "iendswith", "icontains", "iexact"]
|
||||||
ID_KWARGS = ["in", "exact"]
|
ID_KWARGS = ["in", "exact"]
|
||||||
@@ -326,51 +322,6 @@ class MonetaryAmountField(serializers.Field):
|
|||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
class DateQueryField(serializers.DateField):
|
|
||||||
"""
|
|
||||||
Accepts either a fixed date or a relative date expression using `dateparser` and
|
|
||||||
resolves it against the provided reference datetime.
|
|
||||||
"""
|
|
||||||
|
|
||||||
default_error_messages = {
|
|
||||||
"expression": _(
|
|
||||||
'Expected a date or a relative date such as "today" or "in 30 days".',
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, *, reference_datetime: datetime, **kwargs):
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
self.reference_datetime = reference_datetime
|
|
||||||
|
|
||||||
def to_internal_value(self, value):
|
|
||||||
try:
|
|
||||||
# Let the parent class handle fixed-date formats and string validation first
|
|
||||||
return super().to_internal_value(value)
|
|
||||||
except serializers.ValidationError:
|
|
||||||
if not isinstance(value, str):
|
|
||||||
raise
|
|
||||||
|
|
||||||
try:
|
|
||||||
parsed = dateparser.parse(
|
|
||||||
value,
|
|
||||||
# The UI always builds English expressions
|
|
||||||
locales=[*settings.DATE_PARSER_LANGUAGES, "en"]
|
|
||||||
if settings.DATE_PARSER_LANGUAGES
|
|
||||||
else None,
|
|
||||||
settings={
|
|
||||||
"PARSERS": ["relative-time"],
|
|
||||||
"RELATIVE_BASE": self.reference_datetime,
|
|
||||||
"TIMEZONE": str(self.reference_datetime.tzinfo),
|
|
||||||
"RETURN_AS_TIMEZONE_AWARE": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
except (ValueError, OverflowError):
|
|
||||||
self.fail("expression")
|
|
||||||
if parsed is None:
|
|
||||||
self.fail("expression")
|
|
||||||
return parsed.astimezone(self.reference_datetime.tzinfo).date()
|
|
||||||
|
|
||||||
|
|
||||||
class SelectField(serializers.CharField):
|
class SelectField(serializers.CharField):
|
||||||
def __init__(self, custom_field: CustomField) -> None:
|
def __init__(self, custom_field: CustomField) -> None:
|
||||||
self._options = custom_field.extra_data["select_options"]
|
self._options = custom_field.extra_data["select_options"]
|
||||||
@@ -512,8 +463,6 @@ class CustomFieldQueryParser:
|
|||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
{self._validation_prefix: [_("Value must be valid JSON.")]},
|
{self._validation_prefix: [_("Value must be valid JSON.")]},
|
||||||
)
|
)
|
||||||
# Save the current local time as the reference for the query
|
|
||||||
self._reference_datetime = timezone.localtime()
|
|
||||||
return (
|
return (
|
||||||
self._parse_expr(expr, validation_prefix=self._validation_prefix),
|
self._parse_expr(expr, validation_prefix=self._validation_prefix),
|
||||||
self._annotations,
|
self._annotations,
|
||||||
@@ -700,8 +649,6 @@ class CustomFieldQueryParser:
|
|||||||
):
|
):
|
||||||
# DateField admits queries in the form of `year__exact`, etc. These take integers.
|
# DateField admits queries in the form of `year__exact`, etc. These take integers.
|
||||||
field = serializers.IntegerField()
|
field = serializers.IntegerField()
|
||||||
elif custom_field.data_type == CustomField.FieldDataType.DATE:
|
|
||||||
field = DateQueryField(reference_datetime=self._reference_datetime)
|
|
||||||
elif custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
|
elif custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
|
||||||
# We can be more specific here and make sure the value is a list.
|
# We can be more specific here and make sure the value is a list.
|
||||||
field = serializers.ListField(child=serializers.IntegerField())
|
field = serializers.ListField(child=serializers.IntegerField())
|
||||||
|
|||||||
+134
-95
@@ -1,12 +1,8 @@
|
|||||||
from collections.abc import Iterable
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
from typing import Final
|
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
from typing import TypeAlias
|
|
||||||
from typing import TypedDict
|
|
||||||
from typing import TypeGuard
|
from typing import TypeGuard
|
||||||
from typing import TypeVar
|
from typing import TypeVar
|
||||||
from typing import cast
|
from typing import cast
|
||||||
@@ -30,38 +26,15 @@ from django.db.models.functions import Coalesce
|
|||||||
from guardian.core import ObjectPermissionChecker
|
from guardian.core import ObjectPermissionChecker
|
||||||
from guardian.models import GroupObjectPermission
|
from guardian.models import GroupObjectPermission
|
||||||
from guardian.models import UserObjectPermission
|
from guardian.models import UserObjectPermission
|
||||||
|
from guardian.shortcuts import assign_perm
|
||||||
|
from guardian.shortcuts import get_users_with_perms
|
||||||
|
from guardian.shortcuts import remove_perm
|
||||||
from rest_framework.permissions import BasePermission
|
from rest_framework.permissions import BasePermission
|
||||||
from rest_framework.permissions import DjangoObjectPermissions
|
from rest_framework.permissions import DjangoObjectPermissions
|
||||||
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.versioning import get_root_document
|
from documents.versioning import get_root_document
|
||||||
|
|
||||||
PermissionAction = Literal["view", "change"]
|
|
||||||
PERMISSION_ACTIONS: Final[tuple[PermissionAction, ...]] = ("view", "change")
|
|
||||||
|
|
||||||
|
|
||||||
class PermissionGrantees(TypedDict):
|
|
||||||
"""
|
|
||||||
The user and group ids holding one permission action directly on an
|
|
||||||
object. Users who only hold it through a group are not listed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
users: list[int]
|
|
||||||
groups: list[int]
|
|
||||||
|
|
||||||
|
|
||||||
class PermissionGranteesUpdate(TypedDict, total=False):
|
|
||||||
"""
|
|
||||||
The user and group ids to grant one permission action to. A missing key
|
|
||||||
leaves that kind of grant untouched.
|
|
||||||
"""
|
|
||||||
|
|
||||||
users: Iterable[int]
|
|
||||||
groups: Iterable[int]
|
|
||||||
|
|
||||||
|
|
||||||
PermissionsSpec: TypeAlias = Mapping[PermissionAction, PermissionGranteesUpdate]
|
|
||||||
|
|
||||||
|
|
||||||
class PaperlessObjectPermissions(DjangoObjectPermissions):
|
class PaperlessObjectPermissions(DjangoObjectPermissions):
|
||||||
"""
|
"""
|
||||||
@@ -117,10 +90,101 @@ def has_system_status_permission(user: User | None) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_groups_with_only_permission(obj, codename):
|
||||||
|
ctype = ContentType.objects.get_for_model(obj)
|
||||||
|
permission = Permission.objects.get(content_type=ctype, codename=codename)
|
||||||
|
group_object_perm_group_ids = (
|
||||||
|
GroupObjectPermission.objects.filter(
|
||||||
|
object_pk=obj.pk,
|
||||||
|
content_type=ctype,
|
||||||
|
)
|
||||||
|
.filter(permission=permission)
|
||||||
|
.values_list("group_id")
|
||||||
|
)
|
||||||
|
return Group.objects.filter(id__in=group_object_perm_group_ids).distinct()
|
||||||
|
|
||||||
|
|
||||||
|
def set_permissions_for_object(
|
||||||
|
permissions: dict,
|
||||||
|
object,
|
||||||
|
*,
|
||||||
|
merge: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Set permissions for an object. The permissions are given as a mapping of actions
|
||||||
|
to a dict of user / group id lists, e.g.
|
||||||
|
{"view": {"users": [1], "groups": [2]}, "change": {"users": [], "groups": []}}.
|
||||||
|
|
||||||
|
If merge is True, the permissions are merged with the existing permissions and
|
||||||
|
no users or groups are removed. If False, the permissions are set to exactly
|
||||||
|
the given list of users and groups.
|
||||||
|
"""
|
||||||
|
|
||||||
|
for action, entry in permissions.items():
|
||||||
|
permission = f"{action}_{object.__class__.__name__.lower()}"
|
||||||
|
if "users" in entry:
|
||||||
|
# users
|
||||||
|
users_to_add = User.objects.filter(id__in=entry["users"])
|
||||||
|
users_to_remove = (
|
||||||
|
get_users_with_perms(
|
||||||
|
object,
|
||||||
|
only_with_perms_in=[permission],
|
||||||
|
with_group_users=False,
|
||||||
|
)
|
||||||
|
if not merge
|
||||||
|
else User.objects.none()
|
||||||
|
)
|
||||||
|
if users_to_add.exists() and users_to_remove.exists():
|
||||||
|
users_to_remove = users_to_remove.exclude(id__in=users_to_add)
|
||||||
|
if users_to_remove.exists():
|
||||||
|
for user in users_to_remove:
|
||||||
|
remove_perm(permission, user, object)
|
||||||
|
if users_to_add.exists():
|
||||||
|
for user in users_to_add:
|
||||||
|
assign_perm(permission, user, object)
|
||||||
|
if action == "change":
|
||||||
|
# change gives view too
|
||||||
|
assign_perm(
|
||||||
|
f"view_{object.__class__.__name__.lower()}",
|
||||||
|
user,
|
||||||
|
object,
|
||||||
|
)
|
||||||
|
if "groups" in entry:
|
||||||
|
# groups
|
||||||
|
groups_to_add = Group.objects.filter(id__in=entry["groups"])
|
||||||
|
groups_to_remove = (
|
||||||
|
get_groups_with_only_permission(
|
||||||
|
object,
|
||||||
|
permission,
|
||||||
|
)
|
||||||
|
if not merge
|
||||||
|
else Group.objects.none()
|
||||||
|
)
|
||||||
|
if groups_to_add.exists() and groups_to_remove.exists():
|
||||||
|
groups_to_remove = groups_to_remove.exclude(id__in=groups_to_add)
|
||||||
|
if groups_to_remove.exists():
|
||||||
|
for group in groups_to_remove:
|
||||||
|
remove_perm(permission, group, object)
|
||||||
|
if groups_to_add.exists():
|
||||||
|
for group in groups_to_add:
|
||||||
|
assign_perm(permission, group, object)
|
||||||
|
if action == "change":
|
||||||
|
# change gives view too
|
||||||
|
assign_perm(
|
||||||
|
f"view_{object.__class__.__name__.lower()}",
|
||||||
|
group,
|
||||||
|
object,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_permissions(codenames: set[str], ctype: ContentType) -> list[Permission]:
|
def _resolve_permissions(codenames: set[str], ctype: ContentType) -> list[Permission]:
|
||||||
"""
|
"""
|
||||||
Resolve ``codenames`` to Permission rows, raising for any that don't exist
|
Resolves `codenames` to Permission rows, raising like the single-object
|
||||||
so a bad action name fails loudly instead of granting nothing.
|
assign_perm() this bulk path replaces does (via a `.get()` internally)
|
||||||
|
if any codename doesn't exist. SetPermissionsSerializer rejects unknown
|
||||||
|
action names at the API, but a caller passing one directly would
|
||||||
|
otherwise get a plain `.filter()` that silently builds zero rows and
|
||||||
|
no-ops instead of reporting the bad input.
|
||||||
"""
|
"""
|
||||||
permission_objs = list(
|
permission_objs = list(
|
||||||
Permission.objects.filter(content_type=ctype, codename__in=codenames),
|
Permission.objects.filter(content_type=ctype, codename__in=codenames),
|
||||||
@@ -139,13 +203,16 @@ def _apply_bulk_permission_entry(
|
|||||||
perm_model: type[UserObjectPermission] | type[GroupObjectPermission],
|
perm_model: type[UserObjectPermission] | type[GroupObjectPermission],
|
||||||
identity_model: type[User] | type[Group],
|
identity_model: type[User] | type[Group],
|
||||||
identity_field: str,
|
identity_field: str,
|
||||||
ids: Iterable[int],
|
ids: list[int],
|
||||||
codename: str,
|
codename: str,
|
||||||
permission_objs: list[Permission],
|
permission_objs: list[Permission],
|
||||||
ctype: ContentType,
|
ctype: ContentType,
|
||||||
object_pks: list[str],
|
object_pks: list[str],
|
||||||
merge: bool,
|
merge: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
# Only the ids are needed to build permission rows (via `<field>_id=`),
|
||||||
|
# so avoid fetching full User/Group rows for identities that may not
|
||||||
|
# even end up being granted anything new.
|
||||||
add_ids = set(
|
add_ids = set(
|
||||||
identity_model.objects.filter(id__in=ids).values_list("id", flat=True),
|
identity_model.objects.filter(id__in=ids).values_list("id", flat=True),
|
||||||
)
|
)
|
||||||
@@ -183,26 +250,45 @@ def _apply_bulk_permission_entry(
|
|||||||
for pk in object_pks
|
for pk in object_pks
|
||||||
for identity_id in add_ids
|
for identity_id in add_ids
|
||||||
]
|
]
|
||||||
# ignore_conflicts skips grants that already exist (unique constraint).
|
# ignore_conflicts skips only rows that already exist as an exact
|
||||||
|
# (identity, permission, object) match -- the same de-dup the
|
||||||
|
# underlying (user|group, permission, object_pk) unique constraint
|
||||||
|
# already enforces for the single-object assign_perm() this replaces,
|
||||||
|
# so it doesn't change what counts as "already granted". batch_size
|
||||||
|
# caps how many rows go into a single INSERT statement.
|
||||||
perm_model.objects.bulk_create(rows, ignore_conflicts=True, batch_size=1000)
|
perm_model.objects.bulk_create(rows, ignore_conflicts=True, batch_size=1000)
|
||||||
|
|
||||||
|
|
||||||
def set_permissions_for_objects(
|
def set_permissions_for_objects(
|
||||||
permissions: PermissionsSpec,
|
permissions: dict,
|
||||||
model: type[Model],
|
model: type[Model],
|
||||||
pks: QuerySet | list,
|
pks: QuerySet | list,
|
||||||
*,
|
*,
|
||||||
merge: bool = False,
|
merge: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Set the view/change grants for every object in ``pks``. With ``merge``,
|
Bulk equivalent of set_permissions_for_object: applies the same
|
||||||
the given users and groups are added; without it, each given action's
|
permission changes to every object identified by `pks` at once.
|
||||||
direct grants are replaced. Granting change also grants view, and an
|
|
||||||
action entry with neither key is left untouched.
|
|
||||||
|
|
||||||
guardian's bulk assign_perm is avoided because it skips the direct row
|
Takes a model + pks (rather than model instances) deliberately -- the
|
||||||
for anyone who already has access through a group, so revoking the group
|
permission rows built below only ever need `pk`, `content_type`, and
|
||||||
later would silently remove access that was granted explicitly.
|
identity ids, so callers shouldn't have to fetch full rows (with every
|
||||||
|
other field) just to hand them to this function.
|
||||||
|
|
||||||
|
Deliberately does not use guardian's queryset/list-aware assign_perm:
|
||||||
|
passing a list as the object routes to bulk_assign_perm, which skips
|
||||||
|
creating a direct permission row for anyone who already has the
|
||||||
|
permission via ANY group membership (it checks
|
||||||
|
ObjectPermissionChecker.has_perm, which is group-inheritance-aware) --
|
||||||
|
unlike the single-object assign_perm this replaces, which always
|
||||||
|
ensures a direct row via get_or_create regardless of group-derived
|
||||||
|
access. Losing that guarantee would mean a later revocation of the
|
||||||
|
group's grant silently strips access an admin explicitly asked to be
|
||||||
|
direct. Bulk-creating rows straight against the permission models
|
||||||
|
instead (see _apply_bulk_permission_entry) preserves the original
|
||||||
|
always-create-a-direct-row semantics while still batching every object
|
||||||
|
and every identity into one query per action, rather than one query per
|
||||||
|
(object, user) pair.
|
||||||
"""
|
"""
|
||||||
object_pks = [str(pk) for pk in pks]
|
object_pks = [str(pk) for pk in pks]
|
||||||
if not object_pks: # pragma: no cover
|
if not object_pks: # pragma: no cover
|
||||||
@@ -211,9 +297,12 @@ def set_permissions_for_objects(
|
|||||||
model_name = model.__name__.lower()
|
model_name = model.__name__.lower()
|
||||||
ctype = ContentType.objects.get_for_model(model)
|
ctype = ContentType.objects.get_for_model(model)
|
||||||
|
|
||||||
# Resolve every action before writing, so a bad one can't leave the
|
# Every action is resolved up front, before anything is written, so an
|
||||||
# actions ahead of it applied.
|
# unrecognized action name (see _resolve_permissions) aborts the whole
|
||||||
permissions_by_action: dict[PermissionAction, list[Permission]] = {}
|
# call instead of leaving the actions ahead of it already applied.
|
||||||
|
# SetPermissionsSerializer rejects unknown actions at the API, so this
|
||||||
|
# guards any other caller.
|
||||||
|
permissions_by_action: dict[str, list[Permission]] = {}
|
||||||
for action, entry in permissions.items():
|
for action, entry in permissions.items():
|
||||||
if "users" not in entry and "groups" not in entry:
|
if "users" not in entry and "groups" not in entry:
|
||||||
continue
|
continue
|
||||||
@@ -257,56 +346,6 @@ def set_permissions_for_objects(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_permission_grantees(
|
|
||||||
model: type[Model],
|
|
||||||
pks: Iterable[int],
|
|
||||||
actions: Iterable[PermissionAction] = PERMISSION_ACTIONS,
|
|
||||||
) -> dict[int, dict[PermissionAction, PermissionGrantees]]:
|
|
||||||
"""
|
|
||||||
Return the users and groups holding each of ``actions`` directly on each
|
|
||||||
object of ``model`` identified by ``pks``, in two queries however many
|
|
||||||
objects are asked about.
|
|
||||||
|
|
||||||
Every requested pk and action has an entry, with empty lists where nothing
|
|
||||||
is granted. Ids are sorted. Group grants stay group ids rather than being
|
|
||||||
expanded to their members, so a caller that copies them keeps access tied
|
|
||||||
to current group membership.
|
|
||||||
"""
|
|
||||||
pks = list(pks)
|
|
||||||
actions = list(actions)
|
|
||||||
model_name = model.__name__.lower()
|
|
||||||
action_by_codename: dict[str, PermissionAction] = {
|
|
||||||
f"{action}_{model_name}": action for action in actions
|
|
||||||
}
|
|
||||||
result: dict[int, dict[PermissionAction, PermissionGrantees]] = {
|
|
||||||
pk: {action: {"users": [], "groups": []} for action in actions} for pk in pks
|
|
||||||
}
|
|
||||||
if not pks or not actions:
|
|
||||||
return result
|
|
||||||
|
|
||||||
ctype = ContentType.objects.get_for_model(model)
|
|
||||||
grant_filter = {
|
|
||||||
"content_type": ctype,
|
|
||||||
"permission__content_type": ctype,
|
|
||||||
"permission__codename__in": list(action_by_codename),
|
|
||||||
"object_pk__in": [str(pk) for pk in pks],
|
|
||||||
}
|
|
||||||
for perm_model, identity_field, key in (
|
|
||||||
(UserObjectPermission, "user_id", "users"),
|
|
||||||
(GroupObjectPermission, "group_id", "groups"),
|
|
||||||
):
|
|
||||||
rows = (
|
|
||||||
perm_model.objects.filter(**grant_filter)
|
|
||||||
.values_list("object_pk", "permission__codename", identity_field)
|
|
||||||
.order_by(identity_field)
|
|
||||||
)
|
|
||||||
for object_pk, codename, identity_id in rows:
|
|
||||||
result[int(object_pk)][action_by_codename[codename]][key].append(
|
|
||||||
identity_id,
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def permitted_object_ids(
|
def permitted_object_ids(
|
||||||
user: User | None,
|
user: User | None,
|
||||||
model: type[Model],
|
model: type[Model],
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ if TYPE_CHECKING:
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
|
from django.contrib.auth.models import User
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
@@ -526,8 +528,8 @@ class TantivyBackend:
|
|||||||
Annotate the queryset with ``annotate_effective_content`` when indexing
|
Annotate the queryset with ``annotate_effective_content`` when indexing
|
||||||
more than a couple of documents, to resolve that without a query each.
|
more than a couple of documents, to resolve that without a query each.
|
||||||
"""
|
"""
|
||||||
from documents.models import Document
|
from guardian.shortcuts import get_groups_with_perms
|
||||||
from documents.permissions import get_permission_grantees
|
from guardian.shortcuts import get_users_with_perms
|
||||||
|
|
||||||
# Every searchable string is normalized on the way in, and every
|
# Every searchable string is normalized on the way in, and every
|
||||||
# query string on the way out (_parse_query), so the two agree on
|
# query string on the way out (_parse_query), so the two agree on
|
||||||
@@ -672,18 +674,28 @@ class TantivyBackend:
|
|||||||
doc.add_unsigned("owner_id", document.owner_id)
|
doc.add_unsigned("owner_id", document.owner_id)
|
||||||
|
|
||||||
# Viewers with permission
|
# Viewers with permission
|
||||||
if viewer_ids is None or viewer_group_ids is None:
|
if viewer_ids is None:
|
||||||
view = get_permission_grantees(
|
users_with_perms = get_users_with_perms(
|
||||||
Document,
|
document,
|
||||||
[document.pk],
|
only_with_perms_in=["view_document"],
|
||||||
actions=("view",),
|
with_group_users=False,
|
||||||
)[document.pk]["view"]
|
)
|
||||||
if viewer_ids is None:
|
viewer_ids = list(
|
||||||
viewer_ids = view["users"]
|
cast("QuerySet[User]", users_with_perms).values_list("id", flat=True),
|
||||||
if viewer_group_ids is None:
|
)
|
||||||
viewer_group_ids = view["groups"]
|
|
||||||
for viewer_id in viewer_ids:
|
for viewer_id in viewer_ids:
|
||||||
doc.add_unsigned("viewer_id", viewer_id)
|
doc.add_unsigned("viewer_id", viewer_id)
|
||||||
|
if viewer_group_ids is None:
|
||||||
|
groups_with_perms = get_groups_with_perms(
|
||||||
|
document,
|
||||||
|
only_with_perms_in=["view_document"],
|
||||||
|
)
|
||||||
|
viewer_group_ids = list(
|
||||||
|
cast("QuerySet[Group]", groups_with_perms).values_list(
|
||||||
|
"id",
|
||||||
|
flat=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
for viewer_group_id in viewer_group_ids:
|
for viewer_group_id in viewer_group_ids:
|
||||||
doc.add_unsigned("viewer_group_id", viewer_group_id)
|
doc.add_unsigned("viewer_group_id", viewer_group_id)
|
||||||
|
|
||||||
@@ -1188,6 +1200,12 @@ def chunked(iterable, size):
|
|||||||
yield chunk
|
yield chunk
|
||||||
|
|
||||||
|
|
||||||
|
_EMPTY_VIEWER_GRANT: Final[ViewerGrant] = ViewerGrant(
|
||||||
|
viewer_ids=[],
|
||||||
|
viewer_group_ids=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _DocumentViewerStream(QuerySetStream["Document"]):
|
class _DocumentViewerStream(QuerySetStream["Document"]):
|
||||||
"""Yield document permission data while batch-loading grants.
|
"""Yield document permission data while batch-loading grants.
|
||||||
|
|
||||||
@@ -1213,7 +1231,7 @@ class _DocumentViewerStream(QuerySetStream["Document"]):
|
|||||||
for chunk in chunked(documents, self._chunk_size):
|
for chunk in chunked(documents, self._chunk_size):
|
||||||
grants_by_pk = _bulk_get_viewer_permissions([doc.pk for doc in chunk])
|
grants_by_pk = _bulk_get_viewer_permissions([doc.pk for doc in chunk])
|
||||||
for doc in chunk:
|
for doc in chunk:
|
||||||
yield doc, grants_by_pk[doc.pk]
|
yield doc, grants_by_pk.get(doc.pk, _EMPTY_VIEWER_GRANT)
|
||||||
|
|
||||||
|
|
||||||
def _bulk_get_viewer_permissions(
|
def _bulk_get_viewer_permissions(
|
||||||
@@ -1225,19 +1243,48 @@ def _bulk_get_viewer_permissions(
|
|||||||
requesting user's current memberships. Expanding groups to user IDs here
|
requesting user's current memberships. Expanding groups to user IDs here
|
||||||
would leave stale access behind after a user is removed from a group.
|
would leave stale access behind after a user is removed from a group.
|
||||||
"""
|
"""
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from guardian.models import GroupObjectPermission
|
||||||
|
from guardian.models import UserObjectPermission
|
||||||
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.permissions import get_permission_grantees
|
|
||||||
|
# get_for_model is cached by Django, so this costs at most one query total.
|
||||||
|
ct = ContentType.objects.get_for_model(Document)
|
||||||
|
str_pks = [str(pk) for pk in doc_pks]
|
||||||
|
|
||||||
|
viewer_map: dict[int, set[int]] = defaultdict(set)
|
||||||
|
viewer_group_map: dict[int, set[int]] = defaultdict(set)
|
||||||
|
|
||||||
|
# Fold the permission lookup into the query via a join on codename instead
|
||||||
|
# of a separate Permission.objects.get(), which would otherwise run once per
|
||||||
|
# chunk during a full reindex.
|
||||||
|
user_qs = UserObjectPermission.objects.filter(
|
||||||
|
content_type=ct,
|
||||||
|
permission__content_type=ct,
|
||||||
|
permission__codename="view_document",
|
||||||
|
object_pk__in=str_pks,
|
||||||
|
).values_list("object_pk", "user_id")
|
||||||
|
for object_pk, user_id in user_qs:
|
||||||
|
viewer_map[int(object_pk)].add(user_id)
|
||||||
|
|
||||||
|
group_qs = GroupObjectPermission.objects.filter(
|
||||||
|
content_type=ct,
|
||||||
|
permission__content_type=ct,
|
||||||
|
permission__codename="view_document",
|
||||||
|
object_pk__in=str_pks,
|
||||||
|
).values_list("object_pk", "group_id")
|
||||||
|
for object_pk, group_id in group_qs:
|
||||||
|
viewer_group_map[int(object_pk)].add(group_id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pk: ViewerGrant(
|
object_pk: ViewerGrant(
|
||||||
viewer_ids=grantees["view"]["users"],
|
viewer_ids=list(viewer_map.get(object_pk, ())),
|
||||||
viewer_group_ids=grantees["view"]["groups"],
|
viewer_group_ids=list(viewer_group_map.get(object_pk, ())),
|
||||||
)
|
)
|
||||||
for pk, grantees in get_permission_grantees(
|
for object_pk in viewer_map.keys() | viewer_group_map.keys()
|
||||||
Document,
|
|
||||||
doc_pks,
|
|
||||||
actions=("view",),
|
|
||||||
).items()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from datetime import timedelta
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from typing import Literal
|
||||||
from typing import TypedDict
|
from typing import TypedDict
|
||||||
|
|
||||||
import magic
|
import magic
|
||||||
@@ -38,6 +39,7 @@ from drf_spectacular.utils import extend_schema_field
|
|||||||
from drf_spectacular.utils import extend_schema_serializer
|
from drf_spectacular.utils import extend_schema_serializer
|
||||||
from drf_writable_nested.serializers import NestedUpdateMixin
|
from drf_writable_nested.serializers import NestedUpdateMixin
|
||||||
from guardian.core import ObjectPermissionChecker
|
from guardian.core import ObjectPermissionChecker
|
||||||
|
from guardian.shortcuts import get_users_with_perms
|
||||||
from guardian.utils import get_group_obj_perms_model
|
from guardian.utils import get_group_obj_perms_model
|
||||||
from guardian.utils import get_user_obj_perms_model
|
from guardian.utils import get_user_obj_perms_model
|
||||||
from rest_framework import fields
|
from rest_framework import fields
|
||||||
@@ -76,17 +78,12 @@ from documents.models import WorkflowActionEmail
|
|||||||
from documents.models import WorkflowActionWebhook
|
from documents.models import WorkflowActionWebhook
|
||||||
from documents.models import WorkflowTrigger
|
from documents.models import WorkflowTrigger
|
||||||
from documents.parsers import is_mime_type_supported
|
from documents.parsers import is_mime_type_supported
|
||||||
from documents.permissions import PERMISSION_ACTIONS
|
|
||||||
from documents.permissions import PermissionAction
|
|
||||||
from documents.permissions import PermissionGrantees
|
|
||||||
from documents.permissions import PermissionGranteesUpdate
|
|
||||||
from documents.permissions import PermissionsSpec
|
|
||||||
from documents.permissions import get_document_count_filter_for_user
|
from documents.permissions import get_document_count_filter_for_user
|
||||||
from documents.permissions import get_permission_grantees
|
from documents.permissions import get_groups_with_only_permission
|
||||||
from documents.permissions import has_perms_owner_aware
|
from documents.permissions import has_perms_owner_aware
|
||||||
from documents.permissions import permitted_document_ids
|
from documents.permissions import permitted_document_ids
|
||||||
from documents.permissions import restrict_queryset_to_visible
|
from documents.permissions import restrict_queryset_to_visible
|
||||||
from documents.permissions import set_permissions_for_objects
|
from documents.permissions import set_permissions_for_object
|
||||||
from documents.regex import validate_regex_pattern
|
from documents.regex import validate_regex_pattern
|
||||||
from documents.templating.filepath import validate_filepath_template_and_render
|
from documents.templating.filepath import validate_filepath_template_and_render
|
||||||
from documents.templating.utils import convert_format_str_to_template_format
|
from documents.templating.utils import convert_format_str_to_template_format
|
||||||
@@ -214,6 +211,9 @@ class MatchingModelSerializer(serializers.ModelSerializer[Any]):
|
|||||||
return match
|
return match
|
||||||
|
|
||||||
|
|
||||||
|
PERMISSION_ACTIONS = ("view", "change")
|
||||||
|
|
||||||
|
|
||||||
class SetPermissionsMixin:
|
class SetPermissionsMixin:
|
||||||
def _validate_user_ids(self, user_ids):
|
def _validate_user_ids(self, user_ids):
|
||||||
users = User.objects.none()
|
users = User.objects.none()
|
||||||
@@ -235,10 +235,8 @@ class SetPermissionsMixin:
|
|||||||
)
|
)
|
||||||
return groups
|
return groups
|
||||||
|
|
||||||
def validate_set_permissions(self, set_permissions=None) -> PermissionsSpec:
|
def validate_set_permissions(self, set_permissions=None):
|
||||||
permissions_dict: dict[PermissionAction, PermissionGranteesUpdate] = {
|
permissions_dict = {action: {} for action in PERMISSION_ACTIONS}
|
||||||
action: {} for action in PERMISSION_ACTIONS
|
|
||||||
}
|
|
||||||
if set_permissions is not None:
|
if set_permissions is not None:
|
||||||
for action in PERMISSION_ACTIONS:
|
for action in PERMISSION_ACTIONS:
|
||||||
if action in set_permissions:
|
if action in set_permissions:
|
||||||
@@ -246,18 +244,18 @@ class SetPermissionsMixin:
|
|||||||
users = set_permissions[action]["users"]
|
users = set_permissions[action]["users"]
|
||||||
permissions_dict[action]["users"] = self._validate_user_ids(
|
permissions_dict[action]["users"] = self._validate_user_ids(
|
||||||
users,
|
users,
|
||||||
).values_list("id", flat=True)
|
)
|
||||||
if "groups" in set_permissions[action]:
|
if "groups" in set_permissions[action]:
|
||||||
groups = set_permissions[action]["groups"]
|
groups = set_permissions[action]["groups"]
|
||||||
permissions_dict[action]["groups"] = self._validate_group_ids(
|
permissions_dict[action]["groups"] = self._validate_group_ids(
|
||||||
groups,
|
groups,
|
||||||
).values_list("id", flat=True)
|
)
|
||||||
else:
|
else:
|
||||||
del permissions_dict[action]
|
del permissions_dict[action]
|
||||||
return permissions_dict
|
return permissions_dict
|
||||||
|
|
||||||
def _set_permissions(self, permissions: PermissionsSpec, object) -> None:
|
def _set_permissions(self, permissions, object) -> None:
|
||||||
set_permissions_for_objects(permissions, type(object), [object.pk])
|
set_permissions_for_object(permissions, object)
|
||||||
|
|
||||||
|
|
||||||
class SerializerWithPerms(serializers.Serializer[dict[str, Any]]):
|
class SerializerWithPerms(serializers.Serializer[dict[str, Any]]):
|
||||||
@@ -313,6 +311,35 @@ class OwnedObjectSerializer(
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _get_perms(self, obj, codename: str, target: Literal["users", "groups"]):
|
||||||
|
"""
|
||||||
|
Get the given permissions from context or from django-guardian.
|
||||||
|
|
||||||
|
:param codename: The permission codename, e.g. 'view' or 'change'
|
||||||
|
:param target: 'users' or 'groups'
|
||||||
|
"""
|
||||||
|
key = f"{target}_{codename}_perms"
|
||||||
|
cached = self.context.get(key, {}).get(obj.pk)
|
||||||
|
if cached is not None:
|
||||||
|
return list(cached)
|
||||||
|
|
||||||
|
# Permission not found in the context, get it from guardian
|
||||||
|
if target == "users":
|
||||||
|
return list(
|
||||||
|
get_users_with_perms(
|
||||||
|
obj,
|
||||||
|
only_with_perms_in=[f"{codename}_{obj.__class__.__name__.lower()}"],
|
||||||
|
with_group_users=False,
|
||||||
|
).values_list("id", flat=True),
|
||||||
|
)
|
||||||
|
else: # groups
|
||||||
|
return list(
|
||||||
|
get_groups_with_only_permission(
|
||||||
|
obj,
|
||||||
|
codename=f"{codename}_{obj.__class__.__name__.lower()}",
|
||||||
|
).values_list("id", flat=True),
|
||||||
|
)
|
||||||
|
|
||||||
@extend_schema_field(
|
@extend_schema_field(
|
||||||
field={
|
field={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -346,12 +373,17 @@ class OwnedObjectSerializer(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
def get_permissions(self, obj) -> dict[PermissionAction, PermissionGrantees]:
|
def get_permissions(self, obj) -> dict:
|
||||||
# Prefer the page-level batch computed by BulkPermissionMixin.
|
return {
|
||||||
grantees = self.context.get("permission_grantees", {}).get(obj.pk)
|
"view": {
|
||||||
if grantees is not None:
|
"users": self._get_perms(obj, "view", "users"),
|
||||||
return grantees
|
"groups": self._get_perms(obj, "view", "groups"),
|
||||||
return get_permission_grantees(type(obj), [obj.pk])[obj.pk]
|
},
|
||||||
|
"change": {
|
||||||
|
"users": self._get_perms(obj, "change", "users"),
|
||||||
|
"groups": self._get_perms(obj, "change", "groups"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
def get_user_can_change(self, obj) -> bool:
|
def get_user_can_change(self, obj) -> bool:
|
||||||
if obj.owner is None or obj.owner == self.user:
|
if obj.owner is None or obj.owner == self.user:
|
||||||
@@ -367,18 +399,18 @@ class OwnedObjectSerializer(
|
|||||||
# Prefer the page-level batch computed by BulkPermissionMixin
|
# Prefer the page-level batch computed by BulkPermissionMixin
|
||||||
# (get_serializer_context) over a fresh per-object guardian check,
|
# (get_serializer_context) over a fresh per-object guardian check,
|
||||||
# which would otherwise query the permission tables once per row.
|
# which would otherwise query the permission tables once per row.
|
||||||
grantees_by_pk = self.context.get("permission_grantees")
|
users_change_perms = self.context.get("users_change_perms")
|
||||||
if grantees_by_pk is not None:
|
groups_change_perms = self.context.get("groups_change_perms")
|
||||||
change = grantees_by_pk.get(obj.pk, {}).get("change")
|
if users_change_perms is not None and groups_change_perms is not None:
|
||||||
if change is None:
|
if self.user.pk in users_change_perms.get(obj.pk, []):
|
||||||
return False
|
|
||||||
if self.user.pk in change["users"]:
|
|
||||||
return True
|
return True
|
||||||
user_group_ids = getattr(self, "_user_group_ids", None)
|
user_group_ids = getattr(self, "_user_group_ids", None)
|
||||||
if user_group_ids is None:
|
if user_group_ids is None:
|
||||||
user_group_ids = set(self.user.groups.values_list("id", flat=True))
|
user_group_ids = set(self.user.groups.values_list("id", flat=True))
|
||||||
self._user_group_ids = user_group_ids
|
self._user_group_ids = user_group_ids
|
||||||
return bool(user_group_ids.intersection(change["groups"]))
|
return bool(
|
||||||
|
user_group_ids.intersection(groups_change_perms.get(obj.pk, [])),
|
||||||
|
)
|
||||||
|
|
||||||
checker = ObjectPermissionChecker(self.user)
|
checker = ObjectPermissionChecker(self.user)
|
||||||
return checker.has_perm(f"change_{obj.__class__.__name__.lower()}", obj)
|
return checker.has_perm(f"change_{obj.__class__.__name__.lower()}", obj)
|
||||||
@@ -3056,7 +3088,7 @@ class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
|
|||||||
)
|
)
|
||||||
return objects
|
return objects
|
||||||
|
|
||||||
def _validate_permissions(self, permissions) -> PermissionsSpec:
|
def _validate_permissions(self, permissions) -> dict:
|
||||||
return self.validate_set_permissions(
|
return self.validate_set_permissions(
|
||||||
permissions,
|
permissions,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1381,17 +1381,9 @@ def task_revoked_handler(
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
close_old_connections()
|
close_old_connections()
|
||||||
reason = "Task revoked before completion."
|
|
||||||
if expired:
|
|
||||||
reason = "Task expired before execution."
|
|
||||||
elif terminated:
|
|
||||||
reason = "Task terminated during execution."
|
|
||||||
PaperlessTask.objects.filter(task_id=task_id).update(
|
PaperlessTask.objects.filter(task_id=task_id).update(
|
||||||
status=PaperlessTask.Status.REVOKED,
|
status=PaperlessTask.Status.REVOKED,
|
||||||
date_done=timezone.now(),
|
date_done=timezone.now(),
|
||||||
result_data={
|
|
||||||
"reason": (reason + " Check worker logs for details."),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
except Exception: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
logger.exception("Updating PaperlessTask on revocation failed")
|
logger.exception("Updating PaperlessTask on revocation failed")
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import logging
|
import logging
|
||||||
from collections.abc import Iterable
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -14,9 +13,7 @@ from jinja2 import make_logging_undefined
|
|||||||
from jinja2.meta import find_undeclared_variables
|
from jinja2.meta import find_undeclared_variables
|
||||||
from jinja2.sandbox import SecurityError
|
from jinja2.sandbox import SecurityError
|
||||||
|
|
||||||
from documents.models import CustomFieldInstance
|
|
||||||
from documents.templating.environment import _template_environment
|
from documents.templating.environment import _template_environment
|
||||||
from documents.templating.filepath import get_custom_fields_context
|
|
||||||
from documents.templating.filters import format_datetime
|
from documents.templating.filters import format_datetime
|
||||||
from documents.templating.filters import localize_date
|
from documents.templating.filters import localize_date
|
||||||
|
|
||||||
@@ -59,7 +56,6 @@ _known_placeholder_names = {
|
|||||||
"doc_title",
|
"doc_title",
|
||||||
"doc_url",
|
"doc_url",
|
||||||
"doc_id",
|
"doc_id",
|
||||||
"custom_fields",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -90,7 +86,6 @@ def parse_w_workflow_placeholders(
|
|||||||
doc_title: str | None = None,
|
doc_title: str | None = None,
|
||||||
doc_url: str | None = None,
|
doc_url: str | None = None,
|
||||||
doc_id: int | None = None,
|
doc_id: int | None = None,
|
||||||
custom_fields: Iterable[CustomFieldInstance] = (),
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Available title placeholders for Workflows depend on what has already been assigned,
|
Available title placeholders for Workflows depend on what has already been assigned,
|
||||||
@@ -113,7 +108,6 @@ def parse_w_workflow_placeholders(
|
|||||||
"original_filename": Path(original_filename).stem,
|
"original_filename": Path(original_filename).stem,
|
||||||
"filename": Path(filename).stem,
|
"filename": Path(filename).stem,
|
||||||
}
|
}
|
||||||
formatting.update(get_custom_fields_context(custom_fields))
|
|
||||||
if created is not None:
|
if created is not None:
|
||||||
formatting.update(
|
formatting.update(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,25 +12,29 @@ if TYPE_CHECKING:
|
|||||||
from paperless_testing.dirs import PaperlessDirs
|
from paperless_testing.dirs import PaperlessDirs
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def document_samples_dir() -> Path:
|
||||||
|
"""Path to the shared test sample documents."""
|
||||||
|
return Path(__file__).parent / "samples" / "documents"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def sample_doc(
|
def sample_doc(
|
||||||
paperless_dirs: "PaperlessDirs",
|
paperless_dirs: "PaperlessDirs",
|
||||||
simple_digital_pdf_file: Path,
|
document_samples_dir: Path,
|
||||||
multi_page_images_pdf_file: Path,
|
|
||||||
thumbnail_webp_file: Path,
|
|
||||||
) -> "Document":
|
) -> "Document":
|
||||||
"""Create a document with valid files and matching checksums."""
|
"""Create a document with valid files and matching checksums."""
|
||||||
with filelock.FileLock(paperless_dirs.media_lock):
|
with filelock.FileLock(paperless_dirs.media_lock):
|
||||||
shutil.copy(
|
shutil.copy(
|
||||||
simple_digital_pdf_file,
|
document_samples_dir / "originals" / "0000001.pdf",
|
||||||
paperless_dirs.originals_dir / "0000001.pdf",
|
paperless_dirs.originals_dir / "0000001.pdf",
|
||||||
)
|
)
|
||||||
shutil.copy(
|
shutil.copy(
|
||||||
multi_page_images_pdf_file,
|
document_samples_dir / "archive" / "0000001.pdf",
|
||||||
paperless_dirs.archive_dir / "0000001.pdf",
|
paperless_dirs.archive_dir / "0000001.pdf",
|
||||||
)
|
)
|
||||||
shutil.copy(
|
shutil.copy(
|
||||||
thumbnail_webp_file,
|
document_samples_dir / "thumbnails" / "0000001.webp",
|
||||||
paperless_dirs.thumbnail_dir / "0000001.webp",
|
paperless_dirs.thumbnail_dir / "0000001.webp",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
This is a test file.
|
||||||
@@ -53,7 +53,7 @@ class TestBulkDownload(DirectoriesMixin, SampleDirMixin, APITestCase):
|
|||||||
archive_checksum="D",
|
archive_checksum="D",
|
||||||
)
|
)
|
||||||
|
|
||||||
shutil.copy(self.SIMPLE_PDF, self.doc2.source_path)
|
shutil.copy(self.SAMPLE_DIR / "simple.pdf", self.doc2.source_path)
|
||||||
shutil.copy(self.SAMPLE_DIR / "simple.png", self.doc2b.source_path)
|
shutil.copy(self.SAMPLE_DIR / "simple.png", self.doc2b.source_path)
|
||||||
shutil.copy(self.SAMPLE_DIR / "simple.jpg", self.doc3.source_path)
|
shutil.copy(self.SAMPLE_DIR / "simple.jpg", self.doc3.source_path)
|
||||||
shutil.copy(self.SAMPLE_DIR / "test_with_bom.pdf", self.doc3.archive_path)
|
shutil.copy(self.SAMPLE_DIR / "test_with_bom.pdf", self.doc3.archive_path)
|
||||||
|
|||||||
@@ -1062,7 +1062,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
|||||||
"custom_fields": [
|
"custom_fields": [
|
||||||
{
|
{
|
||||||
"field": custom_field_doclink.id,
|
"field": custom_field_doclink.id,
|
||||||
"value": [doc2.id, doc3.id, doc4.id],
|
"value": [2, 3, 4],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -1073,13 +1073,13 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(CustomFieldInstance.objects.count(), 4)
|
self.assertEqual(CustomFieldInstance.objects.count(), 4)
|
||||||
_cf_5 = doc2.custom_fields.first()
|
_cf_5 = doc2.custom_fields.first()
|
||||||
assert _cf_5 is not None
|
assert _cf_5 is not None
|
||||||
self.assertEqual(_cf_5.value, [doc1.id])
|
self.assertEqual(_cf_5.value, [1])
|
||||||
_cf_6 = doc3.custom_fields.first()
|
_cf_6 = doc3.custom_fields.first()
|
||||||
assert _cf_6 is not None
|
assert _cf_6 is not None
|
||||||
self.assertEqual(_cf_6.value, [doc1.id])
|
self.assertEqual(_cf_6.value, [1])
|
||||||
_cf_7 = doc4.custom_fields.first()
|
_cf_7 = doc4.custom_fields.first()
|
||||||
assert _cf_7 is not None
|
assert _cf_7 is not None
|
||||||
self.assertEqual(_cf_7.value, [doc1.id])
|
self.assertEqual(_cf_7.value, [1])
|
||||||
|
|
||||||
# Add links appends if necessary
|
# Add links appends if necessary
|
||||||
resp = self.client.patch(
|
resp = self.client.patch(
|
||||||
@@ -1088,7 +1088,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
|||||||
"custom_fields": [
|
"custom_fields": [
|
||||||
{
|
{
|
||||||
"field": custom_field_doclink.id,
|
"field": custom_field_doclink.id,
|
||||||
"value": [doc1.id, doc4.id],
|
"value": [1, 4],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -1098,7 +1098,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
_cf_8 = doc4.custom_fields.first()
|
_cf_8 = doc4.custom_fields.first()
|
||||||
assert _cf_8 is not None
|
assert _cf_8 is not None
|
||||||
self.assertEqual(_cf_8.value, [doc1.id, doc3.id])
|
self.assertEqual(_cf_8.value, [1, 3])
|
||||||
|
|
||||||
# Remove one of the links, removed on other doc
|
# Remove one of the links, removed on other doc
|
||||||
resp = self.client.patch(
|
resp = self.client.patch(
|
||||||
@@ -1107,7 +1107,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
|||||||
"custom_fields": [
|
"custom_fields": [
|
||||||
{
|
{
|
||||||
"field": custom_field_doclink.id,
|
"field": custom_field_doclink.id,
|
||||||
"value": [doc2.id, doc3.id],
|
"value": [2, 3],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -1117,13 +1117,13 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
_cf_9 = doc2.custom_fields.first()
|
_cf_9 = doc2.custom_fields.first()
|
||||||
assert _cf_9 is not None
|
assert _cf_9 is not None
|
||||||
self.assertEqual(_cf_9.value, [doc1.id])
|
self.assertEqual(_cf_9.value, [1])
|
||||||
_cf_10 = doc3.custom_fields.first()
|
_cf_10 = doc3.custom_fields.first()
|
||||||
assert _cf_10 is not None
|
assert _cf_10 is not None
|
||||||
self.assertEqual(_cf_10.value, [doc1.id, doc4.id])
|
self.assertEqual(_cf_10.value, [1, 4])
|
||||||
_cf_11 = doc4.custom_fields.first()
|
_cf_11 = doc4.custom_fields.first()
|
||||||
assert _cf_11 is not None
|
assert _cf_11 is not None
|
||||||
self.assertEqual(_cf_11.value, [doc3.id])
|
self.assertEqual(_cf_11.value, [3])
|
||||||
|
|
||||||
# Removes the field entirely
|
# Removes the field entirely
|
||||||
resp = self.client.patch(
|
resp = self.client.patch(
|
||||||
@@ -1140,10 +1140,10 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(_cf_12.value, [])
|
self.assertEqual(_cf_12.value, [])
|
||||||
_cf_13 = doc3.custom_fields.first()
|
_cf_13 = doc3.custom_fields.first()
|
||||||
assert _cf_13 is not None
|
assert _cf_13 is not None
|
||||||
self.assertEqual(_cf_13.value, [doc4.id])
|
self.assertEqual(_cf_13.value, [4])
|
||||||
_cf_14 = doc4.custom_fields.first()
|
_cf_14 = doc4.custom_fields.first()
|
||||||
assert _cf_14 is not None
|
assert _cf_14 is not None
|
||||||
self.assertEqual(_cf_14.value, [doc3.id])
|
self.assertEqual(_cf_14.value, [3])
|
||||||
|
|
||||||
# If field exists on target doc but value is None
|
# If field exists on target doc but value is None
|
||||||
doc5 = Document.objects.create(
|
doc5 = Document.objects.create(
|
||||||
@@ -1170,7 +1170,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
_cf_15 = doc5.custom_fields.first()
|
_cf_15 = doc5.custom_fields.first()
|
||||||
assert _cf_15 is not None
|
assert _cf_15 is not None
|
||||||
self.assertEqual(_cf_15.value, [doc1.id])
|
self.assertEqual(_cf_15.value, [1])
|
||||||
|
|
||||||
def test_documentlink_patch_requires_change_permission_on_target_documents(
|
def test_documentlink_patch_requires_change_permission_on_target_documents(
|
||||||
self,
|
self,
|
||||||
|
|||||||
Loaded 100 of 192 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user