mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-09-10 21:33:48 -05:00
Compare commits
24 Commits
89e5c08a1f
...
feature-pa
Author | SHA1 | Date | |
---|---|---|---|
![]() |
a0a9e0c6c8 | ||
![]() |
1c7c703e5f | ||
![]() |
53e9e910d8 | ||
![]() |
9fe611a24c | ||
![]() |
31e71aab83 | ||
![]() |
7e7ce97d10 | ||
![]() |
e06adc58c7 | ||
![]() |
7170ac31b7 | ||
![]() |
a0aa78c788 | ||
![]() |
f3438914cc | ||
![]() |
e1b944ce6b | ||
![]() |
0add5aab0e | ||
![]() |
c9adc74fa9 | ||
![]() |
32abfbfc0a | ||
![]() |
7f02f782f4 | ||
![]() |
7c3f011e84 | ||
![]() |
5c68177960 | ||
![]() |
7a4666783e | ||
![]() |
372825c271 | ||
![]() |
abfddd6931 | ||
![]() |
b3d49dbf12 | ||
![]() |
673839265d | ||
![]() |
f31df22ab6 | ||
![]() |
f897447a65 |
@@ -76,15 +76,18 @@ RUN set -eux \
|
||||
&& apt-get update \
|
||||
&& apt-get install --yes --quiet --no-install-recommends ${RUNTIME_PACKAGES}
|
||||
|
||||
ARG PYTHON_PACKAGES="ca-certificates"
|
||||
ARG PYTHON_PACKAGES="\
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-wheel \
|
||||
pipenv \
|
||||
ca-certificates"
|
||||
|
||||
RUN set -eux \
|
||||
echo "Installing python packages" \
|
||||
&& apt-get update \
|
||||
&& apt-get install --yes --quiet ${PYTHON_PACKAGES}
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.6 /uv /bin/uv
|
||||
|
||||
RUN set -eux \
|
||||
&& echo "Installing pre-built updates" \
|
||||
&& echo "Installing qpdf ${QPDF_VERSION}" \
|
||||
@@ -120,15 +123,13 @@ RUN set -eux \
|
||||
WORKDIR /usr/src/paperless/src/docker/
|
||||
|
||||
COPY [ \
|
||||
"docker/rootfs/etc/ImageMagick-6/paperless-policy.xml", \
|
||||
"docker/imagemagick-policy.xml", \
|
||||
"./" \
|
||||
]
|
||||
|
||||
RUN set -eux \
|
||||
&& echo "Configuring ImageMagick" \
|
||||
&& mv paperless-policy.xml /etc/ImageMagick-6/policy.xml
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.6 /uv /bin/uv
|
||||
&& mv imagemagick-policy.xml /etc/ImageMagick-6/policy.xml
|
||||
|
||||
# Packages needed only for building a few quick Python
|
||||
# dependencies
|
||||
@@ -139,10 +140,11 @@ ARG BUILD_PACKAGES="\
|
||||
libpq-dev \
|
||||
# https://github.com/PyMySQL/mysqlclient#linux
|
||||
default-libmysqlclient-dev \
|
||||
pkg-config"
|
||||
pkg-config \
|
||||
pre-commit"
|
||||
|
||||
# hadolint ignore=DL3042
|
||||
RUN --mount=type=cache,target=/root/.cache/uv,id=pip-cache \
|
||||
RUN --mount=type=cache,target=/root/.cache/pip/,id=pip-cache \
|
||||
set -eux \
|
||||
&& echo "Installing build system packages" \
|
||||
&& apt-get update \
|
||||
@@ -167,6 +169,9 @@ RUN set -eux \
|
||||
&& mkdir --parents --verbose /usr/src/paperless/paperless-ngx/.venv \
|
||||
&& echo "Adjusting all permissions" \
|
||||
&& chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless
|
||||
# && echo "Collecting static files" \
|
||||
# && gosu paperless python3 manage.py collectstatic --clear --no-input --link \
|
||||
# && gosu paperless python3 manage.py compilemessages
|
||||
|
||||
VOLUME ["/usr/src/paperless/paperless-ngx/data", \
|
||||
"/usr/src/paperless/paperless-ngx/media", \
|
||||
|
@@ -1,117 +0,0 @@
|
||||
# Paperless-ngx Development Environment
|
||||
|
||||
## Overview
|
||||
|
||||
Welcome to the Paperless-ngx development environment! This setup uses VSCode DevContainers to provide a consistent and seamless development experience.
|
||||
|
||||
### What are DevContainers?
|
||||
|
||||
DevContainers are a feature in VSCode that allows you to develop within a Docker container. This ensures that your development environment is consistent across different machines and setups. By defining a containerized environment, you can eliminate the "works on my machine" problem.
|
||||
|
||||
### Advantages of DevContainers
|
||||
|
||||
- **Consistency**: Same environment for all developers.
|
||||
- **Isolation**: Separate development environment from your local machine.
|
||||
- **Reproducibility**: Easily recreate the environment on any machine.
|
||||
- **Pre-configured Tools**: Include all necessary tools and dependencies in the container.
|
||||
|
||||
## DevContainer Setup
|
||||
|
||||
The DevContainer configuration provides up all the necessary services for Paperless-ngx, including:
|
||||
|
||||
- Redis
|
||||
- Gotenberg
|
||||
- Tika
|
||||
|
||||
Data is stored using Docker volumes to ensure persistence across container restarts.
|
||||
|
||||
## Configuration Files
|
||||
|
||||
The setup includes debugging configurations (`launch.json`) and tasks (`tasks.json`) to help you manage and debug various parts of the project:
|
||||
|
||||
- **Backend Debugging:**
|
||||
- `manage.py runserver`
|
||||
- `manage.py document-consumer`
|
||||
- `celery`
|
||||
- **Maintenance Tasks:**
|
||||
- Create superuser
|
||||
- Run migrations
|
||||
- Recreate virtual environment (`.venv` with `uv`)
|
||||
- Compile frontend assets
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Step 1: Running the DevContainer
|
||||
|
||||
To start the DevContainer:
|
||||
|
||||
1. Open VSCode.
|
||||
2. Open the project folder.
|
||||
3. Open the command palette:
|
||||
- **Windows/Linux**: `Ctrl+Shift+P`
|
||||
- **Mac**: `Cmd+Shift+P`
|
||||
4. Type and select `Dev Containers: Rebuild and Reopen in Container`.
|
||||
|
||||
VSCode will build and start the DevContainer environment.
|
||||
|
||||
### Step 2: Initial Setup
|
||||
|
||||
Once the DevContainer is up and running, perform the following steps:
|
||||
|
||||
1. **Compile Frontend Assets**:
|
||||
|
||||
- Open the command palette:
|
||||
- **Windows/Linux**: `Ctrl+Shift+P`
|
||||
- **Mac**: `Cmd+Shift+P`
|
||||
- Select `Tasks: Run Task`.
|
||||
- Choose `Frontend Compile`.
|
||||
|
||||
2. **Run Database Migrations**:
|
||||
|
||||
- Open the command palette:
|
||||
- **Windows/Linux**: `Ctrl+Shift+P`
|
||||
- **Mac**: `Cmd+Shift+P`
|
||||
- Select `Tasks: Run Task`.
|
||||
- Choose `Migrate Database`.
|
||||
|
||||
3. **Create Superuser**:
|
||||
- Open the command palette:
|
||||
- **Windows/Linux**: `Ctrl+Shift+P`
|
||||
- **Mac**: `Cmd+Shift+P`
|
||||
- Select `Tasks: Run Task`.
|
||||
- Choose `Create Superuser`.
|
||||
|
||||
### Debugging and Running Services
|
||||
|
||||
You can start and debug backend services either as debugging sessions via `launch.json` or as tasks.
|
||||
|
||||
#### Using `launch.json`
|
||||
|
||||
1. Press `F5` or go to the **Run and Debug** view in VSCode.
|
||||
2. Select the desired configuration:
|
||||
- `Runserver`
|
||||
- `Document Consumer`
|
||||
- `Celery`
|
||||
|
||||
#### Using Tasks
|
||||
|
||||
1. Open the command palette:
|
||||
- **Windows/Linux**: `Ctrl+Shift+P`
|
||||
- **Mac**: `Cmd+Shift+P`
|
||||
2. Select `Tasks: Run Task`.
|
||||
3. Choose the desired task:
|
||||
- `Runserver`
|
||||
- `Document Consumer`
|
||||
- `Celery`
|
||||
|
||||
### Additional Maintenance Tasks
|
||||
|
||||
Additional tasks are available for common maintenance operations:
|
||||
|
||||
- **Recreate .venv**: For setting up the virtual environment using `uv`.
|
||||
- **Migrate Database**: To apply database migrations.
|
||||
- **Create Superuser**: To create an admin user for the application.
|
||||
|
||||
## Let's Get Started!
|
||||
|
||||
Follow the steps above to get your development environment up and running. Happy coding!
|
@@ -3,7 +3,7 @@
|
||||
"dockerComposeFile": "docker-compose.devcontainer.sqlite-tika.yml",
|
||||
"service": "paperless-development",
|
||||
"workspaceFolder": "/usr/src/paperless/paperless-ngx",
|
||||
"postCreateCommand": "/bin/bash -c uv sync --dev && uv run pre-commit install",
|
||||
"postCreateCommand": "pipenv install --dev && pipenv run pre-commit install",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
|
@@ -43,7 +43,7 @@ services:
|
||||
volumes:
|
||||
- ..:/usr/src/paperless/paperless-ngx:delegated
|
||||
- ../.devcontainer/vscode:/usr/src/paperless/paperless-ngx/.vscode:delegated # VSCode config files
|
||||
- virtualenv:/usr/src/paperless/paperless-ngx/.venv # Virtual environment persisted in volume
|
||||
- pipenv:/usr/src/paperless/paperless-ngx/.venv
|
||||
- /usr/src/paperless/paperless-ngx/src/documents/static/frontend # Static frontend files exist only in container
|
||||
- /usr/src/paperless/paperless-ngx/src/.pytest_cache
|
||||
- /usr/src/paperless/paperless-ngx/.ruff_cache
|
||||
@@ -65,7 +65,7 @@ services:
|
||||
command: /bin/sh -c "chown -R paperless:paperless /usr/src/paperless/paperless-ngx/src/documents/static/frontend && chown -R paperless:paperless /usr/src/paperless/paperless-ngx/.ruff_cache && while sleep 1000; do :; done"
|
||||
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.17
|
||||
image: docker.io/gotenberg/gotenberg:7.10
|
||||
restart: unless-stopped
|
||||
|
||||
# The Gotenberg Chromium route is used to convert .eml files. We do not
|
||||
@@ -80,7 +80,4 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
data:
|
||||
media:
|
||||
redisdata:
|
||||
virtualenv:
|
||||
pipenv:
|
||||
|
@@ -5,7 +5,7 @@
|
||||
"label": "Start: Celery Worker",
|
||||
"description": "Start the Celery Worker which processes background and consume tasks",
|
||||
"type": "shell",
|
||||
"command": "uv run celery --app paperless worker -l DEBUG",
|
||||
"command": "pipenv run celery --app paperless worker -l DEBUG",
|
||||
"isBackground": true,
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/src"
|
||||
@@ -61,7 +61,7 @@
|
||||
"label": "Start: Consumer Service (manage.py document_consumer)",
|
||||
"description": "Start the Consumer Service which processes files from a directory",
|
||||
"type": "shell",
|
||||
"command": "uv run python manage.py document_consumer",
|
||||
"command": "pipenv run python manage.py document_consumer",
|
||||
"group": "build",
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
@@ -80,7 +80,7 @@
|
||||
"label": "Start: Backend Server (manage.py runserver)",
|
||||
"description": "Start the Backend Server which serves the Django API and the compiled Angular frontend",
|
||||
"type": "shell",
|
||||
"command": "uv run python manage.py runserver",
|
||||
"command": "pipenv run python manage.py runserver",
|
||||
"group": "build",
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
@@ -99,7 +99,7 @@
|
||||
"label": "Maintenance: manage.py migrate",
|
||||
"description": "Apply database migrations",
|
||||
"type": "shell",
|
||||
"command": "uv run python manage.py migrate",
|
||||
"command": "pipenv run python manage.py migrate",
|
||||
"group": "none",
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
@@ -118,7 +118,7 @@
|
||||
"label": "Maintenance: Build Documentation",
|
||||
"description": "Build the documentation with MkDocs",
|
||||
"type": "shell",
|
||||
"command": "uv run mkdocs build --config-file mkdocs.yml && uv run mkdocs serve",
|
||||
"command": "pipenv run mkdocs build --config-file mkdocs.yml && pipenv run mkdocs serve",
|
||||
"group": "none",
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
@@ -137,7 +137,7 @@
|
||||
"label": "Maintenance: manage.py createsuperuser",
|
||||
"description": "Create a superuser",
|
||||
"type": "shell",
|
||||
"command": "uv run python manage.py createsuperuser",
|
||||
"command": "pipenv run python manage.py createsuperuser",
|
||||
"group": "none",
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
@@ -156,7 +156,7 @@
|
||||
"label": "Maintenance: recreate .venv",
|
||||
"description": "Recreate the python virtual environment and install python dependencies",
|
||||
"type": "shell",
|
||||
"command": "rm -R -v .venv/* || uv install --dev",
|
||||
"command": "rm -R -v .venv/* || pipenv install --dev",
|
||||
"group": "none",
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
|
@@ -27,6 +27,9 @@ indent_style = space
|
||||
[*.md]
|
||||
indent_style = space
|
||||
|
||||
[Pipfile.lock]
|
||||
indent_style = space
|
||||
|
||||
# Tests don't get a line width restriction. It's still a good idea to follow
|
||||
# the 79 character rule, but in the interests of clarity, tests often need to
|
||||
# violate it.
|
||||
|
12
.github/dependabot.yml
vendored
12
.github/dependabot.yml
vendored
@@ -1,8 +1,6 @@
|
||||
# https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/configuration-options-for-dependency-updates#package-ecosystem
|
||||
|
||||
version: 2
|
||||
# Required for uv support for now
|
||||
enable-beta-ecosystems: true
|
||||
updates:
|
||||
|
||||
# Enable version updates for npm
|
||||
@@ -36,8 +34,9 @@ updates:
|
||||
- "eslint"
|
||||
|
||||
# Enable version updates for Python
|
||||
- package-ecosystem: "uv"
|
||||
- package-ecosystem: "pip"
|
||||
target-branch: "dev"
|
||||
# Look for a `Pipfile` in the `root` directory
|
||||
directory: "/"
|
||||
# Check for updates once a week
|
||||
schedule:
|
||||
@@ -48,13 +47,14 @@ updates:
|
||||
# Add reviewers
|
||||
reviewers:
|
||||
- "paperless-ngx/backend"
|
||||
ignore:
|
||||
- dependency-name: "uvicorn"
|
||||
groups:
|
||||
development:
|
||||
patterns:
|
||||
- "*pytest*"
|
||||
- "ruff"
|
||||
- "mkdocs-material"
|
||||
- "pre-commit*"
|
||||
django:
|
||||
patterns:
|
||||
- "*django*"
|
||||
@@ -65,10 +65,6 @@ updates:
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
pre-built:
|
||||
patterns:
|
||||
- psycopg*
|
||||
- zxing-cpp
|
||||
|
||||
# Enable updates for GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
|
152
.github/workflows/ci.yml
vendored
152
.github/workflows/ci.yml
vendored
@@ -14,7 +14,9 @@ on:
|
||||
- 'translations**'
|
||||
|
||||
env:
|
||||
DEFAULT_UV_VERSION: "0.6.x"
|
||||
# This is the version of pipenv all the steps will use
|
||||
# If changing this, change Dockerfile
|
||||
DEFAULT_PIP_ENV_VERSION: "2024.4.1"
|
||||
# This is the default version of Python to use in most steps which aren't specific
|
||||
DEFAULT_PYTHON_VERSION: "3.11"
|
||||
|
||||
@@ -57,25 +59,24 @@ jobs:
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
cache: "pipenv"
|
||||
cache-dependency-path: 'Pipfile.lock'
|
||||
-
|
||||
name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
-
|
||||
name: Install Python dependencies
|
||||
name: Install pipenv
|
||||
run: |
|
||||
uv sync --python ${{ steps.setup-python.outputs.python-version }} --dev --frozen
|
||||
pip install --user pipenv==${{ env.DEFAULT_PIP_ENV_VERSION }}
|
||||
-
|
||||
name: Install dependencies
|
||||
run: |
|
||||
pipenv --python ${{ steps.setup-python.outputs.python-version }} sync --dev
|
||||
-
|
||||
name: List installed Python dependencies
|
||||
run: |
|
||||
pipenv --python ${{ steps.setup-python.outputs.python-version }} run pip list
|
||||
-
|
||||
name: Make documentation
|
||||
run: |
|
||||
uv run \
|
||||
--python ${{ steps.setup-python.outputs.python-version }} \
|
||||
--dev \
|
||||
--frozen \
|
||||
mkdocs build --config-file ./mkdocs.yml
|
||||
pipenv --python ${{ steps.setup-python.outputs.python-version }} run mkdocs build --config-file ./mkdocs.yml
|
||||
-
|
||||
name: Deploy documentation
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
@@ -83,11 +84,7 @@ jobs:
|
||||
echo "docs.paperless-ngx.com" > "${{ github.workspace }}/docs/CNAME"
|
||||
git config --global user.name "${{ github.actor }}"
|
||||
git config --global user.email "${{ github.actor }}@users.noreply.github.com"
|
||||
uv run \
|
||||
--python ${{ steps.setup-python.outputs.python-version }} \
|
||||
--dev \
|
||||
--frozen \
|
||||
mkdocs gh-deploy --force --no-history
|
||||
pipenv --python ${{ steps.setup-python.outputs.python-version }} run mkdocs gh-deploy --force --no-history
|
||||
-
|
||||
name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -120,13 +117,12 @@ jobs:
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "${{ matrix.python-version }}"
|
||||
cache: "pipenv"
|
||||
cache-dependency-path: 'Pipfile.lock'
|
||||
-
|
||||
name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
python-version: ${{ steps.setup-python.outputs.python-version }}
|
||||
name: Install pipenv
|
||||
run: |
|
||||
pip install --user pipenv==${{ env.DEFAULT_PIP_ENV_VERSION }}
|
||||
-
|
||||
name: Install system dependencies
|
||||
run: |
|
||||
@@ -139,14 +135,12 @@ jobs:
|
||||
-
|
||||
name: Install Python dependencies
|
||||
run: |
|
||||
uv sync \
|
||||
--python ${{ steps.setup-python.outputs.python-version }} \
|
||||
--group testing \
|
||||
--frozen
|
||||
pipenv --python ${{ steps.setup-python.outputs.python-version }} run python --version
|
||||
pipenv --python ${{ steps.setup-python.outputs.python-version }} sync --dev
|
||||
-
|
||||
name: List installed Python dependencies
|
||||
run: |
|
||||
uv pip list
|
||||
pipenv --python ${{ steps.setup-python.outputs.python-version }} run pip list
|
||||
-
|
||||
name: Tests
|
||||
env:
|
||||
@@ -156,22 +150,17 @@ jobs:
|
||||
PAPERLESS_MAIL_TEST_USER: ${{ secrets.TEST_MAIL_USER }}
|
||||
PAPERLESS_MAIL_TEST_PASSWD: ${{ secrets.TEST_MAIL_PASSWD }}
|
||||
run: |
|
||||
uv run \
|
||||
--python ${{ steps.setup-python.outputs.python-version }} \
|
||||
--dev \
|
||||
--frozen \
|
||||
pytest
|
||||
cd src/
|
||||
pipenv --python ${{ steps.setup-python.outputs.python-version }} run pytest -ra
|
||||
-
|
||||
name: Upload coverage
|
||||
if: ${{ matrix.python-version == env.DEFAULT_PYTHON_VERSION }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: backend-coverage-report
|
||||
path: |
|
||||
coverage.xml
|
||||
junit.xml
|
||||
path: src/coverage.xml
|
||||
retention-days: 7
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
-
|
||||
name: Stop containers
|
||||
if: always()
|
||||
@@ -245,8 +234,6 @@ jobs:
|
||||
run: cd src-ui && npm run lint
|
||||
-
|
||||
name: Run Jest unit tests
|
||||
env:
|
||||
JEST_JUNIT_OUTPUT_FILE: junit-report-${{ matrix.shard-index }}.xml
|
||||
run: cd src-ui && npm run test -- --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
||||
-
|
||||
name: Upload Jest coverage
|
||||
@@ -259,7 +246,7 @@ jobs:
|
||||
src-ui/coverage/lcov.info
|
||||
src-ui/coverage/clover.xml
|
||||
retention-days: 7
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
-
|
||||
name: Run Playwright e2e tests
|
||||
run: cd src-ui && npx playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
||||
@@ -271,16 +258,6 @@ jobs:
|
||||
name: playwright-report-${{ matrix.shard-index }}
|
||||
path: src-ui/playwright-report
|
||||
retention-days: 7
|
||||
if-no-files-found: error
|
||||
-
|
||||
name: Upload frontend test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: junit-report-${{ matrix.shard-index }}
|
||||
path: src-ui/junit-report-${{ matrix.shard-index }}.xml
|
||||
retention-days: 7
|
||||
if-no-files-found: error
|
||||
|
||||
tests-coverage-upload:
|
||||
name: "Upload to Codecov"
|
||||
@@ -304,13 +281,6 @@ jobs:
|
||||
path: src-ui/coverage/
|
||||
pattern: playwright-report-*
|
||||
merge-multiple: true
|
||||
-
|
||||
name: Download frontend test results
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: src-ui/junit/
|
||||
pattern: junit-report-*
|
||||
merge-multiple: true
|
||||
-
|
||||
name: Upload frontend coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
@@ -321,14 +291,6 @@ jobs:
|
||||
directory: src-ui/coverage/
|
||||
# dont include backend coverage files here
|
||||
files: '!coverage.xml'
|
||||
-
|
||||
name: Upload frontend test results to Codecov
|
||||
if: ${{ !cancelled() }}
|
||||
uses: codecov/test-results-action@v1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
flags: frontend
|
||||
directory: src-ui/junit/
|
||||
-
|
||||
name: Download backend coverage
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -344,14 +306,6 @@ jobs:
|
||||
# future expansion
|
||||
flags: backend
|
||||
directory: src/
|
||||
-
|
||||
name: Upload backend test results to Codecov
|
||||
if: ${{ !cancelled() }}
|
||||
uses: codecov/test-results-action@v1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
flags: backend
|
||||
directory: src/
|
||||
-
|
||||
name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
@@ -518,17 +472,16 @@ jobs:
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
cache: "pipenv"
|
||||
cache-dependency-path: 'Pipfile.lock'
|
||||
-
|
||||
name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
python-version: ${{ steps.setup-python.outputs.python-version }}
|
||||
name: Install pipenv + tools
|
||||
run: |
|
||||
pip install --upgrade --user pipenv==${{ env.DEFAULT_PIP_ENV_VERSION }} setuptools wheel
|
||||
-
|
||||
name: Install Python dependencies
|
||||
run: |
|
||||
uv sync --python ${{ steps.setup-python.outputs.python-version }} --dev --frozen
|
||||
pipenv --python ${{ steps.setup-python.outputs.python-version }} sync --dev
|
||||
-
|
||||
name: Install system dependencies
|
||||
run: |
|
||||
@@ -549,21 +502,17 @@ jobs:
|
||||
-
|
||||
name: Generate requirements file
|
||||
run: |
|
||||
uv export --quiet --no-dev --all-extras --format requirements-txt --output-file requirements.txt
|
||||
pipenv --python ${{ steps.setup-python.outputs.python-version }} requirements > requirements.txt
|
||||
-
|
||||
name: Compile messages
|
||||
run: |
|
||||
cd src/
|
||||
uv run \
|
||||
--python ${{ steps.setup-python.outputs.python-version }} \
|
||||
manage.py compilemessages
|
||||
pipenv --python ${{ steps.setup-python.outputs.python-version }} run python3 manage.py compilemessages
|
||||
-
|
||||
name: Collect static files
|
||||
run: |
|
||||
cd src/
|
||||
uv run \
|
||||
--python ${{ steps.setup-python.outputs.python-version }} \
|
||||
manage.py collectstatic --no-input
|
||||
pipenv --python ${{ steps.setup-python.outputs.python-version }} run python3 manage.py collectstatic --no-input
|
||||
-
|
||||
name: Move files
|
||||
run: |
|
||||
@@ -579,13 +528,13 @@ jobs:
|
||||
for file_name in .dockerignore \
|
||||
.env \
|
||||
Dockerfile \
|
||||
pyproject.toml \
|
||||
uv.lock \
|
||||
Pipfile \
|
||||
Pipfile.lock \
|
||||
requirements.txt \
|
||||
LICENSE \
|
||||
README.md \
|
||||
paperless.conf.example \
|
||||
webserver.py
|
||||
gunicorn.conf.py
|
||||
do
|
||||
cp --verbose ${file_name} dist/paperless-ngx/
|
||||
done
|
||||
@@ -682,17 +631,15 @@ jobs:
|
||||
ref: main
|
||||
-
|
||||
name: Set up Python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
cache: "pipenv"
|
||||
cache-dependency-path: 'Pipfile.lock'
|
||||
-
|
||||
name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
name: Install pipenv + tools
|
||||
run: |
|
||||
pip install --upgrade --user pipenv==${{ env.DEFAULT_PIP_ENV_VERSION }} setuptools wheel
|
||||
-
|
||||
name: Append Changelog to docs
|
||||
id: append-Changelog
|
||||
@@ -708,10 +655,7 @@ jobs:
|
||||
CURRENT_CHANGELOG=`tail --lines +2 changelog.md`
|
||||
echo -e "$CURRENT_CHANGELOG" >> changelog-new.md
|
||||
mv changelog-new.md changelog.md
|
||||
uv run \
|
||||
--python ${{ steps.setup-python.outputs.python-version }} \
|
||||
--dev \
|
||||
pre-commit run --files changelog.md || true
|
||||
pipenv run pre-commit run --files changelog.md || true
|
||||
git config --global user.name "github-actions"
|
||||
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git commit -am "Changelog ${{ needs.publish-release.outputs.version }} - GHA"
|
||||
|
4
.github/workflows/cleanup-tags.yml
vendored
4
.github/workflows/cleanup-tags.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
-
|
||||
name: Clean temporary images
|
||||
if: "${{ env.TOKEN != '' }}"
|
||||
uses: stumpylog/image-cleaner-action/ephemeral@v0.10.0
|
||||
uses: stumpylog/image-cleaner-action/ephemeral@v0.9.0
|
||||
with:
|
||||
token: "${{ env.TOKEN }}"
|
||||
owner: "${{ github.repository_owner }}"
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
-
|
||||
name: Clean untagged images
|
||||
if: "${{ env.TOKEN != '' }}"
|
||||
uses: stumpylog/image-cleaner-action/untagged@v0.10.0
|
||||
uses: stumpylog/image-cleaner-action/untagged@v0.9.0
|
||||
with:
|
||||
token: "${{ env.TOKEN }}"
|
||||
owner: "${{ github.repository_owner }}"
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -44,7 +44,6 @@ nosetests.xml
|
||||
coverage.xml
|
||||
*,cover
|
||||
.pytest_cache
|
||||
junit.xml
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
|
@@ -45,19 +45,16 @@ repos:
|
||||
- javascript
|
||||
- ts
|
||||
- markdown
|
||||
exclude: "(^Pipfile\\.lock$)"
|
||||
additional_dependencies:
|
||||
- prettier@3.3.3
|
||||
- 'prettier-plugin-organize-imports@4.1.0'
|
||||
# Python hooks
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.9.9
|
||||
rev: v0.9.6
|
||||
hooks:
|
||||
- id: ruff
|
||||
- id: ruff-format
|
||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
||||
rev: "v2.5.1"
|
||||
hooks:
|
||||
- id: pyproject-fmt
|
||||
# Dockerfile hooks
|
||||
- repo: https://github.com/AleksaC/hadolint-py
|
||||
rev: v2.12.0.3
|
||||
|
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.10.15
|
87
.ruff.toml
Normal file
87
.ruff.toml
Normal file
@@ -0,0 +1,87 @@
|
||||
fix = true
|
||||
line-length = 88
|
||||
respect-gitignore = true
|
||||
src = ["src"]
|
||||
target-version = "py310"
|
||||
output-format = "grouped"
|
||||
show-fixes = true
|
||||
|
||||
# https://docs.astral.sh/ruff/settings/
|
||||
# https://docs.astral.sh/ruff/rules/
|
||||
[lint]
|
||||
extend-select = [
|
||||
"W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
|
||||
"I", # https://docs.astral.sh/ruff/rules/#isort-i
|
||||
"UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
|
||||
"COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com
|
||||
"DJ", # https://docs.astral.sh/ruff/rules/#flake8-django-dj
|
||||
"EXE", # https://docs.astral.sh/ruff/rules/#flake8-executable-exe
|
||||
"ISC", # https://docs.astral.sh/ruff/rules/#flake8-implicit-str-concat-isc
|
||||
"ICN", # https://docs.astral.sh/ruff/rules/#flake8-import-conventions-icn
|
||||
"G201", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g
|
||||
"INP", # https://docs.astral.sh/ruff/rules/#flake8-no-pep420-inp
|
||||
"PIE", # https://docs.astral.sh/ruff/rules/#flake8-pie-pie
|
||||
"Q", # https://docs.astral.sh/ruff/rules/#flake8-quotes-q
|
||||
"RSE", # https://docs.astral.sh/ruff/rules/#flake8-raise-rse
|
||||
"T20", # https://docs.astral.sh/ruff/rules/#flake8-print-t20
|
||||
"SIM", # https://docs.astral.sh/ruff/rules/#flake8-simplify-sim
|
||||
"TID", # https://docs.astral.sh/ruff/rules/#flake8-tidy-imports-tid
|
||||
"TCH", # https://docs.astral.sh/ruff/rules/#flake8-type-checking-tch
|
||||
"PLC", # https://docs.astral.sh/ruff/rules/#pylint-pl
|
||||
"PLE", # https://docs.astral.sh/ruff/rules/#pylint-pl
|
||||
"RUF", # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf
|
||||
"FLY", # https://docs.astral.sh/ruff/rules/#flynt-fly
|
||||
"PTH", # https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth
|
||||
"FBT", # https://docs.astral.sh/ruff/rules/#flake8-boolean-trap-fbt
|
||||
]
|
||||
ignore = ["DJ001", "SIM105", "RUF012"]
|
||||
|
||||
[lint.per-file-ignores]
|
||||
".github/scripts/*.py" = ["E501", "INP001", "SIM117"]
|
||||
"docker/wait-for-redis.py" = ["INP001", "T201"]
|
||||
"src/documents/file_handling.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/documents/management/commands/document_consumer.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/documents/management/commands/document_exporter.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/documents/migrations/0012_auto_20160305_0040.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/documents/migrations/0014_document_checksum.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/documents/migrations/1003_mime_types.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/documents/migrations/1012_fix_archive_files.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/documents/models.py" = ["SIM115", "PTH"] # TODO PTH Enable & remove
|
||||
"src/documents/parsers.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/documents/signals/handlers.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/documents/tasks.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/documents/tests/test_api_app_config.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/documents/tests/test_classifier.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/documents/tests/test_consumer.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/documents/tests/test_file_handling.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/documents/tests/test_management.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/documents/tests/test_management_consumer.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/documents/tests/test_management_exporter.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/documents/tests/test_management_thumbnails.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/documents/tests/test_migration_archive_files.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/documents/tests/test_migration_document_pages_count.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/documents/tests/test_migration_mime_type.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/documents/tests/test_sanity_check.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/documents/tests/test_tasks.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/documents/tests/test_views.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/documents/views.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/paperless/checks.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/paperless/settings.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/paperless/tests/test_checks.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/paperless/urls.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/paperless/views.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/paperless_mail/mail.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/paperless_mail/preprocessor.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/paperless_tesseract/parsers.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/paperless_tesseract/tests/test_parser.py" = ["RUF001", "PTH"] # TODO PTH Enable & remove
|
||||
"src/paperless_tika/tests/test_live_tika.py" = ["PTH"] # TODO Enable & remove
|
||||
"src/paperless_tika/tests/test_tika_parser.py" = ["PTH"] # TODO Enable & remove
|
||||
# Testing
|
||||
"*/tests/*.py" = ["E501", "SIM117"]
|
||||
# Migrations
|
||||
"*/migrations/*.py" = ["E501", "SIM", "T201"]
|
||||
# Docker specific
|
||||
"docker/rootfs/usr/local/bin/wait-for-redis.py" = ["INP001", "T201"]
|
||||
|
||||
[lint.isort]
|
||||
force-single-line = true
|
@@ -5,6 +5,5 @@
|
||||
/src-ui/ @paperless-ngx/frontend
|
||||
|
||||
/src/ @paperless-ngx/backend
|
||||
pyproject.toml @paperless-ngx/backend
|
||||
uv.lock @paperless-ngx/backend
|
||||
Pipfile* @paperless-ngx/backend
|
||||
*.py @paperless-ngx/backend
|
||||
|
50
Dockerfile
50
Dockerfile
@@ -26,11 +26,28 @@ esac
|
||||
RUN set -eux \
|
||||
&& ./node_modules/.bin/ng build --configuration production
|
||||
|
||||
# Stage: pipenv-base
|
||||
# Purpose: Generates a requirements.txt file for building
|
||||
# Comments:
|
||||
# - pipenv dependencies are not left in the final image
|
||||
# - pipenv can't touch the final image somehow
|
||||
FROM --platform=$BUILDPLATFORM docker.io/python:3.12-alpine AS pipenv-base
|
||||
|
||||
WORKDIR /usr/src/pipenv
|
||||
|
||||
COPY Pipfile* ./
|
||||
|
||||
RUN set -eux \
|
||||
&& echo "Installing pipenv" \
|
||||
&& python3 -m pip install --no-cache-dir --upgrade pipenv==2024.4.1 \
|
||||
&& echo "Generating requirement.txt" \
|
||||
&& pipenv requirements > requirements.txt
|
||||
|
||||
# Stage: s6-overlay-base
|
||||
# Purpose: Installs s6-overlay and rootfs
|
||||
# Comments:
|
||||
# - Don't leave anything extra in here either
|
||||
FROM ghcr.io/astral-sh/uv:0.6.3-python3.12-bookworm-slim AS s6-overlay-base
|
||||
FROM docker.io/python:3.12-slim-bookworm AS s6-overlay-base
|
||||
|
||||
WORKDIR /usr/src/s6
|
||||
|
||||
@@ -106,12 +123,9 @@ ARG GS_VERSION=10.03.1
|
||||
# Set Python environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
# Ignore warning from Whitenoise about async iterators
|
||||
# Ignore warning from Whitenoise
|
||||
PYTHONWARNINGS="ignore:::django.http.response:517" \
|
||||
PNGX_CONTAINERIZED=1 \
|
||||
# https://docs.astral.sh/uv/reference/settings/#link-mode
|
||||
UV_LINK_MODE=copy \
|
||||
UV_CACHE_DIR=/cache/uv/
|
||||
PNGX_CONTAINERIZED=1
|
||||
|
||||
#
|
||||
# Begin installation and configuration
|
||||
@@ -190,34 +204,46 @@ RUN set -eux \
|
||||
&& rm --force --verbose *.deb \
|
||||
&& rm --recursive --force --verbose /var/lib/apt/lists/*
|
||||
|
||||
# Copy webserver config
|
||||
# Copy gunicorn config
|
||||
# Changes very infrequently
|
||||
WORKDIR /usr/src/paperless/
|
||||
COPY --chown=1000:1000 webserver.py /usr/src/paperless/webserver.py
|
||||
|
||||
COPY --chown=1000:1000 gunicorn.conf.py /usr/src/paperless/gunicorn.conf.py
|
||||
|
||||
WORKDIR /usr/src/paperless/src/
|
||||
|
||||
# Python dependencies
|
||||
# Change pretty frequently
|
||||
COPY --chown=1000:1000 ["pyproject.toml", "uv.lock", "/usr/src/paperless/src/"]
|
||||
COPY --chown=1000:1000 --from=pipenv-base /usr/src/pipenv/requirements.txt ./
|
||||
|
||||
# Packages needed only for building a few quick Python
|
||||
# dependencies
|
||||
ARG BUILD_PACKAGES="\
|
||||
build-essential \
|
||||
git \
|
||||
# https://www.psycopg.org/docs/install.html#prerequisites
|
||||
libpq-dev \
|
||||
# https://github.com/PyMySQL/mysqlclient#linux
|
||||
default-libmysqlclient-dev \
|
||||
pkg-config"
|
||||
|
||||
ARG ZXING_VERSION=2.3.0
|
||||
ARG PSYCOPG_VERSION=3.2.4
|
||||
|
||||
# hadolint ignore=DL3042
|
||||
RUN --mount=type=cache,target=${UV_CACHE_DIR},id=python-cache \
|
||||
RUN --mount=type=cache,target=/root/.cache/pip/,id=pip-cache \
|
||||
set -eux \
|
||||
&& echo "Installing build system packages" \
|
||||
&& apt-get update \
|
||||
&& apt-get install --yes --quiet --no-install-recommends ${BUILD_PACKAGES} \
|
||||
&& python3 -m pip install --upgrade wheel \
|
||||
&& echo "Installing Python requirements" \
|
||||
&& uv export --quiet --no-dev --all-extras --format requirements-txt --output-file requirements.txt \
|
||||
&& uv pip install --system --no-python-downloads --python-preference system --requirements requirements.txt \
|
||||
&& curl --fail --silent --no-progress-meter --show-error --location --remote-name-all --parallel --parallel-max 4 \
|
||||
https://github.com/paperless-ngx/builder/releases/download/psycopg-${PSYCOPG_VERSION}/psycopg_c-${PSYCOPG_VERSION}-cp312-cp312-linux_x86_64.whl \
|
||||
https://github.com/paperless-ngx/builder/releases/download/psycopg-${PSYCOPG_VERSION}/psycopg_c-${PSYCOPG_VERSION}-cp312-cp312-linux_aarch64.whl \
|
||||
https://github.com/paperless-ngx/builder/releases/download/zxing-${ZXING_VERSION}/zxing_cpp-${ZXING_VERSION}-cp312-cp312-linux_aarch64.whl \
|
||||
https://github.com/paperless-ngx/builder/releases/download/zxing-${ZXING_VERSION}/zxing_cpp-${ZXING_VERSION}-cp312-cp312-linux_x86_64.whl \
|
||||
&& python3 -m pip install --default-timeout=1000 --find-links . --requirement requirements.txt \
|
||||
&& echo "Installing NLTK data" \
|
||||
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" snowball_data \
|
||||
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" stopwords \
|
||||
|
102
Pipfile
Normal file
102
Pipfile
Normal file
@@ -0,0 +1,102 @@
|
||||
[[source]]
|
||||
url = "https://pypi.python.org/simple"
|
||||
verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
[packages]
|
||||
dateparser = "~=1.2"
|
||||
# WARNING: django does not use semver.
|
||||
# Only patch versions are guaranteed to not introduce breaking changes.
|
||||
django = "~=5.1.5"
|
||||
django-allauth = {extras = ["mfa", "socialaccount"], version = "*"}
|
||||
django-auditlog = "*"
|
||||
django-celery-results = "*"
|
||||
django-compression-middleware = "*"
|
||||
django-cors-headers = "*"
|
||||
django-extensions = "*"
|
||||
django-filter = "~=25.1"
|
||||
django-guardian = "*"
|
||||
django-multiselectfield = "*"
|
||||
django-soft-delete = "*"
|
||||
djangorestframework = "~=3.15.2"
|
||||
djangorestframework-guardian = "*"
|
||||
drf-spectacular = "*"
|
||||
drf-spectacular-sidecar = "*"
|
||||
drf-writable-nested = "*"
|
||||
bleach = "*"
|
||||
celery = {extras = ["redis"], version = "*"}
|
||||
channels = "~=4.2"
|
||||
channels-redis = "*"
|
||||
concurrent-log-handler = "*"
|
||||
filelock = "*"
|
||||
flower = "*"
|
||||
gotenberg-client = "*"
|
||||
gunicorn = "*"
|
||||
httpx-oauth = "*"
|
||||
imap-tools = "*"
|
||||
inotifyrecursive = "~=0.3"
|
||||
jinja2 = "~=3.1"
|
||||
langdetect = "*"
|
||||
mysqlclient = "*"
|
||||
nltk = "*"
|
||||
ocrmypdf = "~=16.9"
|
||||
pathvalidate = "*"
|
||||
pdf2image = "*"
|
||||
psycopg = {version = "*", extras = ["c"]}
|
||||
python-dateutil = "*"
|
||||
python-dotenv = "*"
|
||||
python-gnupg = "*"
|
||||
python-ipware = "*"
|
||||
python-magic = "*"
|
||||
pyzbar = "*"
|
||||
rapidfuzz = "*"
|
||||
redis = {extras = ["hiredis"], version = "*"}
|
||||
scikit-learn = "~=1.6"
|
||||
setproctitle = "*"
|
||||
tika-client = "*"
|
||||
tqdm = "*"
|
||||
# See https://github.com/paperless-ngx/paperless-ngx/issues/5494
|
||||
uvicorn = {extras = ["standard"], version = "==0.25.0"}
|
||||
watchdog = "~=6.0"
|
||||
whitenoise = "~=6.9"
|
||||
whoosh = "~=2.7"
|
||||
zxing-cpp = "*"
|
||||
|
||||
|
||||
[dev-packages]
|
||||
# Linting
|
||||
pre-commit = "*"
|
||||
ruff = "*"
|
||||
factory-boy = "*"
|
||||
# Testing
|
||||
pytest = "*"
|
||||
pytest-cov = "*"
|
||||
pytest-django = "*"
|
||||
pytest-httpx = "*"
|
||||
pytest-env = "*"
|
||||
pytest-sugar = "*"
|
||||
pytest-xdist = "*"
|
||||
pytest-mock = "*"
|
||||
pytest-rerunfailures = "*"
|
||||
imagehash = "*"
|
||||
daphne = "*"
|
||||
# Documentation
|
||||
mkdocs-material = "*"
|
||||
mkdocs-glightbox = "*"
|
||||
|
||||
[typing-dev]
|
||||
mypy = "*"
|
||||
types-Pillow = "*"
|
||||
django-filter-stubs = "*"
|
||||
types-python-dateutil = "*"
|
||||
djangorestframework-stubs = {extras= ["compatible-mypy"], version="*"}
|
||||
celery-types = "*"
|
||||
django-stubs = {extras= ["compatible-mypy"], version="*"}
|
||||
types-dateparser = "*"
|
||||
types-bleach = "*"
|
||||
types-redis = "*"
|
||||
types-tqdm = "*"
|
||||
types-Markdown = "*"
|
||||
types-Pygments = "*"
|
||||
types-colorama = "*"
|
||||
types-setuptools = "*"
|
4978
Pipfile.lock
generated
Normal file
4978
Pipfile.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@
|
||||
|
||||
services:
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.17
|
||||
image: docker.io/gotenberg/gotenberg:8.7
|
||||
hostname: gotenberg
|
||||
container_name: gotenberg
|
||||
network_mode: host
|
||||
|
@@ -77,7 +77,7 @@ services:
|
||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.17
|
||||
image: docker.io/gotenberg/gotenberg:8.7
|
||||
restart: unless-stopped
|
||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||
# want to allow external content like tracking pixels or even javascript.
|
||||
|
@@ -71,7 +71,7 @@ services:
|
||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.17
|
||||
image: docker.io/gotenberg/gotenberg:8.7
|
||||
restart: unless-stopped
|
||||
|
||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||
|
@@ -59,7 +59,7 @@ services:
|
||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.17
|
||||
image: docker.io/gotenberg/gotenberg:8.7
|
||||
restart: unless-stopped
|
||||
|
||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||
|
@@ -1,18 +1,10 @@
|
||||
#!/command/with-contenv /usr/bin/bash
|
||||
# shellcheck shell=bash
|
||||
|
||||
cd ${PAPERLESS_SRC_DIR}
|
||||
|
||||
if [[ -n "${PAPERLESS_CONSUMER_DISABLE}" ]]; then
|
||||
echo "[svc-consumer] Consumer is disabled, exiting"
|
||||
# https://skarnet.org/software/s6/s6-svc.html
|
||||
s6-svc -Od .
|
||||
|
||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||
exec python3 manage.py document_consumer
|
||||
else
|
||||
cd ${PAPERLESS_SRC_DIR}
|
||||
|
||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||
exec python3 manage.py document_consumer
|
||||
else
|
||||
exec s6-setuidgid paperless python3 manage.py document_consumer
|
||||
fi
|
||||
exec s6-setuidgid paperless python3 manage.py document_consumer
|
||||
fi
|
||||
|
@@ -4,7 +4,7 @@
|
||||
cd ${PAPERLESS_SRC_DIR}
|
||||
|
||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||
exec python3 /usr/src/paperless/webserver.py
|
||||
exec /usr/local/bin/gunicorn -c /usr/src/paperless/gunicorn.conf.py paperless.asgi:application
|
||||
else
|
||||
exec s6-setuidgid paperless python3 /usr/src/paperless/webserver.py
|
||||
exec s6-setuidgid paperless /usr/local/bin/gunicorn -c /usr/src/paperless/gunicorn.conf.py paperless.asgi:application
|
||||
fi
|
||||
|
@@ -509,12 +509,6 @@ Invoice_{{ custom_fields|get_cf_value("Select Field") }}_{{ custom_fields|get_cf
|
||||
|
||||
This will create a path like `invoices/2022/01/01/Invoice_OptionTwo_20220101.pdf` if the custom field "Date Field" is set to January 1, 2022 and "Select Field" is set to `OptionTwo`.
|
||||
|
||||
You can also use a custom `slugify` filter to slufigy text:
|
||||
|
||||
```jinja
|
||||
{{ title | slugify }}
|
||||
```
|
||||
|
||||
## Automatic recovery of invalid PDFs {#pdf-recovery}
|
||||
|
||||
Paperless will attempt to "clean" certain invalid PDFs with `qpdf` before processing if, for example, the mime_type
|
||||
|
@@ -557,20 +557,6 @@ This is for use with self-signed certificates against local IMAP servers.
|
||||
Settings this value has security implications for the security of your email.
|
||||
Understand what it does and be sure you need to before setting.
|
||||
|
||||
### Authentication & SSO {#authentication}
|
||||
|
||||
#### [`PAPERLESS_ACCOUNT_ALLOW_SIGNUPS=<bool>`](#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS) {#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS}
|
||||
|
||||
: Allow users to signup for a new Paperless-ngx account.
|
||||
|
||||
Defaults to False
|
||||
|
||||
#### [`PAPERLESS_ACCOUNT_DEFAULT_GROUPS=<comma-separated-list>`](#PAPERLESS_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_ACCOUNT_DEFAULT_GROUPS}
|
||||
|
||||
: A list of group names that users will be added to when they sign up for a new account. Groups listed here must already exist.
|
||||
|
||||
Defaults to None
|
||||
|
||||
#### [`PAPERLESS_SOCIALACCOUNT_PROVIDERS=<json>`](#PAPERLESS_SOCIALACCOUNT_PROVIDERS) {#PAPERLESS_SOCIALACCOUNT_PROVIDERS}
|
||||
|
||||
: This variable is used to setup login and signup via social account providers which are compatible with django-allauth.
|
||||
@@ -594,25 +580,12 @@ system. See the corresponding
|
||||
|
||||
Defaults to True
|
||||
|
||||
#### [`PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS=<bool>`](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS}
|
||||
#### [`PAPERLESS_ACCOUNT_ALLOW_SIGNUPS=<bool>`](#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS) {#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS}
|
||||
|
||||
: Sync groups from the third party authentication system (e.g. OIDC) to Paperless-ngx. When enabled, users will be added or removed from groups based on their group membership in the third party authentication system. Groups must already exist in Paperless-ngx and have the same name as in the third party authentication system. Groups are updated upon logging in via the third party authentication system, see the corresponding [django-allauth documentation](https://docs.allauth.org/en/dev/socialaccount/signals.html).
|
||||
|
||||
: In order to pass groups from the authentication system you will need to update your [PAPERLESS_SOCIALACCOUNT_PROVIDERS](#PAPERLESS_SOCIALACCOUNT_PROVIDERS) setting by adding a top-level "SCOPES" setting which includes "groups", e.g.:
|
||||
|
||||
```json
|
||||
{"openid_connect":{"SCOPE": ["openid","profile","email","groups"]...
|
||||
```
|
||||
: Allow users to signup for a new Paperless-ngx account.
|
||||
|
||||
Defaults to False
|
||||
|
||||
#### [`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.
|
||||
If both the [PAPERLESS_ACCOUNT_DEFAULT_GROUPS](#PAPERLESS_ACCOUNT_DEFAULT_GROUPS) setting and this setting are used, the user will be added to both sets of groups.
|
||||
|
||||
Defaults to None
|
||||
|
||||
#### [`PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL=<string>`](#PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL) {#PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL}
|
||||
|
||||
: The protocol used when generating URLs, e.g. login callback URLs. See the corresponding
|
||||
@@ -1057,11 +1030,6 @@ be used with caution!
|
||||
|
||||
## Document Consumption {#consume_config}
|
||||
|
||||
#### [`PAPERLESS_CONSUMER_DISABLE=<bool>`](#PAPERLESS_CONSUMER_DISABLE) {#PAPERLESS_CONSUMER_DISABLE}
|
||||
|
||||
: Completely disable the directory-based consumer in docker. If you don't plan to consume documents
|
||||
via the consumption directory, you can disable the consumer to save resources.
|
||||
|
||||
#### [`PAPERLESS_CONSUMER_DELETE_DUPLICATES=<bool>`](#PAPERLESS_CONSUMER_DELETE_DUPLICATES) {#PAPERLESS_CONSUMER_DELETE_DUPLICATES}
|
||||
|
||||
: When the consumer detects a duplicate document, it will not touch
|
||||
@@ -1538,23 +1506,13 @@ increase RAM usage.
|
||||
|
||||
Defaults to 1.
|
||||
|
||||
!!! note
|
||||
|
||||
This option may also be set with `GRANIAN_WORKERS` and
|
||||
this option may be removed in the future
|
||||
|
||||
#### [`PAPERLESS_BIND_ADDR=<ip address>`](#PAPERLESS_BIND_ADDR) {#PAPERLESS_BIND_ADDR}
|
||||
|
||||
: The IP address the webserver will listen on inside the container.
|
||||
There are special setups where you may need to configure this value
|
||||
to restrict the Ip address or interface the webserver listens on.
|
||||
|
||||
Defaults to `::`, meaning all interfaces, including IPv6.
|
||||
|
||||
!!! note
|
||||
|
||||
This option may also be set with `GRANIAN_HOST` and
|
||||
this option may be removed in the future
|
||||
Defaults to `[::]`, meaning all interfaces, including IPv6.
|
||||
|
||||
#### [`PAPERLESS_PORT=<port>`](#PAPERLESS_PORT) {#PAPERLESS_PORT}
|
||||
|
||||
@@ -1569,11 +1527,6 @@ one pod).
|
||||
|
||||
Defaults to 8000.
|
||||
|
||||
!!! note
|
||||
|
||||
This option may also be set with `GRANIAN_PORT` and
|
||||
this option may be removed in the future
|
||||
|
||||
#### [`USERMAP_UID=<uid>`](#USERMAP_UID) {#USERMAP_UID}
|
||||
|
||||
: The ID of the paperless user in the container. Set this to your
|
||||
|
@@ -60,7 +60,7 @@ first-time setup.
|
||||
|
||||
Every command is executed directly from the root folder of the project unless specified otherwise.
|
||||
|
||||
1. Install prerequisites + [uv](https://github.com/astral-sh/uv) as mentioned in
|
||||
1. Install prerequisites + pipenv as mentioned in
|
||||
[Bare metal route](setup.md#bare_metal).
|
||||
|
||||
2. Copy `paperless.conf.example` to `paperless.conf` and enable debug
|
||||
@@ -75,13 +75,17 @@ first-time setup.
|
||||
4. Install the Python dependencies:
|
||||
|
||||
```bash
|
||||
$ uv sync --dev
|
||||
pipenv install --dev
|
||||
```
|
||||
|
||||
!!! note
|
||||
|
||||
Using a virtual environment is highly recommended. You can spawn one via `pipenv shell`.
|
||||
|
||||
5. Install pre-commit hooks:
|
||||
|
||||
```bash
|
||||
$ uv run pre-commit install
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
6. Apply migrations and create a superuser for your development instance:
|
||||
@@ -89,8 +93,8 @@ first-time setup.
|
||||
```bash
|
||||
# src/
|
||||
|
||||
$ uv run manage.py migrate
|
||||
$ uv run manage.py createsuperuser
|
||||
python3 manage.py migrate
|
||||
python3 manage.py createsuperuser
|
||||
```
|
||||
|
||||
7. You can now either ...
|
||||
@@ -160,19 +164,6 @@ $ ng build --configuration production
|
||||
complicated IF cases. Append `# noqa: E501` to disable this check
|
||||
for certain lines.
|
||||
|
||||
### Package Management
|
||||
|
||||
Paperless uses `uv` to manage packages and virtual environments for both development and production.
|
||||
To accomplish some common tasks using `uv`, follow the shortcuts below:
|
||||
|
||||
To upgrade all locked packages to the latest allowed versions: `uv lock --upgrade`
|
||||
|
||||
To upgrade a single locked package: `uv lock --upgrade-package <package>`
|
||||
|
||||
To add a new package: `uv add <package>`
|
||||
|
||||
To add a new development package `uv add --dev <package>`
|
||||
|
||||
## Front end development
|
||||
|
||||
The front end is built using AngularJS. In order to get started, you need Node.js (version 14.15+) and
|
||||
@@ -341,21 +332,27 @@ LANGUAGES = [
|
||||
The documentation is built using material-mkdocs, see their [documentation](https://squidfunk.github.io/mkdocs-material/reference/).
|
||||
If you want to build the documentation locally, this is how you do it:
|
||||
|
||||
1. Build the documentation
|
||||
1. Have an active pipenv shell (`pipenv shell`) and install Python dependencies:
|
||||
|
||||
```bash
|
||||
$ uv run mkdocs build --config-file mkdocs.yml
|
||||
pipenv install --dev
|
||||
```
|
||||
|
||||
2. Build the documentation
|
||||
|
||||
```bash
|
||||
mkdocs build --config-file mkdocs.yml
|
||||
```
|
||||
|
||||
_alternatively..._
|
||||
|
||||
2. Serve the documentation. This will spin up a
|
||||
3. Serve the documentation. This will spin up a
|
||||
copy of the documentation at http://127.0.0.1:8000
|
||||
that will automatically refresh every time you change
|
||||
something.
|
||||
|
||||
```bash
|
||||
$ uv run mkdocs serve
|
||||
mkdocs serve
|
||||
```
|
||||
|
||||
## Building the Docker image
|
||||
|
@@ -133,9 +133,6 @@ Multiple options for ASGI servers exist:
|
||||
implementation for ASGI.
|
||||
- `uvicorn` as a standalone server
|
||||
|
||||
You may also find the [Django documentation](https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/) on ASGI
|
||||
useful to review.
|
||||
|
||||
## _What about the Redis licensing change and using one of the open source forks_?
|
||||
|
||||
Currently (October 2024), forks of Redis such as Valkey or Redirect are not officially supported by our upstream
|
||||
|
@@ -380,12 +380,6 @@ are released, dependency support is confirmed, etc.
|
||||
dependencies. This is an alternative to the above and may require adjusting
|
||||
the example scripts to utilize the virtual environment paths
|
||||
|
||||
!!! tip
|
||||
|
||||
If you use modern Python tooling, such as `uv`, installation will not include
|
||||
dependencies for Postgres or Mariadb. You can select those extras with `--extra <EXTRA>`
|
||||
or all with `--all-extras`
|
||||
|
||||
9. Go to `/opt/paperless/src`, and execute the following commands:
|
||||
|
||||
```bash
|
||||
@@ -432,20 +426,31 @@ are released, dependency support is confirmed, etc.
|
||||
|
||||
!!! note
|
||||
|
||||
The `socket` script enables `granian` to run on port 80 without
|
||||
The `socket` script enables `gunicorn` to run on port 80 without
|
||||
root privileges. For this you need to uncomment the
|
||||
`Require=paperless-webserver.socket` in the `webserver` script
|
||||
and configure `granian` to listen on port 80 (set `GRANIAN_PORT`).
|
||||
and configure `gunicorn` to listen on port 80 (see
|
||||
`paperless/gunicorn.conf.py`).
|
||||
|
||||
You may need to adjust the path to the `gunicorn` executable. This
|
||||
will be installed as part of the python dependencies, and is either
|
||||
located in the `bin` folder of your virtual environment, or in
|
||||
`~/.local/bin/` if no virtual environment is used.
|
||||
|
||||
These services rely on redis and optionally the database server, but
|
||||
don't need to be started in any particular order. The example files
|
||||
depend on redis being started. If you use a database server, you
|
||||
should add additional dependencies.
|
||||
|
||||
!!! note
|
||||
!!! warning
|
||||
|
||||
For instructions on using a reverse proxy,
|
||||
[see the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Using-a-Reverse-Proxy-with-Paperless-ngx#).
|
||||
The included scripts run a `gunicorn` standalone server, which is
|
||||
fine for running paperless. It does support SSL, however, the
|
||||
documentation of GUnicorn states that you should use a proxy server
|
||||
in front of gunicorn instead.
|
||||
|
||||
For instructions on how to use nginx for that,
|
||||
[see the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Using-a-Reverse-Proxy-with-Paperless-ngx#nginx).
|
||||
|
||||
!!! warning
|
||||
|
||||
@@ -709,8 +714,6 @@ the Pi and configuring some options in paperless can help improve
|
||||
performance immensely:
|
||||
|
||||
- Stick with SQLite to save some resources.
|
||||
- If you do not need the filesystem-based consumer, consider disabling it
|
||||
entirely by setting [`PAPERLESS_CONSUMER_DISABLE`](configuration.md#PAPERLESS_CONSUMER_DISABLE) to `true`.
|
||||
- Consider setting [`PAPERLESS_OCR_PAGES`](configuration.md#PAPERLESS_OCR_PAGES) to 1, so that paperless will
|
||||
only OCR the first page of your documents. In most cases, this page
|
||||
contains enough information to be able to find it.
|
||||
|
@@ -195,6 +195,34 @@ This might have multiple reasons.
|
||||
is not, you need to compile the front end yourself or download the
|
||||
release archive instead of cloning the repository.
|
||||
|
||||
2. Check the output of the web server. You might see errors like this:
|
||||
|
||||
```
|
||||
[2021-01-25 10:08:04 +0000] [40] [ERROR] Socket error processing request.
|
||||
Traceback (most recent call last):
|
||||
File "/usr/local/lib/python3.7/site-packages/gunicorn/workers/sync.py", line 134, in handle
|
||||
self.handle_request(listener, req, client, addr)
|
||||
File "/usr/local/lib/python3.7/site-packages/gunicorn/workers/sync.py", line 190, in handle_request
|
||||
util.reraise(*sys.exc_info())
|
||||
File "/usr/local/lib/python3.7/site-packages/gunicorn/util.py", line 625, in reraise
|
||||
raise value
|
||||
File "/usr/local/lib/python3.7/site-packages/gunicorn/workers/sync.py", line 178, in handle_request
|
||||
resp.write_file(respiter)
|
||||
File "/usr/local/lib/python3.7/site-packages/gunicorn/http/wsgi.py", line 396, in write_file
|
||||
if not self.sendfile(respiter):
|
||||
File "/usr/local/lib/python3.7/site-packages/gunicorn/http/wsgi.py", line 386, in sendfile
|
||||
sent += os.sendfile(sockno, fileno, offset + sent, count)
|
||||
OSError: [Errno 22] Invalid argument
|
||||
```
|
||||
|
||||
To fix this issue, add
|
||||
|
||||
```
|
||||
SENDFILE=0
|
||||
```
|
||||
|
||||
to your `docker-compose.env` file.
|
||||
|
||||
## Error while reading metadata
|
||||
|
||||
You might find messages like these in your log files:
|
||||
@@ -294,12 +322,12 @@ many documents at once often. Otherwise, try tweaking the
|
||||
[`PAPERLESS_DB_TIMEOUT`](configuration.md#PAPERLESS_DB_TIMEOUT) setting to allow more time for the database to
|
||||
unlock. This may have minor performance implications.
|
||||
|
||||
## granian fails to start with "is not a valid port number"
|
||||
## gunicorn fails to start with "is not a valid port number"
|
||||
|
||||
You are likely running using Kubernetes, which automatically creates an
|
||||
environment variable named `${serviceName}_PORT`. This is
|
||||
the same environment variable which is used by Paperless to optionally
|
||||
change the port granian listens on.
|
||||
change the port gunicorn listens on.
|
||||
|
||||
To fix this, set [`PAPERLESS_PORT`](configuration.md#PAPERLESS_PORT) again to your desired port, or the
|
||||
default of 8000.
|
||||
|
@@ -837,7 +837,7 @@ Paperless-ngx consists of the following components:
|
||||
|
||||
```shell-session
|
||||
cd /path/to/paperless/src/
|
||||
python3 webserver.py
|
||||
gunicorn -c ../gunicorn.conf.py paperless.wsgi
|
||||
```
|
||||
|
||||
or by any other means such as Apache `mod_wsgi`.
|
||||
|
49
gunicorn.conf.py
Normal file
49
gunicorn.conf.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import os
|
||||
|
||||
# See https://docs.gunicorn.org/en/stable/settings.html for
|
||||
# explanations of settings
|
||||
|
||||
bind = f"{os.getenv('PAPERLESS_BIND_ADDR', '[::]')}:{os.getenv('PAPERLESS_PORT', 8000)}"
|
||||
|
||||
workers = int(os.getenv("PAPERLESS_WEBSERVER_WORKERS", 1))
|
||||
worker_class = "paperless.workers.ConfigurableWorker"
|
||||
timeout = 120
|
||||
preload_app = True
|
||||
|
||||
# https://docs.gunicorn.org/en/stable/faq.html#blocking-os-fchmod
|
||||
worker_tmp_dir = "/dev/shm"
|
||||
|
||||
|
||||
def pre_fork(server, worker):
|
||||
pass
|
||||
|
||||
|
||||
def pre_exec(server):
|
||||
server.log.info("Forked child, re-executing.")
|
||||
|
||||
|
||||
def when_ready(server):
|
||||
server.log.info("Server is ready. Spawning workers")
|
||||
|
||||
|
||||
def worker_int(worker):
|
||||
worker.log.info("worker received INT or QUIT signal")
|
||||
|
||||
## get traceback info
|
||||
import sys
|
||||
import threading
|
||||
import traceback
|
||||
|
||||
id2name = {th.ident: th.name for th in threading.enumerate()}
|
||||
code = []
|
||||
for threadId, stack in sys._current_frames().items():
|
||||
code.append(f"\n# Thread: {id2name.get(threadId, '')}({threadId})")
|
||||
for filename, lineno, name, line in traceback.extract_stack(stack):
|
||||
code.append(f'File: "{filename}", line {lineno}, in {name}')
|
||||
if line:
|
||||
code.append(f" {line.strip()}")
|
||||
worker.log.debug("\n".join(code))
|
||||
|
||||
|
||||
def worker_abort(worker):
|
||||
worker.log.info("worker received SIGABRT signal")
|
401
pyproject.toml
401
pyproject.toml
@@ -1,401 +0,0 @@
|
||||
[project]
|
||||
name = "paperless-ngx"
|
||||
version = "2.14.7"
|
||||
description = "A community-supported supercharged version of paperless: scan, index and archive all your physical documents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
]
|
||||
# TODO: Move certain things to groups and then utilize that further
|
||||
# This will allow testing to not install a webserver, mysql, etc
|
||||
|
||||
dependencies = [
|
||||
"bleach~=6.2.0",
|
||||
"celery[redis]~=5.4.0",
|
||||
"channels~=4.2",
|
||||
"channels-redis~=4.2",
|
||||
"concurrent-log-handler~=0.9.25",
|
||||
"dateparser~=1.2",
|
||||
# WARNING: django does not use semver.
|
||||
# Only patch versions are guaranteed to not introduce breaking changes.
|
||||
"django~=5.1.6",
|
||||
"django-allauth[socialaccount,mfa]~=65.4.0",
|
||||
"django-auditlog~=3.0.0",
|
||||
"django-celery-results~=2.5.1",
|
||||
"django-compression-middleware~=0.5.0",
|
||||
"django-cors-headers~=4.7.0",
|
||||
"django-extensions~=3.2.3",
|
||||
"django-filter~=25.1",
|
||||
"django-guardian~=2.4.0",
|
||||
"django-multiselectfield~=0.1.13",
|
||||
"django-soft-delete~=1.0.18",
|
||||
"djangorestframework~=3.15",
|
||||
"djangorestframework-guardian~=0.3.0",
|
||||
"drf-spectacular~=0.28",
|
||||
"drf-spectacular-sidecar~=2025.2.1",
|
||||
"drf-writable-nested~=0.7.1",
|
||||
"filelock~=3.17.0",
|
||||
"flower~=2.0.1",
|
||||
"gotenberg-client~=0.9.0",
|
||||
"httpx-oauth~=0.16",
|
||||
"imap-tools~=1.10.0",
|
||||
"inotifyrecursive~=0.3",
|
||||
"jinja2~=3.1.5",
|
||||
"langdetect~=1.0.9",
|
||||
"nltk~=3.9.1",
|
||||
"ocrmypdf~=16.9.0",
|
||||
"pathvalidate~=3.2.3",
|
||||
"pdf2image~=1.17.0",
|
||||
"python-dateutil~=2.9.0",
|
||||
"python-dotenv~=1.0.1",
|
||||
"python-gnupg~=0.5.4",
|
||||
"python-ipware~=3.0.0",
|
||||
"python-magic~=0.4.27",
|
||||
"pyzbar~=0.1.9",
|
||||
"rapidfuzz~=3.12.1",
|
||||
"redis[hiredis]~=5.2.1",
|
||||
"scikit-learn~=1.6.1",
|
||||
"setproctitle~=1.3.4",
|
||||
"tika-client~=0.9.0",
|
||||
"tqdm~=4.67.1",
|
||||
"watchdog~=6.0",
|
||||
"whitenoise~=6.9",
|
||||
"whoosh~=2.7",
|
||||
"zxing-cpp~=2.3.0",
|
||||
]
|
||||
|
||||
optional-dependencies.mariadb = [
|
||||
"mysqlclient~=2.2.7",
|
||||
]
|
||||
optional-dependencies.postgres = [
|
||||
"psycopg[c]==3.2.4",
|
||||
# Direct dependency for proper resolution of the pre-built wheels
|
||||
"psycopg-c==3.2.4",
|
||||
]
|
||||
optional-dependencies.webserver = [
|
||||
"granian~=1.7.6",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
dev = [
|
||||
{ "include-group" = "docs" },
|
||||
{ "include-group" = "testing" },
|
||||
{ "include-group" = "lint" },
|
||||
]
|
||||
|
||||
docs = [
|
||||
"mkdocs-glightbox~=0.4.0",
|
||||
"mkdocs-material~=9.6.4",
|
||||
]
|
||||
|
||||
testing = [
|
||||
"daphne",
|
||||
"factory-boy~=3.3.1",
|
||||
"imagehash",
|
||||
"pytest~=8.3.3",
|
||||
"pytest-cov~=6.0.0",
|
||||
"pytest-django~=4.10.0",
|
||||
"pytest-env",
|
||||
"pytest-httpx",
|
||||
"pytest-mock",
|
||||
"pytest-rerunfailures",
|
||||
"pytest-sugar",
|
||||
"pytest-xdist",
|
||||
]
|
||||
|
||||
lint = [
|
||||
"pre-commit~=4.1.0",
|
||||
"pre-commit-uv~=4.1.3",
|
||||
"ruff~=0.9.9",
|
||||
]
|
||||
|
||||
typing = [
|
||||
"celery-types",
|
||||
"django-filter-stubs",
|
||||
"django-stubs[compatible-mypy]",
|
||||
"djangorestframework-stubs[compatible-mypy]",
|
||||
"mypy",
|
||||
"types-bleach",
|
||||
"types-colorama",
|
||||
"types-dateparser",
|
||||
"types-markdown",
|
||||
"types-pygments",
|
||||
"types-python-dateutil",
|
||||
"types-redis",
|
||||
"types-setuptools",
|
||||
"types-tqdm",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py310"
|
||||
line-length = 88
|
||||
src = [
|
||||
"src",
|
||||
]
|
||||
respect-gitignore = true
|
||||
# https://docs.astral.sh/ruff/settings/
|
||||
fix = true
|
||||
show-fixes = true
|
||||
|
||||
output-format = "grouped"
|
||||
# https://docs.astral.sh/ruff/rules/
|
||||
lint.extend-select = [
|
||||
"COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com
|
||||
"DJ", # https://docs.astral.sh/ruff/rules/#flake8-django-dj
|
||||
"EXE", # https://docs.astral.sh/ruff/rules/#flake8-executable-exe
|
||||
"FBT", # https://docs.astral.sh/ruff/rules/#flake8-boolean-trap-fbt
|
||||
"FLY", # https://docs.astral.sh/ruff/rules/#flynt-fly
|
||||
"G201", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g
|
||||
"I", # https://docs.astral.sh/ruff/rules/#isort-i
|
||||
"ICN", # https://docs.astral.sh/ruff/rules/#flake8-import-conventions-icn
|
||||
"INP", # https://docs.astral.sh/ruff/rules/#flake8-no-pep420-inp
|
||||
"ISC", # https://docs.astral.sh/ruff/rules/#flake8-implicit-str-concat-isc
|
||||
"PIE", # https://docs.astral.sh/ruff/rules/#flake8-pie-pie
|
||||
"PLC", # https://docs.astral.sh/ruff/rules/#pylint-pl
|
||||
"PLE", # https://docs.astral.sh/ruff/rules/#pylint-pl
|
||||
"PTH", # https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth
|
||||
"Q", # https://docs.astral.sh/ruff/rules/#flake8-quotes-q
|
||||
"RSE", # https://docs.astral.sh/ruff/rules/#flake8-raise-rse
|
||||
"RUF", # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf
|
||||
"SIM", # https://docs.astral.sh/ruff/rules/#flake8-simplify-sim
|
||||
"T20", # https://docs.astral.sh/ruff/rules/#flake8-print-t20
|
||||
"TC", # https://docs.astral.sh/ruff/rules/#flake8-type-checking-tc
|
||||
"TID", # https://docs.astral.sh/ruff/rules/#flake8-tidy-imports-tid
|
||||
"UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
|
||||
"W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
|
||||
]
|
||||
lint.ignore = [
|
||||
"DJ001",
|
||||
"RUF012",
|
||||
"SIM105",
|
||||
]
|
||||
# Migrations
|
||||
lint.per-file-ignores."*/migrations/*.py" = [
|
||||
"E501",
|
||||
"SIM",
|
||||
"T201",
|
||||
]
|
||||
# Testing
|
||||
lint.per-file-ignores."*/tests/*.py" = [
|
||||
"E501",
|
||||
"SIM117",
|
||||
]
|
||||
lint.per-file-ignores.".github/scripts/*.py" = [
|
||||
"E501",
|
||||
"INP001",
|
||||
"SIM117",
|
||||
]
|
||||
# Docker specific
|
||||
lint.per-file-ignores."docker/rootfs/usr/local/bin/wait-for-redis.py" = [
|
||||
"INP001",
|
||||
"T201",
|
||||
]
|
||||
lint.per-file-ignores."docker/wait-for-redis.py" = [
|
||||
"INP001",
|
||||
"T201",
|
||||
]
|
||||
lint.per-file-ignores."src/documents/file_handling.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/documents/management/commands/document_consumer.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/documents/management/commands/document_exporter.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/documents/migrations/0012_auto_20160305_0040.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/documents/migrations/0014_document_checksum.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/documents/migrations/1003_mime_types.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/documents/migrations/1012_fix_archive_files.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/documents/models.py" = [
|
||||
"PTH",
|
||||
"SIM115",
|
||||
] # TODO PTH Enable & remove
|
||||
lint.per-file-ignores."src/documents/parsers.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/documents/signals/handlers.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/documents/tasks.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/documents/tests/test_api_app_config.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/documents/tests/test_classifier.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/documents/tests/test_consumer.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/documents/tests/test_file_handling.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/documents/tests/test_management.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/documents/tests/test_management_consumer.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/documents/tests/test_management_exporter.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/documents/tests/test_management_thumbnails.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/documents/tests/test_migration_archive_files.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/documents/tests/test_migration_document_pages_count.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/documents/tests/test_migration_mime_type.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/documents/tests/test_sanity_check.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/documents/tests/test_tasks.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/documents/tests/test_views.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/documents/views.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/paperless/checks.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/paperless/settings.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/paperless/tests/test_checks.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/paperless/urls.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/paperless/views.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/paperless_mail/mail.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/paperless_mail/preprocessor.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/paperless_tesseract/parsers.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/paperless_tesseract/tests/test_parser.py" = [
|
||||
"PTH",
|
||||
"RUF001",
|
||||
] # TODO PTH Enable & remove
|
||||
lint.per-file-ignores."src/paperless_tika/tests/test_live_tika.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/paperless_tika/tests/test_tika_parser.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.isort.force-single-line = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
minversion = "8.0"
|
||||
pythonpath = [
|
||||
"src",
|
||||
]
|
||||
testpaths = [
|
||||
"src/documents/tests/",
|
||||
"src/paperless/tests/",
|
||||
"src/paperless_mail/tests/",
|
||||
"src/paperless_tesseract/tests/",
|
||||
"src/paperless_tika/tests",
|
||||
]
|
||||
addopts = [
|
||||
"--pythonwarnings=all",
|
||||
"--cov",
|
||||
"--cov-report=html",
|
||||
"--cov-report=xml",
|
||||
"--numprocesses=auto",
|
||||
"--maxprocesses=16",
|
||||
"--quiet",
|
||||
"--durations=50",
|
||||
"--junitxml=junit.xml",
|
||||
"-o junit_family=legacy",
|
||||
]
|
||||
norecursedirs = [ "src/locale/", ".venv/", "src-ui/" ]
|
||||
|
||||
DJANGO_SETTINGS_MODULE = "paperless.settings"
|
||||
|
||||
[tool.pytest_env]
|
||||
PAPERLESS_DISABLE_DBHANDLER = "true"
|
||||
PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
|
||||
|
||||
[tool.coverage.run]
|
||||
source = [
|
||||
"src/",
|
||||
]
|
||||
omit = [
|
||||
"*/tests/*",
|
||||
"manage.py",
|
||||
"paperless/wsgi.py",
|
||||
"paperless/auth.py",
|
||||
]
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_also = [
|
||||
"if settings.AUDIT_LOG_ENABLED:",
|
||||
"if AUDIT_LOG_ENABLED:",
|
||||
"if TYPE_CHECKING:",
|
||||
]
|
||||
|
||||
[tool.mypy]
|
||||
plugins = [
|
||||
"mypy_django_plugin.main",
|
||||
"mypy_drf_plugin.main",
|
||||
"numpy.typing.mypy_plugin",
|
||||
]
|
||||
check_untyped_defs = true
|
||||
disallow_any_generics = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_untyped_defs = true
|
||||
warn_redundant_casts = true
|
||||
warn_unused_ignores = true
|
||||
|
||||
[tool.uv]
|
||||
required-version = ">=0.5.14"
|
||||
package = false
|
||||
environments = [
|
||||
"sys_platform == 'darwin'",
|
||||
"sys_platform == 'linux'",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
# Markers are chosen to select these almost exclusively when building the Docker image
|
||||
psycopg-c = [
|
||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-3.2.4/psycopg_c-3.2.4-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
|
||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-3.2.4/psycopg_c-3.2.4-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
|
||||
]
|
||||
zxing-cpp = [
|
||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/zxing-2.3.0/zxing_cpp-2.3.0-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
|
||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/zxing-2.3.0/zxing_cpp-2.3.0-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
|
||||
]
|
||||
|
||||
[tool.django-stubs]
|
||||
django_settings_module = "paperless.settings"
|
@@ -9,7 +9,7 @@ Requires=redis.service
|
||||
User=paperless
|
||||
Group=paperless
|
||||
WorkingDirectory=/opt/paperless/src
|
||||
ExecStart=python3 webserver.py
|
||||
ExecStart=/opt/paperless/.local/bin/gunicorn -c /opt/paperless/gunicorn.conf.py paperless.asgi:application
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
@@ -12,13 +12,4 @@ module.exports = {
|
||||
'^src/(.*)': '<rootDir>/src/$1',
|
||||
},
|
||||
workerIdleMemoryLimit: '512MB',
|
||||
reporters: [
|
||||
'default',
|
||||
[
|
||||
'jest-junit',
|
||||
{
|
||||
classNameTemplate: '{filepath}/{classname}: {title}',
|
||||
},
|
||||
],
|
||||
],
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
2437
src-ui/package-lock.json
generated
2437
src-ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,17 +11,17 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^19.2.1",
|
||||
"@angular/common": "~19.2.0",
|
||||
"@angular/compiler": "~19.2.0",
|
||||
"@angular/core": "~19.2.0",
|
||||
"@angular/forms": "~19.2.0",
|
||||
"@angular/localize": "~19.2.0",
|
||||
"@angular/platform-browser": "~19.2.0",
|
||||
"@angular/platform-browser-dynamic": "~19.2.0",
|
||||
"@angular/router": "~19.2.0",
|
||||
"@angular/cdk": "^19.1.2",
|
||||
"@angular/common": "~19.1.4",
|
||||
"@angular/compiler": "~19.1.4",
|
||||
"@angular/core": "~19.1.4",
|
||||
"@angular/forms": "~19.1.4",
|
||||
"@angular/localize": "~19.1.4",
|
||||
"@angular/platform-browser": "~19.1.4",
|
||||
"@angular/platform-browser-dynamic": "~19.1.4",
|
||||
"@angular/router": "~19.1.4",
|
||||
"@ng-bootstrap/ng-bootstrap": "^18.0.0",
|
||||
"@ng-select/ng-select": "^14.2.2",
|
||||
"@ng-select/ng-select": "^14.2.0",
|
||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"bootstrap": "^5.3.3",
|
||||
@@ -29,42 +29,41 @@
|
||||
"mime-names": "^1.0.0",
|
||||
"ng2-pdf-viewer": "^10.4.0",
|
||||
"ngx-bootstrap-icons": "^1.9.3",
|
||||
"ngx-color": "^10.0.0",
|
||||
"ngx-cookie-service": "^19.1.2",
|
||||
"ngx-color": "^9.0.0",
|
||||
"ngx-cookie-service": "^19.1.0",
|
||||
"ngx-device-detector": "^9.0.0",
|
||||
"ngx-file-drop": "^16.0.0",
|
||||
"ngx-ui-tour-ng-bootstrap": "^16.0.0",
|
||||
"rxjs": "^7.8.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"tslib": "^2.8.1",
|
||||
"utif": "^3.1.0",
|
||||
"uuid": "^11.1.0",
|
||||
"uuid": "^11.0.5",
|
||||
"zone.js": "^0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-builders/custom-webpack": "^19.0.0",
|
||||
"@angular-builders/jest": "^19.0.0",
|
||||
"@angular-devkit/build-angular": "^19.0.4",
|
||||
"@angular-devkit/core": "^19.2.0",
|
||||
"@angular-devkit/schematics": "^19.2.0",
|
||||
"@angular-eslint/builder": "19.2.0",
|
||||
"@angular-eslint/eslint-plugin": "19.2.0",
|
||||
"@angular-eslint/eslint-plugin-template": "19.2.0",
|
||||
"@angular-eslint/schematics": "19.2.0",
|
||||
"@angular-eslint/template-parser": "19.2.0",
|
||||
"@angular/cli": "~19.2.0",
|
||||
"@angular/compiler-cli": "~19.2.0",
|
||||
"@codecov/webpack-plugin": "^1.9.0",
|
||||
"@angular-devkit/core": "^19.1.5",
|
||||
"@angular-devkit/schematics": "^19.1.5",
|
||||
"@angular-eslint/builder": "19.0.2",
|
||||
"@angular-eslint/eslint-plugin": "19.0.2",
|
||||
"@angular-eslint/eslint-plugin-template": "19.0.2",
|
||||
"@angular-eslint/schematics": "19.0.2",
|
||||
"@angular-eslint/template-parser": "19.0.2",
|
||||
"@angular/cli": "~19.1.5",
|
||||
"@angular/compiler-cli": "~19.1.4",
|
||||
"@codecov/webpack-plugin": "^1.8.0",
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.13.9",
|
||||
"@typescript-eslint/eslint-plugin": "^8.26.0",
|
||||
"@typescript-eslint/parser": "^8.26.0",
|
||||
"@types/node": "^22.13.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.22.0",
|
||||
"@typescript-eslint/parser": "^8.22.0",
|
||||
"@typescript-eslint/utils": "^8.0.0",
|
||||
"eslint": "^9.21.0",
|
||||
"eslint": "^9.19.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-junit": "^16.0.0",
|
||||
"jest-preset-angular": "^14.5.3",
|
||||
"jest-preset-angular": "^14.4.2",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"patch-package": "^8.0.0",
|
||||
"prettier-plugin-organize-imports": "^4.1.0",
|
||||
|
@@ -118,7 +118,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3 col-form-label pt-0">
|
||||
<span i18n>Sidebar</span>
|
||||
</div>
|
||||
@@ -129,7 +129,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3 col-form-label pt-0">
|
||||
<span i18n>Dark mode</span>
|
||||
</div>
|
||||
@@ -165,7 +165,7 @@
|
||||
<p i18n>
|
||||
Update checking works by pinging the public GitHub API for the latest release to determine whether a new version is available. Actual updating of the app must still be performed manually.
|
||||
</p>
|
||||
<p class="mb-0">
|
||||
<p>
|
||||
<em i18n>No tracking data is collected by the app in any way.</em>
|
||||
</p>
|
||||
</ng-template>
|
||||
@@ -173,7 +173,7 @@
|
||||
</div>
|
||||
|
||||
<h5 class="mt-3" i18n>Saved Views</h5>
|
||||
<div class="row">
|
||||
<div class="row mb-3">
|
||||
<div class="col">
|
||||
<pngx-input-check i18n-title title="Show warning when closing saved views with unsaved changes" formControlName="savedViewsWarnOnUnsavedChange"></pngx-input-check>
|
||||
</div>
|
||||
@@ -183,15 +183,15 @@
|
||||
<div class="col-xl-6 ps-xl-5">
|
||||
<h5 class="mt-3 mt-md-0" i18n>Document editing</h5>
|
||||
|
||||
<div class="row">
|
||||
<div class="row mb-3">
|
||||
<div class="col">
|
||||
<pngx-input-check i18n-title title="Use PDF viewer provided by the browser" i18n-hint hint="This is usually faster for displaying large PDF documents, but it might not work on some browsers." formControlName="useNativePdfViewer"></pngx-input-check>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-3 col-form-label pt-0">
|
||||
<span i18n>Default zoom</span>
|
||||
<div class="row mb-3">
|
||||
<div class="col-2">
|
||||
<span i18n>Default zoom:</span>
|
||||
</div>
|
||||
<div class="col">
|
||||
<select class="form-select" formControlName="pdfViewerDefaultZoom">
|
||||
@@ -202,7 +202,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="row mb-3">
|
||||
<div class="col">
|
||||
<pngx-input-check i18n-title title="Automatically remove inbox tag(s) on save" formControlName="documentEditingRemoveInboxTags"></pngx-input-check>
|
||||
</div>
|
||||
@@ -214,22 +214,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="mt-3" i18n>Global search</h5>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<pngx-input-check i18n-title title="Do not include advanced search results" formControlName="searchDbOnly"></pngx-input-check>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="mt-3" i18n>Notes</h5>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3 col-form-label pt-0">
|
||||
<span i18n>Full search links to</span>
|
||||
</div>
|
||||
<div class="col mb-3">
|
||||
<select class="form-select" formControlName="searchLink">
|
||||
<option [ngValue]="GlobalSearchType.TITLE_CONTENT" i18n>Title and content search</option>
|
||||
<option [ngValue]="GlobalSearchType.ADVANCED" i18n>Advanced search</option>
|
||||
</select>
|
||||
<div class="col">
|
||||
<pngx-input-check i18n-title title="Enable notes" formControlName="notesEnabled"></pngx-input-check>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -241,10 +229,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="mt-3" i18n>Notes</h5>
|
||||
<h5 class="mt-3" i18n>Global search</h5>
|
||||
<div class="row mb-3">
|
||||
<div class="col">
|
||||
<pngx-input-check i18n-title title="Enable notes" formControlName="notesEnabled"></pngx-input-check>
|
||||
<pngx-input-check i18n-title title="Do not include advanced search results" formControlName="searchDbOnly"></pngx-input-check>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col">
|
||||
<div class="row">
|
||||
<div class="col-md-3 col-form-label pt-0">
|
||||
<span i18n>Full search links to</span>
|
||||
</div>
|
||||
<div class="col">
|
||||
<select class="form-select" formControlName="searchLink">
|
||||
<option [ngValue]="GlobalSearchType.TITLE_CONTENT" i18n>Title and content search</option>
|
||||
<option [ngValue]="GlobalSearchType.ADVANCED" i18n>Advanced search</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -263,8 +267,8 @@
|
||||
<div class="row mb-3">
|
||||
<div class="col">
|
||||
<p i18n>
|
||||
Settings apply to this user account for objects (Tags, Mail Rules, etc. but not documents) created via the web UI.
|
||||
</p>
|
||||
Settings apply to this user account for objects (Tags, Mail Rules, etc.) created via the web UI
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
@@ -303,7 +307,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3 col-form-label pt-0">
|
||||
<span i18n>Default Edit Permissions</span>
|
||||
</div>
|
||||
@@ -342,7 +346,7 @@
|
||||
|
||||
<h5 i18n>Document processing</h5>
|
||||
|
||||
<div class="row">
|
||||
<div class="row mb-3">
|
||||
<div class="col">
|
||||
<pngx-input-check i18n-title title="Show notifications when new documents are detected" formControlName="notificationsConsumerNewDocument"></pngx-input-check>
|
||||
<pngx-input-check i18n-title title="Show notifications when document processing completes successfully" formControlName="notificationsConsumerSuccess"></pngx-input-check>
|
||||
|
@@ -325,8 +325,6 @@ describe('SettingsComponent', () => {
|
||||
component['systemStatus'].database.status = SystemStatusItemStatus.OK
|
||||
component['systemStatus'].tasks.redis_status = SystemStatusItemStatus.OK
|
||||
component['systemStatus'].tasks.celery_status = SystemStatusItemStatus.OK
|
||||
component['systemStatus'].tasks.sanity_check_status =
|
||||
SystemStatusItemStatus.OK
|
||||
expect(component.systemStatusHasErrors).toBeFalsy()
|
||||
})
|
||||
|
||||
|
@@ -164,10 +164,7 @@ export class SettingsComponent
|
||||
this.systemStatus.tasks.redis_status === SystemStatusItemStatus.ERROR ||
|
||||
this.systemStatus.tasks.celery_status === SystemStatusItemStatus.ERROR ||
|
||||
this.systemStatus.tasks.index_status === SystemStatusItemStatus.ERROR ||
|
||||
this.systemStatus.tasks.classifier_status ===
|
||||
SystemStatusItemStatus.ERROR ||
|
||||
this.systemStatus.tasks.sanity_check_status ===
|
||||
SystemStatusItemStatus.ERROR
|
||||
this.systemStatus.tasks.classifier_status === SystemStatusItemStatus.ERROR
|
||||
)
|
||||
}
|
||||
|
||||
|
@@ -21,7 +21,7 @@
|
||||
}
|
||||
<div class="scroll-list">
|
||||
@for (toast of toasts; track toast.id) {
|
||||
<pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (closed)="toastService.closeToast(toast)"></pngx-toast>
|
||||
<pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (close)="toastService.closeToast(toast)"></pngx-toast>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -84,7 +84,7 @@ export class SplitConfirmDialogComponent
|
||||
addSplit() {
|
||||
if (this.page === this.totalPages) return
|
||||
this.pages.add(this.page)
|
||||
this.pages = new Set(Array.from(this.pages).sort((a, b) => a - b))
|
||||
this.pages = new Set(Array.from(this.pages).sort())
|
||||
this.confirmButtonEnabled = this.pages.size > 0
|
||||
}
|
||||
|
||||
|
@@ -1,32 +0,0 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-1">
|
||||
<label for="email" class="form-label" i18n>Email address(es)</label>
|
||||
<input type="email" class="form-control" id="email" [(ngModel)]="emailAddress">
|
||||
</div>
|
||||
<div class="mb-1">
|
||||
<label for="email" class="form-label" i18n>Subject</label>
|
||||
<input type="email" class="form-control" id="subject" [(ngModel)]="emailSubject">
|
||||
</div>
|
||||
<div>
|
||||
<label for="message" class="form-label" i18n>Message</label>
|
||||
<textarea class="form-control" id="message" rows="3" [(ngModel)]="emailMessage"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="input-group">
|
||||
<div class="input-group-text flex-grow-1">
|
||||
<input class="form-check-input mt-0 me-2" type="checkbox" role="switch" id="useArchiveVersion" [disabled]="!hasArchiveVersion" [(ngModel)]="useArchiveVersion">
|
||||
<label class="form-check-label w-100 text-start" for="useArchiveVersion" i18n>Use archive version</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-outline-primary" (click)="emailDocument()" [disabled]="loading || emailAddress.length === 0 || emailMessage.length === 0 || emailSubject.length === 0">
|
||||
@if (loading) {
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
}
|
||||
<ng-container i18n>Send email</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
@@ -1,72 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { EmailDocumentDialogComponent } from './email-document-dialog.component'
|
||||
|
||||
describe('EmailDocumentDialogComponent', () => {
|
||||
let component: EmailDocumentDialogComponent
|
||||
let fixture: ComponentFixture<EmailDocumentDialogComponent>
|
||||
let documentService: DocumentService
|
||||
let permissionsService: PermissionsService
|
||||
let toastService: ToastService
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
EmailDocumentDialogComponent,
|
||||
IfPermissionsDirective,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
],
|
||||
providers: [
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
NgbActiveModal,
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(EmailDocumentDialogComponent)
|
||||
documentService = TestBed.inject(DocumentService)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should set hasArchiveVersion and useArchiveVersion', () => {
|
||||
expect(component.hasArchiveVersion).toBeTruthy()
|
||||
component.hasArchiveVersion = false
|
||||
expect(component.hasArchiveVersion).toBeFalsy()
|
||||
expect(component.useArchiveVersion).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should support sending document via email, showing error if needed', () => {
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
|
||||
component.emailAddress = 'hello@paperless-ngx.com'
|
||||
component.emailSubject = 'Hello'
|
||||
component.emailMessage = 'World'
|
||||
jest
|
||||
.spyOn(documentService, 'emailDocument')
|
||||
.mockReturnValue(throwError(() => new Error('Unable to email document')))
|
||||
component.emailDocument()
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
|
||||
jest.spyOn(documentService, 'emailDocument').mockReturnValue(of(true))
|
||||
component.emailDocument()
|
||||
expect(toastSuccessSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should close the dialog', () => {
|
||||
const activeModal = TestBed.inject(NgbActiveModal)
|
||||
const closeSpy = jest.spyOn(activeModal, 'close')
|
||||
component.close()
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
@@ -1,77 +0,0 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-email-document-dialog',
|
||||
templateUrl: './email-document-dialog.component.html',
|
||||
styleUrl: './email-document-dialog.component.scss',
|
||||
imports: [FormsModule, NgxBootstrapIconsModule],
|
||||
})
|
||||
export class EmailDocumentDialogComponent extends LoadingComponentWithPermissions {
|
||||
@Input()
|
||||
title = $localize`Email Document`
|
||||
|
||||
@Input()
|
||||
documentId: number
|
||||
|
||||
private _hasArchiveVersion: boolean = true
|
||||
|
||||
@Input()
|
||||
set hasArchiveVersion(value: boolean) {
|
||||
this._hasArchiveVersion = value
|
||||
this.useArchiveVersion = value
|
||||
}
|
||||
|
||||
get hasArchiveVersion(): boolean {
|
||||
return this._hasArchiveVersion
|
||||
}
|
||||
|
||||
public useArchiveVersion: boolean = true
|
||||
|
||||
public emailAddress: string = ''
|
||||
public emailSubject: string = ''
|
||||
public emailMessage: string = ''
|
||||
|
||||
constructor(
|
||||
private activeModal: NgbActiveModal,
|
||||
private documentService: DocumentService,
|
||||
private toastService: ToastService
|
||||
) {
|
||||
super()
|
||||
this.loading = false
|
||||
}
|
||||
|
||||
public emailDocument() {
|
||||
this.loading = true
|
||||
this.documentService
|
||||
.emailDocument(
|
||||
this.documentId,
|
||||
this.emailAddress,
|
||||
this.emailSubject,
|
||||
this.emailMessage,
|
||||
this.useArchiveVersion
|
||||
)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.loading = false
|
||||
this.emailAddress = ''
|
||||
this.emailSubject = ''
|
||||
this.emailMessage = ''
|
||||
this.toastService.showInfo($localize`Email sent`)
|
||||
},
|
||||
error: (e) => {
|
||||
this.loading = false
|
||||
this.toastService.showError($localize`Error emailing document`, e)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.activeModal.close()
|
||||
}
|
||||
}
|
@@ -0,0 +1,14 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancelClicked()">
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
<pngx-input-select [items]="objects" [title]="message" [(ngModel)]="selected"></pngx-input-select>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-dark" (click)="cancelClicked()" i18n>Cancel</button>
|
||||
<button type="button" class="btn btn-primary" (click)="selectClicked.emit(selected)" i18n>Select</button>
|
||||
</div>
|
@@ -0,0 +1,36 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { SelectComponent } from '../input/select/select.component'
|
||||
import { SelectDialogComponent } from './select-dialog.component'
|
||||
|
||||
describe('SelectDialogComponent', () => {
|
||||
let component: SelectDialogComponent
|
||||
let fixture: ComponentFixture<SelectDialogComponent>
|
||||
let modal: NgbActiveModal
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [NgbActiveModal],
|
||||
imports: [
|
||||
NgSelectModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
SelectDialogComponent,
|
||||
SelectComponent,
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
modal = TestBed.inject(NgbActiveModal)
|
||||
fixture = TestBed.createComponent(SelectDialogComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should close modal on cancel', () => {
|
||||
const closeSpy = jest.spyOn(modal, 'close')
|
||||
component.cancelClicked()
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
@@ -0,0 +1,33 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { ObjectWithId } from 'src/app/data/object-with-id'
|
||||
import { SelectComponent } from '../input/select/select.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-select-dialog',
|
||||
templateUrl: './select-dialog.component.html',
|
||||
styleUrls: ['./select-dialog.component.scss'],
|
||||
imports: [SelectComponent, FormsModule, ReactiveFormsModule],
|
||||
})
|
||||
export class SelectDialogComponent {
|
||||
constructor(public activeModal: NgbActiveModal) {}
|
||||
|
||||
@Output()
|
||||
public selectClicked = new EventEmitter()
|
||||
|
||||
@Input()
|
||||
title = $localize`Select`
|
||||
|
||||
@Input()
|
||||
message = $localize`Please select an object`
|
||||
|
||||
@Input()
|
||||
objects: ObjectWithId[] = []
|
||||
|
||||
selected: number
|
||||
|
||||
cancelClicked() {
|
||||
this.activeModal.close()
|
||||
}
|
||||
}
|
@@ -1,68 +0,0 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
||||
</div>
|
||||
<div class="modal-body p-0">
|
||||
<ul class="list-group list-group-flush">
|
||||
@if (!shareLinks || shareLinks.length === 0) {
|
||||
<li class="list-group-item fst-italic small text-center text-secondary" i18n>
|
||||
No existing links
|
||||
</li>
|
||||
}
|
||||
@for (link of shareLinks; track link) {
|
||||
<li class="list-group-item">
|
||||
<div class="input-group w-100">
|
||||
<input type="text" class="form-control" aria-label="Share link" [value]="getShareUrl(link)" readonly>
|
||||
@if (link.expiration) {
|
||||
<span class="input-group-text">
|
||||
{{ getDaysRemaining(link) }}
|
||||
</span>
|
||||
}
|
||||
<button type="button" class="btn btn-outline-primary" (click)="copy(link)">
|
||||
@if (copied !== link.id) {
|
||||
<i-bs width="1.2em" height="1.2em" name="clipboard-fill"></i-bs>
|
||||
}
|
||||
@if (copied === link.id) {
|
||||
<i-bs width="1.2em" height="1.2em" name="clipboard-check-fill"></i-bs>
|
||||
}
|
||||
<span class="visually-hidden" i18n>Copy</span>
|
||||
</button>
|
||||
@if (canShare(link)) {
|
||||
<button type="button" class="btn btn-outline-primary" (click)="share(link)">
|
||||
<i-bs width="1.2em" height="1.2em" name="box-arrow-up"></i-bs><span class="visually-hidden" i18n>Share</span>
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="btn btn-outline-danger" (click)="delete(link)">
|
||||
<i-bs width="1.2em" height="1.2em" name="trash"></i-bs><span class="visually-hidden" i18n>Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
<span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied === link.id" i18n>Copied!</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="input-group w-100">
|
||||
<div class="form-check form-switch ms-auto">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="versionSwitch" [disabled]="!hasArchiveVersion" [(ngModel)]="useArchiveVersion">
|
||||
<label class="form-check-label" for="versionSwitch" i18n>Share archive version</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group w-100 mt-2">
|
||||
<label class="input-group-text" for="addLink"><ng-container i18n>Expires</ng-container>:</label>
|
||||
<select class="form-select fs-6" [(ngModel)]="expirationDays">
|
||||
@for (option of EXPIRATION_OPTIONS; track option) {
|
||||
<option [ngValue]="option.value">{{ option.label }}</option>
|
||||
}
|
||||
</select>
|
||||
<button class="btn btn-outline-primary ms-auto" type="button" (click)="createLink()" [disabled]="loading">
|
||||
@if (loading) {
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
}
|
||||
@if (!loading) {
|
||||
<i-bs name="plus"></i-bs>
|
||||
}
|
||||
<ng-container i18n>Create</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
@@ -1,3 +0,0 @@
|
||||
.copied-badge {
|
||||
right: 15em;
|
||||
}
|
@@ -0,0 +1,70 @@
|
||||
<div ngbDropdown>
|
||||
<button class="btn btn-sm btn-outline-primary" id="shareLinksDropdown" [disabled]="disabled" ngbDropdownToggle>
|
||||
<i-bs name="link"></i-bs>
|
||||
<div class="d-none d-sm-inline"> <ng-container i18n>Share Links</ng-container></div>
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="shareLinksDropdown" class="shadow share-links-dropdown">
|
||||
<ul class="list-group list-group-flush">
|
||||
@if (!shareLinks || shareLinks.length === 0) {
|
||||
<li class="list-group-item fst-italic small text-center text-secondary" i18n>
|
||||
No existing links
|
||||
</li>
|
||||
}
|
||||
@for (link of shareLinks; track link) {
|
||||
<li class="list-group-item">
|
||||
<div class="input-group input-group-sm w-100">
|
||||
<input type="text" class="form-control" aria-label="Share link" [value]="getShareUrl(link)" readonly>
|
||||
@if (link.expiration) {
|
||||
<span class="input-group-text">
|
||||
{{ getDaysRemaining(link) }}
|
||||
</span>
|
||||
}
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="copy(link)">
|
||||
@if (copied !== link.id) {
|
||||
<i-bs width="1.2em" height="1.2em" name="clipboard-fill"></i-bs>
|
||||
}
|
||||
@if (copied === link.id) {
|
||||
<i-bs width="1.2em" height="1.2em" name="clipboard-check-fill"></i-bs>
|
||||
}
|
||||
<span class="visually-hidden" i18n>Copy</span>
|
||||
</button>
|
||||
@if (canShare(link)) {
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="share(link)">
|
||||
<i-bs width="1.2em" height="1.2em" name="box-arrow-up"></i-bs><span class="visually-hidden" i18n>Share</span>
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" (click)="delete(link)">
|
||||
<i-bs width="1.2em" height="1.2em" name="trash"></i-bs><span class="visually-hidden" i18n>Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
<span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied === link.id" i18n>Copied!</span>
|
||||
</li>
|
||||
}
|
||||
<li class="list-group-item pt-3 pb-2">
|
||||
<div class="input-group input-group-sm w-100">
|
||||
<div class="form-check form-switch ms-auto small">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="versionSwitch" [disabled]="!hasArchiveVersion" [(ngModel)]="useArchiveVersion">
|
||||
<label class="form-check-label" for="versionSwitch" i18n>Share archive version</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group input-group-sm w-100 mt-2">
|
||||
<label class="input-group-text" for="addLink"><ng-container i18n>Expires</ng-container>:</label>
|
||||
<select class="form-select form-select-sm" [(ngModel)]="expirationDays">
|
||||
@for (option of EXPIRATION_OPTIONS; track option) {
|
||||
<option [ngValue]="option.value">{{ option.label }}</option>
|
||||
}
|
||||
</select>
|
||||
<button class="btn btn-sm btn-outline-primary ms-auto" type="button" (click)="createLink()" [disabled]="loading">
|
||||
@if (loading) {
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
}
|
||||
@if (!loading) {
|
||||
<i-bs name="plus"></i-bs>
|
||||
}
|
||||
<ng-container i18n>Create</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,14 @@
|
||||
.share-links-dropdown {
|
||||
min-width: 350px;
|
||||
|
||||
// correct position on mobile
|
||||
@media (max-width: 575.98px) {
|
||||
&.show {
|
||||
margin-left: -175px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.copied-badge {
|
||||
right: 7.5em;
|
||||
}
|
@@ -11,18 +11,17 @@ import {
|
||||
tick,
|
||||
} from '@angular/core/testing'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { FileVersion, ShareLink } from 'src/app/data/share-link'
|
||||
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { ShareLinksDialogComponent } from './share-links-dialog.component'
|
||||
import { ShareLinksDropdownComponent } from './share-links-dropdown.component'
|
||||
|
||||
describe('ShareLinksDialogComponent', () => {
|
||||
let component: ShareLinksDialogComponent
|
||||
let fixture: ComponentFixture<ShareLinksDialogComponent>
|
||||
describe('ShareLinksDropdownComponent', () => {
|
||||
let component: ShareLinksDropdownComponent
|
||||
let fixture: ComponentFixture<ShareLinksDropdownComponent>
|
||||
let shareLinkService: ShareLinkService
|
||||
let toastService: ToastService
|
||||
let httpController: HttpTestingController
|
||||
@@ -31,17 +30,16 @@ describe('ShareLinksDialogComponent', () => {
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
ShareLinksDialogComponent,
|
||||
ShareLinksDropdownComponent,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
],
|
||||
providers: [
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
NgbActiveModal,
|
||||
],
|
||||
})
|
||||
|
||||
fixture = TestBed.createComponent(ShareLinksDialogComponent)
|
||||
fixture = TestBed.createComponent(ShareLinksDropdownComponent)
|
||||
shareLinkService = TestBed.inject(ShareLinkService)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
httpController = TestBed.inject(HttpTestingController)
|
||||
@@ -234,11 +232,4 @@ describe('ShareLinksDialogComponent', () => {
|
||||
]
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should support close', () => {
|
||||
const activeModal = TestBed.inject(NgbActiveModal)
|
||||
const closeSpy = jest.spyOn(activeModal, 'close')
|
||||
component.close()
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
@@ -1,7 +1,7 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard'
|
||||
import { Component, Input, OnInit } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { first } from 'rxjs'
|
||||
import { FileVersion, ShareLink } from 'src/app/data/share-link'
|
||||
@@ -10,12 +10,17 @@ import { ToastService } from 'src/app/services/toast.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-share-links-dialog',
|
||||
templateUrl: './share-links-dialog.component.html',
|
||||
styleUrls: ['./share-links-dialog.component.scss'],
|
||||
imports: [FormsModule, ReactiveFormsModule, NgxBootstrapIconsModule],
|
||||
selector: 'pngx-share-links-dropdown',
|
||||
templateUrl: './share-links-dropdown.component.html',
|
||||
styleUrls: ['./share-links-dropdown.component.scss'],
|
||||
imports: [
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgbDropdownModule,
|
||||
NgxBootstrapIconsModule,
|
||||
],
|
||||
})
|
||||
export class ShareLinksDialogComponent implements OnInit {
|
||||
export class ShareLinksDropdownComponent implements OnInit {
|
||||
EXPIRATION_OPTIONS = [
|
||||
{ label: $localize`1 day`, value: 1 },
|
||||
{ label: $localize`7 days`, value: 7 },
|
||||
@@ -36,6 +41,9 @@ export class ShareLinksDialogComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
@Input()
|
||||
disabled: boolean = false
|
||||
|
||||
private _hasArchiveVersion: boolean = true
|
||||
|
||||
@Input()
|
||||
@@ -59,7 +67,6 @@ export class ShareLinksDialogComponent implements OnInit {
|
||||
useArchiveVersion: boolean = true
|
||||
|
||||
constructor(
|
||||
private activeModal: NgbActiveModal,
|
||||
private shareLinkService: ShareLinkService,
|
||||
private toastService: ToastService,
|
||||
private clipboard: Clipboard
|
||||
@@ -162,8 +169,4 @@ export class ShareLinksDialogComponent implements OnInit {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
close() {
|
||||
this.activeModal.close()
|
||||
}
|
||||
}
|
@@ -12,7 +12,7 @@
|
||||
</div>
|
||||
} @else {
|
||||
<div class="row row-cols-1 row-cols-md-4 g-3">
|
||||
<div class="col">
|
||||
<div class="col-4">
|
||||
<div class="card bg-light h-100">
|
||||
<div class="card-header">
|
||||
<h6 class="card-title mb-0" i18n>Environment</h6>
|
||||
@@ -46,14 +46,14 @@
|
||||
<dd>{{status.database.type}}</dd>
|
||||
<dt i18n>Status</dt>
|
||||
<dd>
|
||||
<button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="databaseStatus" triggers="click mouseenter:mouseleave">
|
||||
<div class="badge text-uppercase bg-info text-dark" [ngbPopover]="databaseStatus" triggers="mouseenter:mouseleave">
|
||||
{{status.database.status}}
|
||||
@if (status.database.status === 'OK') {
|
||||
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
|
||||
} @else {
|
||||
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<ng-template #databaseStatus>
|
||||
@if (status.database.status === 'OK') {
|
||||
{{status.database.url}}
|
||||
@@ -64,7 +64,7 @@
|
||||
</dd>
|
||||
<dt i18n>Migration Status</dt>
|
||||
<dd>
|
||||
<button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="migrationStatus" triggers="click mouseenter:mouseleave">
|
||||
<div class="badge text-uppercase bg-info text-dark" [ngbPopover]="migrationStatus" triggers="mouseenter:mouseleave">
|
||||
@if (status.database.migration_status.unapplied_migrations.length === 0) {
|
||||
<ng-container i18n>Up to date</ng-container><i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
|
||||
} @else {
|
||||
@@ -81,7 +81,7 @@
|
||||
</ul>
|
||||
}
|
||||
</ng-template>
|
||||
</button>
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
@@ -97,14 +97,14 @@
|
||||
<dl class="card-text">
|
||||
<dt i18n>Redis Status</dt>
|
||||
<dd>
|
||||
<button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="redisStatus" triggers="click mouseenter:mouseleave">
|
||||
<div class="badge text-uppercase bg-info text-dark" [ngbPopover]="redisStatus" triggers="mouseenter:mouseleave">
|
||||
{{status.tasks.redis_status}}
|
||||
@if (status.tasks.redis_status === 'OK') {
|
||||
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
|
||||
} @else {
|
||||
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<ng-template #redisStatus>
|
||||
@if (status.tasks.redis_status === 'OK') {
|
||||
{{status.tasks.redis_url}}
|
||||
@@ -115,14 +115,14 @@
|
||||
</dd>
|
||||
<dt i18n>Celery Status</dt>
|
||||
<dd>
|
||||
<button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="celeryStatus" triggers="click mouseenter:mouseleave">
|
||||
<div class="badge text-uppercase bg-info text-dark" [ngbPopover]="celeryStatus" triggers="mouseenter:mouseleave">
|
||||
{{status.tasks.celery_status}}
|
||||
@if (status.tasks.celery_status === 'OK') {
|
||||
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
|
||||
} @else {
|
||||
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<ng-template #celeryStatus>
|
||||
@if (status.tasks.celery_status === 'OK') {
|
||||
{{status.tasks.celery_url}}
|
||||
@@ -144,8 +144,8 @@
|
||||
<div class="card-body">
|
||||
<dl class="card-text">
|
||||
<dt i18n>Search Index</dt>
|
||||
<dd class="d-flex align-items-center">
|
||||
<button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="indexStatus" triggers="click mouseenter:mouseleave">
|
||||
<dd>
|
||||
<div class="badge text-uppercase bg-info text-dark" [ngbPopover]="indexStatus" triggers="mouseenter:mouseleave">
|
||||
{{status.tasks.index_status}}
|
||||
@if (status.tasks.index_status === 'OK') {
|
||||
@if (isStale(status.tasks.index_last_modified)) {
|
||||
@@ -156,17 +156,7 @@
|
||||
} @else {
|
||||
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
|
||||
}
|
||||
</button>
|
||||
@if (currentUserIsSuperUser) {
|
||||
@if (isRunning(PaperlessTaskName.IndexOptimize)) {
|
||||
<div class="spinner-border spinner-border-sm ms-2" role="status"></div>
|
||||
} @else {
|
||||
<button class="btn btn-sm d-flex align-items-center btn-dark small ms-2" (click)="runTask(PaperlessTaskName.IndexOptimize)">
|
||||
<i-bs name="play-fill"></i-bs>
|
||||
<ng-container i18n>Run Task</ng-container>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</dd>
|
||||
<ng-template #indexStatus>
|
||||
@if (status.tasks.index_status === 'OK') {
|
||||
@@ -176,8 +166,8 @@
|
||||
}
|
||||
</ng-template>
|
||||
<dt i18n>Classifier</dt>
|
||||
<dd class="d-flex align-items-center">
|
||||
<button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="classifierStatus" triggers="click mouseenter:mouseleave">
|
||||
<dd>
|
||||
<div class="badge text-uppercase bg-info text-dark" [ngbPopover]="classifierStatus" triggers="mouseenter:mouseleave">
|
||||
{{status.tasks.classifier_status}}
|
||||
@if (status.tasks.classifier_status === 'OK') {
|
||||
@if (isStale(status.tasks.classifier_last_trained)) {
|
||||
@@ -190,17 +180,7 @@
|
||||
[class.text-danger]="status.tasks.classifier_status === SystemStatusItemStatus.ERROR"
|
||||
[class.text-warning]="status.tasks.classifier_status === SystemStatusItemStatus.WARNING"></i-bs>
|
||||
}
|
||||
</button>
|
||||
@if (currentUserIsSuperUser) {
|
||||
@if (isRunning(PaperlessTaskName.TrainClassifier)) {
|
||||
<div class="spinner-border spinner-border-sm ms-2" role="status"></div>
|
||||
} @else {
|
||||
<button class="btn btn-sm d-flex align-items-center btn-dark small ms-2" (click)="runTask(PaperlessTaskName.TrainClassifier)">
|
||||
<i-bs name="play-fill"></i-bs>
|
||||
<ng-container i18n>Run Task</ng-container>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</dd>
|
||||
<ng-template #classifierStatus>
|
||||
@if (status.tasks.classifier_status === 'OK') {
|
||||
@@ -210,8 +190,8 @@
|
||||
}
|
||||
</ng-template>
|
||||
<dt i18n>Sanity Checker</dt>
|
||||
<dd class="d-flex align-items-center">
|
||||
<button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="sanityCheckerStatus" triggers="click mouseenter:mouseleave">
|
||||
<dd>
|
||||
<div class="badge text-uppercase bg-info text-dark" [ngbPopover]="sanityCheckerStatus" triggers="mouseenter:mouseleave">
|
||||
{{status.tasks.sanity_check_status}}
|
||||
@if (status.tasks.sanity_check_status === 'OK') {
|
||||
@if (isStale(status.tasks.sanity_check_last_run)) {
|
||||
@@ -224,17 +204,7 @@
|
||||
[class.text-danger]="status.tasks.sanity_check_status === SystemStatusItemStatus.ERROR"
|
||||
[class.text-warning]="status.tasks.sanity_check_status === SystemStatusItemStatus.WARNING"></i-bs>
|
||||
}
|
||||
</button>
|
||||
@if (currentUserIsSuperUser) {
|
||||
@if (isRunning(PaperlessTaskName.SanityCheck)) {
|
||||
<div class="spinner-border spinner-border-sm ms-2" role="status"></div>
|
||||
} @else {
|
||||
<button class="btn btn-sm d-flex align-items-center btn-dark small ms-2" (click)="runTask(PaperlessTaskName.SanityCheck)">
|
||||
<i-bs name="play-fill"></i-bs>
|
||||
<ng-container i18n>Run Task</ng-container>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</dd>
|
||||
<ng-template #sanityCheckerStatus>
|
||||
@if (status.tasks.sanity_check_status === 'OK') {
|
||||
@@ -251,7 +221,7 @@
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-sm d-flex align-items-center btn-dark btn btn-sm d-flex align-items-center btn-dark btn-outline-secondary" (click)="copy()">
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="copy()">
|
||||
@if (!copied) {
|
||||
<i-bs name="clipboard-fill"></i-bs>
|
||||
}
|
||||
|
@@ -1,3 +1,3 @@
|
||||
.btn.small {
|
||||
font-size: 0.75rem;
|
||||
.border-primary {
|
||||
--bs-border-color: var(--bs-primary);
|
||||
}
|
||||
|
@@ -9,16 +9,11 @@ import {
|
||||
} from '@angular/core/testing'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { PaperlessTaskName } from 'src/app/data/paperless-task'
|
||||
import {
|
||||
InstallType,
|
||||
SystemStatus,
|
||||
SystemStatusItemStatus,
|
||||
} from 'src/app/data/system-status'
|
||||
import { SystemStatusService } from 'src/app/services/system-status.service'
|
||||
import { TasksService } from 'src/app/services/tasks.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { SystemStatusDialogComponent } from './system-status-dialog.component'
|
||||
|
||||
const status: SystemStatus = {
|
||||
@@ -59,9 +54,6 @@ describe('SystemStatusDialogComponent', () => {
|
||||
let component: SystemStatusDialogComponent
|
||||
let fixture: ComponentFixture<SystemStatusDialogComponent>
|
||||
let clipboard: Clipboard
|
||||
let tasksService: TasksService
|
||||
let systemStatusService: SystemStatusService
|
||||
let toastService: ToastService
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
@@ -80,9 +72,6 @@ describe('SystemStatusDialogComponent', () => {
|
||||
component = fixture.componentInstance
|
||||
component.status = status
|
||||
clipboard = TestBed.inject(Clipboard)
|
||||
tasksService = TestBed.inject(TasksService)
|
||||
systemStatusService = TestBed.inject(SystemStatusService)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
@@ -109,37 +98,4 @@ describe('SystemStatusDialogComponent', () => {
|
||||
expect(component.isStale(date.toISOString())).toBeTruthy()
|
||||
expect(component.isStale(date.toISOString(), 26)).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should check if task is running', () => {
|
||||
component.runTask(PaperlessTaskName.IndexOptimize)
|
||||
expect(component.isRunning(PaperlessTaskName.IndexOptimize)).toBeTruthy()
|
||||
expect(component.isRunning(PaperlessTaskName.SanityCheck)).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should support running tasks, refresh status and show toasts', () => {
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const getStatusSpy = jest.spyOn(systemStatusService, 'get')
|
||||
const runSpy = jest.spyOn(tasksService, 'run')
|
||||
|
||||
// fail first
|
||||
runSpy.mockReturnValue(throwError(() => new Error('error')))
|
||||
component.runTask(PaperlessTaskName.IndexOptimize)
|
||||
expect(runSpy).toHaveBeenCalledWith(PaperlessTaskName.IndexOptimize)
|
||||
expect(toastErrorSpy).toHaveBeenCalledWith(
|
||||
`Failed to start task ${PaperlessTaskName.IndexOptimize}, see the logs for more details`,
|
||||
expect.any(Error)
|
||||
)
|
||||
|
||||
// succeed
|
||||
runSpy.mockReturnValue(of({}))
|
||||
getStatusSpy.mockReturnValue(of(status))
|
||||
component.runTask(PaperlessTaskName.IndexOptimize)
|
||||
expect(runSpy).toHaveBeenCalledWith(PaperlessTaskName.IndexOptimize)
|
||||
|
||||
expect(getStatusSpy).toHaveBeenCalled()
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
`Task ${PaperlessTaskName.IndexOptimize} started`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
@@ -7,17 +7,12 @@ import {
|
||||
NgbProgressbarModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { PaperlessTaskName } from 'src/app/data/paperless-task'
|
||||
import {
|
||||
SystemStatus,
|
||||
SystemStatusItemStatus,
|
||||
} from 'src/app/data/system-status'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { SystemStatusService } from 'src/app/services/system-status.service'
|
||||
import { TasksService } from 'src/app/services/tasks.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-system-status-dialog',
|
||||
@@ -35,24 +30,13 @@ import { ToastService } from 'src/app/services/toast.service'
|
||||
})
|
||||
export class SystemStatusDialogComponent {
|
||||
public SystemStatusItemStatus = SystemStatusItemStatus
|
||||
public PaperlessTaskName = PaperlessTaskName
|
||||
public status: SystemStatus
|
||||
|
||||
public copied: boolean = false
|
||||
|
||||
private runningTasks: Set<PaperlessTaskName> = new Set()
|
||||
|
||||
get currentUserIsSuperUser(): boolean {
|
||||
return this.permissionsService.isSuperUser()
|
||||
}
|
||||
|
||||
constructor(
|
||||
public activeModal: NgbActiveModal,
|
||||
private clipboard: Clipboard,
|
||||
private systemStatusService: SystemStatusService,
|
||||
private tasksService: TasksService,
|
||||
private toastService: ToastService,
|
||||
private permissionsService: PermissionsService
|
||||
private clipboard: Clipboard
|
||||
) {}
|
||||
|
||||
public close() {
|
||||
@@ -72,30 +56,4 @@ export class SystemStatusDialogComponent {
|
||||
const now = new Date()
|
||||
return now.getTime() - date.getTime() > hours * 60 * 60 * 1000
|
||||
}
|
||||
|
||||
public isRunning(taskName: PaperlessTaskName): boolean {
|
||||
return this.runningTasks.has(taskName)
|
||||
}
|
||||
|
||||
public runTask(taskName: PaperlessTaskName) {
|
||||
this.runningTasks.add(taskName)
|
||||
this.toastService.showInfo(`Task ${taskName} started`)
|
||||
this.tasksService.run(taskName).subscribe({
|
||||
next: () => {
|
||||
this.runningTasks.delete(taskName)
|
||||
this.systemStatusService.get().subscribe({
|
||||
next: (status) => {
|
||||
this.status = status
|
||||
},
|
||||
})
|
||||
},
|
||||
error: (err) => {
|
||||
this.runningTasks.delete(taskName)
|
||||
this.toastService.showError(
|
||||
`Failed to start task ${taskName}, see the logs for more details`,
|
||||
err
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@@ -33,7 +33,7 @@
|
||||
}
|
||||
<div class="row">
|
||||
<div class="col offset-sm-3">
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="copyError(toast.error)">
|
||||
<button class="btn btn-sm btn-outline-dark" (click)="copyError(toast.error)">
|
||||
@if (!copied) {
|
||||
<i-bs name="clipboard"></i-bs>
|
||||
}
|
||||
@@ -51,6 +51,6 @@
|
||||
<p class="mb-0 mt-2"><button class="btn btn-sm btn-outline-secondary" (click)="close.emit(toast); toast.action()">{{toast.actionName}}</button></p>
|
||||
}
|
||||
</div>
|
||||
<button type="button" class="btn-close ms-auto flex-shrink-0" data-bs-dismiss="toast" aria-label="Close" (click)="closed.emit(toast);"></button>
|
||||
<button type="button" class="btn-close ms-auto flex-shrink-0" data-bs-dismiss="toast" aria-label="Close" (click)="close.emit(toast);"></button>
|
||||
</div>
|
||||
</ngb-toast>
|
||||
|
@@ -27,7 +27,7 @@ export class ToastComponent {
|
||||
|
||||
@Output() hidden: EventEmitter<Toast> = new EventEmitter<Toast>()
|
||||
|
||||
@Output() closed: EventEmitter<Toast> = new EventEmitter<Toast>()
|
||||
@Output() close: EventEmitter<Toast> = new EventEmitter<Toast>()
|
||||
|
||||
public copied: boolean = false
|
||||
|
||||
|
@@ -1,3 +1,3 @@
|
||||
@for (toast of toasts; track toast.id) {
|
||||
<pngx-toast [toast]="toast" [autohide]="true" (closed)="closeToast()"></pngx-toast>
|
||||
<pngx-toast [toast]="toast" [autohide]="true" (close)="closeToast()"></pngx-toast>
|
||||
}
|
||||
|
@@ -49,8 +49,12 @@
|
||||
</div>
|
||||
<div class="col-12 col-lg-4 col-xl-3 col-sidebar">
|
||||
<div class="row row-cols-1 g-4 mb-4 sticky-lg-top z-0">
|
||||
<pngx-upload-file-widget></pngx-upload-file-widget>
|
||||
<pngx-statistics-widget *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.UISettings }"></pngx-statistics-widget>
|
||||
<div class="col">
|
||||
<pngx-statistics-widget *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.UISettings }"></pngx-statistics-widget>
|
||||
</div>
|
||||
<div class="col">
|
||||
<pngx-upload-file-widget></pngx-upload-file-widget>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,15 +1,12 @@
|
||||
<pngx-widget-frame *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Document }" [cardless]="true">
|
||||
<pngx-widget-frame title="Upload new documents" i18n-title *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Document }">
|
||||
<div content tourAnchor="tour.upload-widget">
|
||||
<form class="justify-content-center d-flex flex-column align-items-center">
|
||||
<button type="button" class="btn btn-outline-dark bg-light shadow-sm w-100 h-100 pt-3 pb-3" (click)="fileUpload.click()">
|
||||
<i-bs class="text-primary" name="plus-circle"></i-bs>
|
||||
<span class="text-primary" i18n>Upload documents</span>
|
||||
<div class="text-muted smaller fst-italic" i18n>or drop files anywhere</div>
|
||||
</button>
|
||||
<form class="justify-content-center d-flex flex-column align-items-center py-3 px-2">
|
||||
<span class="text-muted" i18n>Drop documents anywhere or</span>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary mt-3" (click)="fileUpload.click()" i18n>Browse files</button>
|
||||
<input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload>
|
||||
</form>
|
||||
@if (getStatus().length > 0) {
|
||||
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none consumer-status-list" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-md-3 offset-lg-2'">
|
||||
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none max-vh100-40" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-md-3 offset-lg-2'">
|
||||
<div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto overflow-y-scroll">
|
||||
<div class="card shadow-sm consumer-status-card">
|
||||
<div class="card-body">
|
||||
@@ -33,6 +30,24 @@
|
||||
<ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container>
|
||||
</div>
|
||||
}
|
||||
@if (getStatusHidden().length) {
|
||||
<div class="alerts-hidden">
|
||||
@if (!alertsExpanded) {
|
||||
<p class="mt-3 mb-0 text-center">
|
||||
<span i18n="This is shown as a summary line when there are more than 5 document in the processing pipeline.">{getStatusHidden().length, plural, =1 {One more document} other {{{getStatusHidden().length}} more documents}}</span>
|
||||
•
|
||||
<a [routerLink]="[]" (click)="alertsExpanded = !alertsExpanded" aria-controls="hiddenAlerts" [attr.aria-expanded]="alertsExpanded" i18n>Show all</a>
|
||||
</p>
|
||||
}
|
||||
<div #hiddenAlerts="ngbCollapse" [ngbCollapse]="!alertsExpanded" (ngbCollapseChange)="alertsExpanded = $event">
|
||||
@for (status of getStatusHidden(); track status) {
|
||||
<div>
|
||||
<ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,13 +1,5 @@
|
||||
:host ::ng-deep i-bs svg {
|
||||
margin-top: -3px;
|
||||
}
|
||||
|
||||
.btn-outline-dark {
|
||||
--bs-btn-border-color: var(--bs-border-color-translucent);
|
||||
}
|
||||
|
||||
.smaller {
|
||||
font-size: 0.75rem;
|
||||
form {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.alert-heading {
|
||||
@@ -48,10 +40,6 @@
|
||||
background-color: rgba(var(--bs-body-bg-rgb), .95) !important;
|
||||
}
|
||||
|
||||
.consumer-status-list {
|
||||
max-height: calc(100vh - 312px); // e.g. below the upload button, mobile
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
max-height: calc(100vh - 208px); // e.g. below the upload button
|
||||
}
|
||||
.max-vh100-40 {
|
||||
max-height: calc(100vh - 40px);
|
||||
}
|
||||
|
@@ -8,6 +8,7 @@ import {
|
||||
} from '@angular/core/testing'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import { NgbAlert, NgbCollapse } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { routes } from 'src/app/app-routing.module'
|
||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
@@ -115,6 +116,20 @@ describe('UploadFileWidgetComponent', () => {
|
||||
expect(component.getStatusColor(successStatus)).toEqual('success')
|
||||
})
|
||||
|
||||
it('should enforce a maximum number of alerts', () => {
|
||||
mockConsumerStatuses(websocketStatusService)
|
||||
fixture.detectChanges()
|
||||
// 5 total, 1 hidden
|
||||
expect(fixture.debugElement.queryAll(By.directive(NgbAlert))).toHaveLength(
|
||||
6
|
||||
)
|
||||
expect(
|
||||
fixture.debugElement
|
||||
.query(By.directive(NgbCollapse))
|
||||
.queryAll(By.directive(NgbAlert))
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should allow dismissing an alert', () => {
|
||||
const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss')
|
||||
component.dismiss(new FileStatus())
|
||||
@@ -123,6 +138,7 @@ describe('UploadFileWidgetComponent', () => {
|
||||
|
||||
it('should allow dismissing completed alerts', fakeAsync(() => {
|
||||
mockConsumerStatuses(websocketStatusService)
|
||||
component.alertsExpanded = true
|
||||
fixture.detectChanges()
|
||||
jest
|
||||
.spyOn(component, 'getStatusCompleted')
|
||||
|
@@ -4,6 +4,7 @@ import { RouterModule } from '@angular/router'
|
||||
import {
|
||||
NgbAlert,
|
||||
NgbAlertModule,
|
||||
NgbCollapseModule,
|
||||
NgbProgressbarModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
@@ -20,6 +21,8 @@ import {
|
||||
} from 'src/app/services/websocket-status.service'
|
||||
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
|
||||
|
||||
const MAX_ALERTS = 5
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-upload-file-widget',
|
||||
templateUrl: './upload-file-widget.component.html',
|
||||
@@ -31,12 +34,15 @@ import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
|
||||
NgTemplateOutlet,
|
||||
RouterModule,
|
||||
NgbAlertModule,
|
||||
NgbCollapseModule,
|
||||
NgbProgressbarModule,
|
||||
NgxBootstrapIconsModule,
|
||||
TourNgBootstrapModule,
|
||||
],
|
||||
})
|
||||
export class UploadFileWidgetComponent extends ComponentWithPermissions {
|
||||
alertsExpanded = false
|
||||
|
||||
@ViewChildren(NgbAlert) alerts: QueryList<NgbAlert>
|
||||
|
||||
constructor(
|
||||
@@ -48,7 +54,7 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions {
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return this.websocketStatusService.getConsumerStatus()
|
||||
return this.websocketStatusService.getConsumerStatus().slice(0, MAX_ALERTS)
|
||||
}
|
||||
|
||||
getStatusSummary() {
|
||||
@@ -71,6 +77,13 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions {
|
||||
)
|
||||
}
|
||||
|
||||
getStatusHidden() {
|
||||
if (this.websocketStatusService.getConsumerStatus().length < MAX_ALERTS)
|
||||
return []
|
||||
else
|
||||
return this.websocketStatusService.getConsumerStatus().slice(MAX_ALERTS)
|
||||
}
|
||||
|
||||
getStatusUploading() {
|
||||
return this.websocketStatusService.getConsumerStatus(
|
||||
FileStatusPhase.UPLOADING
|
||||
|
@@ -1,32 +1,23 @@
|
||||
@if (!cardless) {
|
||||
<div class="card shadow-sm bg-light fade" [class.show]="show" cdkDrag [cdkDragDisabled]="!draggable" cdkDragPreviewContainer="parent">
|
||||
<div class="card-header">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex">
|
||||
@if (draggable) {
|
||||
<div class="ms-n2 me-1" cdkDragHandle>
|
||||
<i-bs name="grip-vertical"></i-bs>
|
||||
</div>
|
||||
}
|
||||
<h6 class="card-title mb-0">{{title}}</h6>
|
||||
</div>
|
||||
@if (loading) {
|
||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||
<div class="visually-hidden" i18n>Loading...</div>
|
||||
<div class="card shadow-sm bg-light fade" [class.show]="show" cdkDrag [cdkDragDisabled]="!draggable" cdkDragPreviewContainer="parent">
|
||||
<div class="card-header">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex">
|
||||
@if (draggable) {
|
||||
<div class="ms-n2 me-1" cdkDragHandle>
|
||||
<i-bs name="grip-vertical"></i-bs>
|
||||
</div>
|
||||
}
|
||||
<ng-content select="[header-buttons]"></ng-content>
|
||||
<h6 class="card-title mb-0">{{title}}</h6>
|
||||
</div>
|
||||
@if (loading) {
|
||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||
<div class="visually-hidden" i18n>Loading...</div>
|
||||
}
|
||||
<ng-content select="[header-buttons]"></ng-content>
|
||||
</div>
|
||||
<div class="card-body text-dark">
|
||||
<ng-container [ngTemplateOutlet]="content"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="fade" [class.show]="show">
|
||||
<ng-container [ngTemplateOutlet]="content"></ng-container>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ng-template #content>
|
||||
<ng-content select="[content]"></ng-content>
|
||||
</ng-template>
|
||||
</div>
|
||||
<div class="card-body text-dark">
|
||||
<ng-content select="[content]"></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import { DragDropModule } from '@angular/cdk/drag-drop'
|
||||
import { NgTemplateOutlet } from '@angular/common'
|
||||
import { AfterViewInit, Component, Input } from '@angular/core'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { LoadingComponentWithPermissions } from 'src/app/components/loading-component/loading.component'
|
||||
@@ -8,7 +7,7 @@ import { LoadingComponentWithPermissions } from 'src/app/components/loading-comp
|
||||
selector: 'pngx-widget-frame',
|
||||
templateUrl: './widget-frame.component.html',
|
||||
styleUrls: ['./widget-frame.component.scss'],
|
||||
imports: [DragDropModule, NgxBootstrapIconsModule, NgTemplateOutlet],
|
||||
imports: [DragDropModule, NgxBootstrapIconsModule],
|
||||
})
|
||||
export class WidgetFrameComponent
|
||||
extends LoadingComponentWithPermissions
|
||||
@@ -27,9 +26,6 @@ export class WidgetFrameComponent
|
||||
@Input()
|
||||
draggable: any
|
||||
|
||||
@Input()
|
||||
cardless: boolean = false
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
setTimeout(() => {
|
||||
this.show = true
|
||||
|
@@ -81,24 +81,7 @@
|
||||
(added)="addField($event)">
|
||||
</pngx-custom-fields-dropdown>
|
||||
|
||||
|
||||
<div class="ms-auto" ngbDropdown>
|
||||
<button class="btn btn-sm btn-outline-primary" id="sendDropdown" ngbDropdownToggle>
|
||||
<i-bs name="send"></i-bs>
|
||||
<div class="d-none d-sm-inline"> <ng-container i18n>Send</ng-container></div>
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow">
|
||||
<button ngbDropdownItem (click)="openShareLinks()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ShareLink }">
|
||||
<i-bs name="link"></i-bs> <span i18n>Share Links</span>
|
||||
</button>
|
||||
@if (emailEnabled) {
|
||||
<button ngbDropdownItem (click)="openEmailDocument()">
|
||||
<i-bs name="envelope"></i-bs> <span i18n>Email</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pngx-share-links-dropdown [documentId]="documentId" [hasArchiveVersion]="!!document?.archived_file_name" [disabled]="!userCanEdit && !userIsOwner" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ShareLink }"></pngx-share-links-dropdown>
|
||||
</pngx-page-header>
|
||||
|
||||
<div class="row">
|
||||
|
@@ -1330,18 +1330,4 @@ describe('DocumentDetailComponent', () => {
|
||||
expect(createSpy).toHaveBeenCalledWith('a')
|
||||
expect(urlRevokeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should get email enabled status from settings', () => {
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||
expect(component.emailEnabled).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should support open share links and email modals', () => {
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
initNormally()
|
||||
component.openShareLinks()
|
||||
expect(modalSpy).toHaveBeenCalled()
|
||||
component.openEmailDocument()
|
||||
expect(modalSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
@@ -88,7 +88,6 @@ import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspo
|
||||
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||
import { EditDialogMode } from '../common/edit-dialog/edit-dialog.component'
|
||||
import { StoragePathEditDialogComponent } from '../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
|
||||
import { EmailDocumentDialogComponent } from '../common/email-document-dialog/email-document-dialog.component'
|
||||
import { CheckComponent } from '../common/input/check/check.component'
|
||||
import { DateComponent } from '../common/input/date/date.component'
|
||||
import { DocumentLinkComponent } from '../common/input/document-link/document-link.component'
|
||||
@@ -100,7 +99,7 @@ import { TagsComponent } from '../common/input/tags/tags.component'
|
||||
import { TextComponent } from '../common/input/text/text.component'
|
||||
import { UrlComponent } from '../common/input/url/url.component'
|
||||
import { PageHeaderComponent } from '../common/page-header/page-header.component'
|
||||
import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.component'
|
||||
import { ShareLinksDropdownComponent } from '../common/share-links-dropdown/share-links-dropdown.component'
|
||||
import { DocumentHistoryComponent } from '../document-history/document-history.component'
|
||||
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
|
||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||
@@ -146,6 +145,7 @@ export enum ZoomSetting {
|
||||
CustomFieldsDropdownComponent,
|
||||
DocumentNotesComponent,
|
||||
DocumentHistoryComponent,
|
||||
ShareLinksDropdownComponent,
|
||||
CheckComponent,
|
||||
DateComponent,
|
||||
DocumentLinkComponent,
|
||||
@@ -1426,26 +1426,6 @@ export class DocumentDetailComponent
|
||||
})
|
||||
}
|
||||
|
||||
public openShareLinks() {
|
||||
const modal = this.modalService.open(ShareLinksDialogComponent)
|
||||
modal.componentInstance.documentId = this.document.id
|
||||
modal.componentInstance.hasArchiveVersion =
|
||||
!!this.document?.archived_file_name
|
||||
}
|
||||
|
||||
get emailEnabled(): boolean {
|
||||
return this.settings.get(SETTINGS_KEYS.EMAIL_ENABLED)
|
||||
}
|
||||
|
||||
public openEmailDocument() {
|
||||
const modal = this.modalService.open(EmailDocumentDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.documentId = this.document.id
|
||||
modal.componentInstance.hasArchiveVersion =
|
||||
!!this.document?.archived_file_name
|
||||
}
|
||||
|
||||
private tryRenderTiff() {
|
||||
this.http.get(this.previewUrl, { responseType: 'arraybuffer' }).subscribe({
|
||||
next: (res) => {
|
||||
|
@@ -376,7 +376,7 @@ describe('DocumentListComponent', () => {
|
||||
expect(documentListService.selected.size).toEqual(3)
|
||||
})
|
||||
|
||||
it('should support saving a view', () => {
|
||||
it('should support saving an edited view', () => {
|
||||
const view: SavedView = {
|
||||
id: 10,
|
||||
name: 'Saved View 10',
|
||||
@@ -414,30 +414,6 @@ describe('DocumentListComponent', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle error on view saving', () => {
|
||||
component.list.activateSavedView({
|
||||
id: 10,
|
||||
name: 'Saved View 10',
|
||||
sort_field: 'added',
|
||||
sort_reverse: true,
|
||||
filter_rules: [
|
||||
{
|
||||
rule_type: FILTER_HAS_TAGS_ANY,
|
||||
value: '20',
|
||||
},
|
||||
],
|
||||
})
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
jest
|
||||
.spyOn(savedViewService, 'patch')
|
||||
.mockReturnValueOnce(throwError(() => new Error('Error saving view')))
|
||||
component.saveViewConfig()
|
||||
expect(toastErrorSpy).toHaveBeenCalledWith(
|
||||
'Failed to save view "Saved View 10".',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('should support edited view saving as', () => {
|
||||
const view: SavedView = {
|
||||
id: 10,
|
||||
|
@@ -377,20 +377,12 @@ export class DocumentListComponent
|
||||
this.savedViewService
|
||||
.patch(savedView)
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: (view) => {
|
||||
this.unmodifiedSavedView = view
|
||||
this.toastService.showInfo(
|
||||
$localize`View "${this.list.activeSavedViewTitle}" saved successfully.`
|
||||
)
|
||||
this.unmodifiedFilterRules = this.list.filterRules
|
||||
},
|
||||
error: (err) => {
|
||||
this.toastService.showError(
|
||||
$localize`Failed to save view "${this.list.activeSavedViewTitle}".`,
|
||||
err
|
||||
)
|
||||
},
|
||||
.subscribe((view) => {
|
||||
this.unmodifiedSavedView = view
|
||||
this.toastService.showInfo(
|
||||
$localize`View "${this.list.activeSavedViewTitle}" saved successfully.`
|
||||
)
|
||||
this.unmodifiedFilterRules = this.list.filterRules
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@@ -17,7 +17,7 @@
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="!buttonsEnabled">Cancel</button>
|
||||
<button type="button" class="btn btn-outline-dark" (click)="cancel()" i18n [disabled]="!buttonsEnabled">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" i18n [disabled]="!buttonsEnabled">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
|
@@ -17,7 +17,6 @@ import { DocumentListViewService } from 'src/app/services/document-list-view.ser
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
@@ -51,8 +50,7 @@ export class CustomFieldsComponent
|
||||
private toastService: ToastService,
|
||||
private documentListViewService: DocumentListViewService,
|
||||
private settingsService: SettingsService,
|
||||
private documentService: DocumentService,
|
||||
private savedViewService: SavedViewService
|
||||
private documentService: DocumentService
|
||||
) {
|
||||
super()
|
||||
}
|
||||
@@ -117,7 +115,6 @@ export class CustomFieldsComponent
|
||||
this.customFieldsService.clearCache()
|
||||
this.settingsService.initializeDisplayFields()
|
||||
this.documentService.reload()
|
||||
this.savedViewService.reload()
|
||||
this.reload()
|
||||
},
|
||||
error: (e) => {
|
||||
|
@@ -10,7 +10,6 @@ export enum PaperlessTaskName {
|
||||
ConsumeFile = 'consume_file',
|
||||
TrainClassifier = 'train_classifier',
|
||||
SanityCheck = 'check_sanity',
|
||||
IndexOptimize = 'index_optimize',
|
||||
}
|
||||
|
||||
export enum PaperlessTaskStatus {
|
||||
|
@@ -45,9 +45,7 @@ describe('CustomDatePipe', () => {
|
||||
if (now.getMonth() === 0) {
|
||||
notNow.setFullYear(now.getFullYear() - 1)
|
||||
}
|
||||
expect(['Last month', '4 weeks ago']).toContain(
|
||||
datePipe.transform(notNow, 'relative')
|
||||
)
|
||||
expect(datePipe.transform(notNow, 'relative')).toEqual('Last month')
|
||||
expect(datePipe.transform(now, 'relative')).toEqual('Just now')
|
||||
})
|
||||
})
|
||||
|
@@ -355,21 +355,6 @@ it('should include custom fields in sort fields if user has permission', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('should call appropriate api endpoint for email document', () => {
|
||||
subscription = service
|
||||
.emailDocument(
|
||||
documents[0].id,
|
||||
'hello@paperless-ngx.com',
|
||||
'hello',
|
||||
'world',
|
||||
true
|
||||
)
|
||||
.subscribe()
|
||||
httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}${endpoint}/${documents[0].id}/email/`
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
subscription?.unsubscribe()
|
||||
httpTestingController.verify()
|
||||
|
@@ -258,19 +258,4 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
||||
public get searchQuery(): string {
|
||||
return this._searchQuery
|
||||
}
|
||||
|
||||
emailDocument(
|
||||
documentId: number,
|
||||
addresses: string,
|
||||
subject: string,
|
||||
message: string,
|
||||
useArchiveVersion: boolean
|
||||
): Observable<any> {
|
||||
return this.http.post(this.getResourceUrl(documentId, 'email'), {
|
||||
addresses: addresses,
|
||||
subject: subject,
|
||||
message: message,
|
||||
use_archive_version: useArchiveVersion,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@@ -132,19 +132,4 @@ describe('TasksService', () => {
|
||||
expect(tasksService.queuedFileTasks).toHaveLength(1)
|
||||
expect(tasksService.startedFileTasks).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('supports running tasks', () => {
|
||||
tasksService.run(PaperlessTaskName.SanityCheck).subscribe((res) => {
|
||||
expect(res).toEqual({
|
||||
result: 'success',
|
||||
})
|
||||
})
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}tasks/run/`
|
||||
)
|
||||
expect(req.request.method).toEqual('POST')
|
||||
req.flush({
|
||||
result: 'success',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { HttpClient } from '@angular/common/http'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { Observable, Subject } from 'rxjs'
|
||||
import { Subject } from 'rxjs'
|
||||
import { first, takeUntil } from 'rxjs/operators'
|
||||
import {
|
||||
PaperlessTask,
|
||||
@@ -14,7 +14,6 @@ import { environment } from 'src/environments/environment'
|
||||
})
|
||||
export class TasksService {
|
||||
private baseUrl: string = environment.apiBaseUrl
|
||||
private endpoint: string = 'tasks'
|
||||
|
||||
public loading: boolean
|
||||
|
||||
@@ -56,7 +55,7 @@ export class TasksService {
|
||||
|
||||
this.http
|
||||
.get<PaperlessTask[]>(
|
||||
`${this.baseUrl}${this.endpoint}/?task_name=consume_file&acknowledged=false`
|
||||
`${this.baseUrl}tasks/?task_name=consume_file&acknowledged=false`
|
||||
)
|
||||
.pipe(takeUntil(this.unsubscribeNotifer), first())
|
||||
.subscribe((r) => {
|
||||
@@ -81,13 +80,4 @@ export class TasksService {
|
||||
public cancelPending(): void {
|
||||
this.unsubscribeNotifer.next(true)
|
||||
}
|
||||
|
||||
public run(taskName: PaperlessTaskName): Observable<any> {
|
||||
return this.http.post<any>(
|
||||
`${environment.apiBaseUrl}${this.endpoint}/run/`,
|
||||
{
|
||||
task_name: taskName,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@@ -107,13 +107,11 @@ import {
|
||||
personFillLock,
|
||||
personLock,
|
||||
personSquare,
|
||||
playFill,
|
||||
plus,
|
||||
plusCircle,
|
||||
questionCircle,
|
||||
scissors,
|
||||
search,
|
||||
send,
|
||||
slashCircle,
|
||||
sliders2Vertical,
|
||||
sortAlphaDown,
|
||||
@@ -313,13 +311,11 @@ const icons = {
|
||||
personFillLock,
|
||||
personLock,
|
||||
personSquare,
|
||||
playFill,
|
||||
plus,
|
||||
plusCircle,
|
||||
questionCircle,
|
||||
scissors,
|
||||
search,
|
||||
send,
|
||||
slashCircle,
|
||||
sliders2Vertical,
|
||||
sortAlphaDown,
|
||||
|
@@ -767,8 +767,6 @@ canvas.hiddenCanvasElement {
|
||||
}
|
||||
|
||||
.document-card {
|
||||
overflow: hidden;
|
||||
|
||||
.card-footer i-bs svg {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
@@ -194,7 +194,6 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
|
||||
border-radius: 0;
|
||||
border-color: var(--bs-border-color);
|
||||
filter: invert(10%);
|
||||
transform: translateZ(0); // fix for safari to force hw acceleration
|
||||
|
||||
&.border-end {
|
||||
border-right: none !important;
|
||||
@@ -209,6 +208,28 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
|
||||
mix-blend-mode: luminosity;
|
||||
}
|
||||
|
||||
@supports (hanging-punctuation: first) and (font: -apple-system-body) and (-webkit-appearance: none) {
|
||||
// Safari does not like the filters on the image, see https://github.com/paperless-ngx/paperless-ngx/pull/8121
|
||||
.document-card:not(.placeholder-glow),
|
||||
.document-card-large:not(.placeholder-glow) {
|
||||
.doc-img-container {
|
||||
transition: none;
|
||||
background-color: #ffffff !important;
|
||||
}
|
||||
|
||||
.doc-img {
|
||||
filter: none !important;
|
||||
box-shadow: inset 0px 0px 0px 10px rgba(0,0,0,1);
|
||||
}
|
||||
|
||||
.doc-img.inverted {
|
||||
filter: none !important;
|
||||
mix-blend-mode: difference;
|
||||
opacity: 0.95;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.paperless-input-select .ng-select .ng-dropdown-panel .ng-dropdown-panel-items .ng-option:not(.ng-option-selected):hover,
|
||||
.paperless-input-select .ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-marked {
|
||||
background-color: var(--bs-light) !important;
|
||||
@@ -291,6 +312,10 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
|
||||
color: var(--pngx-primary-text-contrast);
|
||||
}
|
||||
|
||||
.toast .btn-outline-dark:hover {
|
||||
color: var(--bs-body-color)
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
--bs-dropdown-color: var(--bs-body-color);
|
||||
--pngx-bg-darker: var(--pngx-bg-alt);
|
||||
|
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import tempfile
|
||||
@@ -12,6 +10,7 @@ from pdf2image import convert_from_path
|
||||
from pikepdf import Page
|
||||
from pikepdf import PasswordError
|
||||
from pikepdf import Pdf
|
||||
from PIL import Image
|
||||
|
||||
from documents.converters import convert_from_tiff_to_pdf
|
||||
from documents.data_models import ConsumableDocument
|
||||
@@ -26,8 +25,6 @@ from documents.utils import maybe_override_pixel_limit
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from PIL import Image
|
||||
|
||||
logger = logging.getLogger("paperless.barcodes")
|
||||
|
||||
|
||||
|
@@ -1,14 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import NoReturn
|
||||
from zipfile import ZipFile
|
||||
|
||||
from documents.models import Document
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from zipfile import ZipFile
|
||||
|
||||
from documents.models import Document
|
||||
|
||||
|
||||
class BulkArchiveStrategy:
|
||||
|
@@ -1,11 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import itertools
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Literal
|
||||
|
||||
from celery import chain
|
||||
@@ -13,6 +10,7 @@ from celery import chord
|
||||
from celery import group
|
||||
from celery import shared_task
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -31,9 +29,6 @@ from documents.tasks import bulk_update_documents
|
||||
from documents.tasks import consume_file
|
||||
from documents.tasks import update_document_content_maybe_archive_file
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
logger: logging.Logger = logging.getLogger("paperless.bulk_edit")
|
||||
|
||||
|
||||
|
@@ -1,10 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from binascii import hexlify
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Final
|
||||
from typing import Optional
|
||||
|
||||
from django.core.cache import cache
|
||||
|
||||
@@ -81,7 +80,7 @@ def get_suggestion_cache(document_id: int) -> SuggestionCacheData | None:
|
||||
def set_suggestions_cache(
|
||||
document_id: int,
|
||||
suggestions: dict,
|
||||
classifier: DocumentClassifier | None,
|
||||
classifier: Optional["DocumentClassifier"],
|
||||
*,
|
||||
timeout=CACHE_50_MINUTES,
|
||||
) -> None:
|
||||
|
@@ -1,21 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import pickle
|
||||
import re
|
||||
import warnings
|
||||
from collections.abc import Iterator
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime
|
||||
|
||||
from numpy import ndarray
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from sklearn.exceptions import InconsistentVersionWarning
|
||||
|
||||
from documents.caching import CACHE_50_MINUTES
|
||||
from documents.caching import CLASSIFIER_HASH_KEY
|
||||
@@ -37,7 +37,7 @@ class ClassifierModelCorruptError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def load_classifier(*, raise_exception: bool = False) -> DocumentClassifier | None:
|
||||
def load_classifier(*, raise_exception: bool = False) -> Optional["DocumentClassifier"]:
|
||||
if not settings.MODEL_FILE.is_file():
|
||||
logger.debug(
|
||||
"Document classification model does not exist (yet), not "
|
||||
@@ -102,8 +102,6 @@ class DocumentClassifier:
|
||||
self._stop_words = None
|
||||
|
||||
def load(self) -> None:
|
||||
from sklearn.exceptions import InconsistentVersionWarning
|
||||
|
||||
# Catch warnings for processing
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
with Path(settings.MODEL_FILE).open("rb") as f:
|
||||
|
@@ -26,6 +26,7 @@ from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import FileInfo
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.models import WorkflowTrigger
|
||||
@@ -704,6 +705,8 @@ class ConsumerPlugin(
|
||||
) -> Document:
|
||||
# If someone gave us the original filename, use it instead of doc.
|
||||
|
||||
file_info = FileInfo.from_filename(self.filename)
|
||||
|
||||
self.log.debug("Saving record to database")
|
||||
|
||||
if self.metadata.created is not None:
|
||||
@@ -711,6 +714,9 @@ class ConsumerPlugin(
|
||||
self.log.debug(
|
||||
f"Creation date from post_documents parameter: {create_date}",
|
||||
)
|
||||
elif file_info.created is not None:
|
||||
create_date = file_info.created
|
||||
self.log.debug(f"Creation date from FileInfo: {create_date}")
|
||||
elif date is not None:
|
||||
create_date = date
|
||||
self.log.debug(f"Creation date from parse_date: {create_date}")
|
||||
@@ -723,11 +729,7 @@ class ConsumerPlugin(
|
||||
|
||||
storage_type = Document.STORAGE_TYPE_UNENCRYPTED
|
||||
|
||||
if self.metadata.filename:
|
||||
title = Path(self.metadata.filename).stem
|
||||
else:
|
||||
title = self.input_doc.original_file.stem
|
||||
|
||||
title = file_info.title
|
||||
if self.metadata.title is not None:
|
||||
try:
|
||||
title = self._parse_title_placeholders(self.metadata.title)
|
||||
|
@@ -1,11 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import inspect
|
||||
import json
|
||||
import operator
|
||||
from collections.abc import Callable
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Case
|
||||
@@ -36,14 +34,12 @@ from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import Log
|
||||
from documents.models import PaperlessTask
|
||||
from documents.models import ShareLink
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
CHAR_KWARGS = ["istartswith", "iendswith", "icontains", "iexact"]
|
||||
ID_KWARGS = ["in", "exact"]
|
||||
INT_KWARGS = ["exact", "gt", "gte", "lt", "lte", "isnull"]
|
||||
@@ -760,6 +756,12 @@ class DocumentFilterSet(FilterSet):
|
||||
}
|
||||
|
||||
|
||||
class LogFilterSet(FilterSet):
|
||||
class Meta:
|
||||
model = Log
|
||||
fields = {"level": INT_KWARGS, "created": DATE_KWARGS, "group": ID_KWARGS}
|
||||
|
||||
|
||||
class ShareLinkFilterSet(FilterSet):
|
||||
class Meta:
|
||||
model = ShareLink
|
||||
|
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from collections import Counter
|
||||
@@ -7,10 +5,10 @@ from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from datetime import timezone
|
||||
from shutil import rmtree
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Literal
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import QuerySet
|
||||
from django.utils import timezone as django_timezone
|
||||
from guardian.shortcuts import get_users_with_perms
|
||||
from whoosh import classify
|
||||
@@ -34,7 +32,10 @@ from whoosh.qparser import QueryParser
|
||||
from whoosh.qparser.dateparse import DateParserPlugin
|
||||
from whoosh.qparser.dateparse import English
|
||||
from whoosh.qparser.plugins import FieldsPlugin
|
||||
from whoosh.reading import IndexReader
|
||||
from whoosh.scoring import TF_IDF
|
||||
from whoosh.searching import ResultsPage
|
||||
from whoosh.searching import Searcher
|
||||
from whoosh.util.times import timespan
|
||||
from whoosh.writing import AsyncWriter
|
||||
|
||||
@@ -43,12 +44,6 @@ from documents.models import Document
|
||||
from documents.models import Note
|
||||
from documents.models import User
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.db.models import QuerySet
|
||||
from whoosh.reading import IndexReader
|
||||
from whoosh.searching import ResultsPage
|
||||
from whoosh.searching import Searcher
|
||||
|
||||
logger = logging.getLogger("paperless.index")
|
||||
|
||||
|
||||
|
@@ -1,10 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from fnmatch import fnmatch
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from documents.classifier import DocumentClassifier
|
||||
from documents.data_models import ConsumableDocument
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.models import Correspondent
|
||||
@@ -17,9 +15,6 @@ from documents.models import Workflow
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.permissions import get_objects_for_user_owner_aware
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from documents.classifier import DocumentClassifier
|
||||
|
||||
logger = logging.getLogger("paperless.matching")
|
||||
|
||||
|
||||
|
@@ -50,7 +50,6 @@ class Migration(migrations.Migration):
|
||||
("consume_file", "Consume File"),
|
||||
("train_classifier", "Train Classifier"),
|
||||
("check_sanity", "Check Sanity"),
|
||||
("index_optimize", "Index Optimize"),
|
||||
],
|
||||
help_text="Name of the task that was run",
|
||||
max_length=255,
|
||||
|
@@ -1,15 +0,0 @@
|
||||
# Generated by Django 5.1.6 on 2025-02-28 15:19
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("documents", "1063_paperlesstask_type_alter_paperlesstask_task_name_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name="Log",
|
||||
),
|
||||
]
|
@@ -1,7 +1,12 @@
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import dateutil.parser
|
||||
import pathvalidate
|
||||
from celery import states
|
||||
from django.conf import settings
|
||||
@@ -374,6 +379,36 @@ class Document(SoftDeleteModel, ModelWithOwner):
|
||||
return timezone.localdate(self.created)
|
||||
|
||||
|
||||
class Log(models.Model):
|
||||
LEVELS = (
|
||||
(logging.DEBUG, _("debug")),
|
||||
(logging.INFO, _("information")),
|
||||
(logging.WARNING, _("warning")),
|
||||
(logging.ERROR, _("error")),
|
||||
(logging.CRITICAL, _("critical")),
|
||||
)
|
||||
|
||||
group = models.UUIDField(_("group"), blank=True, null=True)
|
||||
|
||||
message = models.TextField(_("message"))
|
||||
|
||||
level = models.PositiveIntegerField(
|
||||
_("level"),
|
||||
choices=LEVELS,
|
||||
default=logging.INFO,
|
||||
)
|
||||
|
||||
created = models.DateTimeField(_("created"), auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ("-created",)
|
||||
verbose_name = _("log")
|
||||
verbose_name_plural = _("logs")
|
||||
|
||||
def __str__(self):
|
||||
return self.message
|
||||
|
||||
|
||||
class SavedView(ModelWithOwner):
|
||||
class DisplayMode(models.TextChoices):
|
||||
TABLE = ("table", _("Table"))
|
||||
@@ -513,6 +548,91 @@ class SavedViewFilterRule(models.Model):
|
||||
return f"SavedViewFilterRule: {self.rule_type} : {self.value}"
|
||||
|
||||
|
||||
# TODO: why is this in the models file?
|
||||
# TODO: how about, what is this and where is it documented?
|
||||
# It appears to parsing JSON from an environment variable to get a title and date from
|
||||
# the filename, if possible, as a higher priority than either document filename or
|
||||
# content parsing
|
||||
class FileInfo:
|
||||
REGEXES = OrderedDict(
|
||||
[
|
||||
(
|
||||
"created-title",
|
||||
re.compile(
|
||||
r"^(?P<created>\d{8}(\d{6})?Z) - (?P<title>.*)$",
|
||||
flags=re.IGNORECASE,
|
||||
),
|
||||
),
|
||||
("title", re.compile(r"(?P<title>.*)$", flags=re.IGNORECASE)),
|
||||
],
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
created=None,
|
||||
correspondent=None,
|
||||
title=None,
|
||||
tags=(),
|
||||
extension=None,
|
||||
):
|
||||
self.created = created
|
||||
self.title = title
|
||||
self.extension = extension
|
||||
self.correspondent = correspondent
|
||||
self.tags = tags
|
||||
|
||||
@classmethod
|
||||
def _get_created(cls, created):
|
||||
try:
|
||||
return dateutil.parser.parse(f"{created[:-1]:0<14}Z")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _get_title(cls, title):
|
||||
return title
|
||||
|
||||
@classmethod
|
||||
def _mangle_property(cls, properties, name):
|
||||
if name in properties:
|
||||
properties[name] = getattr(cls, f"_get_{name}")(properties[name])
|
||||
|
||||
@classmethod
|
||||
def from_filename(cls, filename) -> "FileInfo":
|
||||
# Mutate filename in-place before parsing its components
|
||||
# by applying at most one of the configured transformations.
|
||||
for pattern, repl in settings.FILENAME_PARSE_TRANSFORMS:
|
||||
(filename, count) = pattern.subn(repl, filename)
|
||||
if count:
|
||||
break
|
||||
|
||||
# do this after the transforms so that the transforms can do whatever
|
||||
# with the file extension.
|
||||
filename_no_ext = os.path.splitext(filename)[0]
|
||||
|
||||
if filename_no_ext == filename and filename.startswith("."):
|
||||
# This is a very special case where there is no text before the
|
||||
# file type.
|
||||
# TODO: this should be handled better. The ext is not removed
|
||||
# because usually, files like '.pdf' are just hidden files
|
||||
# with the name pdf, but in our case, its more likely that
|
||||
# there's just no name to begin with.
|
||||
filename = ""
|
||||
# This isn't too bad either, since we'll just not match anything
|
||||
# and return an empty title. TODO: actually, this is kinda bad.
|
||||
else:
|
||||
filename = filename_no_ext
|
||||
|
||||
# Parse filename components.
|
||||
for regex in cls.REGEXES.values():
|
||||
m = regex.match(filename)
|
||||
if m:
|
||||
properties = m.groupdict()
|
||||
cls._mangle_property(properties, "created")
|
||||
cls._mangle_property(properties, "title")
|
||||
return cls(**properties)
|
||||
|
||||
|
||||
# Extending User Model Using a One-To-One Link
|
||||
class UiSettings(models.Model):
|
||||
user = models.OneToOneField(
|
||||
@@ -539,7 +659,6 @@ class PaperlessTask(ModelWithOwner):
|
||||
CONSUME_FILE = ("consume_file", _("Consume File"))
|
||||
TRAIN_CLASSIFIER = ("train_classifier", _("Train Classifier"))
|
||||
CHECK_SANITY = ("check_sanity", _("Check Sanity"))
|
||||
INDEX_OPTIMIZE = ("index_optimize", _("Index Optimize"))
|
||||
|
||||
task_id = models.CharField(
|
||||
max_length=255,
|
||||
|
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
@@ -7,10 +6,10 @@ import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from collections.abc import Iterator
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from re import Match
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
@@ -20,10 +19,6 @@ from documents.signals import document_consumer_declaration
|
||||
from documents.utils import copy_file_with_basic_stats
|
||||
from documents.utils import run_subprocess
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import datetime
|
||||
from collections.abc import Iterator
|
||||
|
||||
# This regular expression will try to find dates in the document at
|
||||
# hand and will match the following formats:
|
||||
# - XX.YY.ZZZZ with XX + YY being 1 or 2 and ZZZZ being 2 or 4 digits
|
||||
@@ -111,7 +106,7 @@ def get_supported_file_extensions() -> set[str]:
|
||||
return extensions
|
||||
|
||||
|
||||
def get_parser_class_for_mime_type(mime_type: str) -> type[DocumentParser] | None:
|
||||
def get_parser_class_for_mime_type(mime_type: str) -> type["DocumentParser"] | None:
|
||||
"""
|
||||
Returns the best parser (by weight) for the given mimetype or
|
||||
None if no parser exists
|
||||
|
@@ -1,12 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import zoneinfo
|
||||
from collections.abc import Iterable
|
||||
from decimal import Decimal
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import magic
|
||||
from celery import states
|
||||
@@ -34,7 +32,6 @@ from rest_framework.fields import SerializerMethodField
|
||||
if settings.AUDIT_LOG_ENABLED:
|
||||
from auditlog.context import set_actor
|
||||
|
||||
|
||||
from documents import bulk_edit
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.models import Correspondent
|
||||
@@ -63,9 +60,6 @@ from documents.templating.utils import convert_format_str_to_template_format
|
||||
from documents.validators import uri_validator
|
||||
from documents.validators import url_validator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
logger = logging.getLogger("paperless.serializers")
|
||||
|
||||
|
||||
@@ -1748,14 +1742,6 @@ class TasksViewSerializer(OwnedObjectSerializer):
|
||||
return result
|
||||
|
||||
|
||||
class RunTaskViewSerializer(serializers.Serializer):
|
||||
task_name = serializers.ChoiceField(
|
||||
choices=PaperlessTask.TaskName.choices,
|
||||
label="Task Name",
|
||||
write_only=True,
|
||||
)
|
||||
|
||||
|
||||
class AcknowledgeTasksViewSerializer(serializers.Serializer):
|
||||
tasks = serializers.ListField(
|
||||
required=True,
|
||||
|
@@ -1,9 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from typing import TYPE_CHECKING
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from celery import shared_task
|
||||
@@ -25,6 +23,9 @@ from guardian.shortcuts import remove_perm
|
||||
|
||||
from documents import matching
|
||||
from documents.caching import clear_document_caches
|
||||
from documents.classifier import DocumentClassifier
|
||||
from documents.data_models import ConsumableDocument
|
||||
from documents.data_models import DocumentMetadataOverrides
|
||||
from documents.file_handling import create_source_path_directory
|
||||
from documents.file_handling import delete_empty_directories
|
||||
from documents.file_handling import generate_unique_filename
|
||||
@@ -36,7 +37,6 @@ from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import MatchingModel
|
||||
from documents.models import PaperlessTask
|
||||
from documents.models import SavedView
|
||||
from documents.models import Tag
|
||||
from documents.models import Workflow
|
||||
from documents.models import WorkflowAction
|
||||
@@ -46,13 +46,6 @@ from documents.permissions import get_objects_for_user_owner_aware
|
||||
from documents.permissions import set_permissions_for_object
|
||||
from documents.templating.workflows import parse_w_workflow_placeholders
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from documents.classifier import DocumentClassifier
|
||||
from documents.data_models import ConsumableDocument
|
||||
from documents.data_models import DocumentMetadataOverrides
|
||||
|
||||
logger = logging.getLogger("paperless.handlers")
|
||||
|
||||
|
||||
@@ -550,33 +543,6 @@ def check_paths_and_prune_custom_fields(sender, instance: CustomField, **kwargs)
|
||||
update_filename_and_move_files(sender, cf_instance)
|
||||
|
||||
|
||||
@receiver(models.signals.post_delete, sender=CustomField)
|
||||
def cleanup_custom_field_deletion(sender, instance: CustomField, **kwargs):
|
||||
"""
|
||||
When a custom field is deleted, ensure no saved views reference it.
|
||||
"""
|
||||
field_identifier = SavedView.DisplayFields.CUSTOM_FIELD % instance.pk
|
||||
# remove field from display_fields of all saved views
|
||||
for view in SavedView.objects.filter(display_fields__isnull=False).distinct():
|
||||
if field_identifier in view.display_fields:
|
||||
logger.debug(
|
||||
f"Removing custom field {instance} from view {view}",
|
||||
)
|
||||
view.display_fields.remove(field_identifier)
|
||||
view.save()
|
||||
|
||||
# remove from sort_field of all saved views
|
||||
views_with_sort_updated = SavedView.objects.filter(
|
||||
sort_field=field_identifier,
|
||||
).update(
|
||||
sort_field=SavedView.DisplayFields.CREATED,
|
||||
)
|
||||
if views_with_sort_updated > 0:
|
||||
logger.debug(
|
||||
f"Removing custom field {instance} from sort field of {views_with_sort_updated} views",
|
||||
)
|
||||
|
||||
|
||||
def add_to_index(sender, document, **kwargs):
|
||||
from documents import index
|
||||
|
||||
@@ -632,7 +598,7 @@ def send_webhook(
|
||||
else:
|
||||
httpx.post(
|
||||
url,
|
||||
content=data,
|
||||
data=data,
|
||||
files=files,
|
||||
headers=headers,
|
||||
).raise_for_status()
|
||||
@@ -1002,37 +968,29 @@ def run_workflows(
|
||||
added = timezone.localtime(timezone.now())
|
||||
created = timezone.localtime(overrides.created)
|
||||
|
||||
subject = (
|
||||
parse_w_workflow_placeholders(
|
||||
action.email.subject,
|
||||
correspondent,
|
||||
document_type,
|
||||
owner_username,
|
||||
added,
|
||||
filename,
|
||||
current_filename,
|
||||
created,
|
||||
title,
|
||||
doc_url,
|
||||
)
|
||||
if action.email.subject
|
||||
else ""
|
||||
subject = parse_w_workflow_placeholders(
|
||||
action.email.subject,
|
||||
correspondent,
|
||||
document_type,
|
||||
owner_username,
|
||||
added,
|
||||
filename,
|
||||
current_filename,
|
||||
created,
|
||||
title,
|
||||
doc_url,
|
||||
)
|
||||
body = (
|
||||
parse_w_workflow_placeholders(
|
||||
action.email.body,
|
||||
correspondent,
|
||||
document_type,
|
||||
owner_username,
|
||||
added,
|
||||
filename,
|
||||
current_filename,
|
||||
created,
|
||||
title,
|
||||
doc_url,
|
||||
)
|
||||
if action.email.body
|
||||
else ""
|
||||
body = parse_w_workflow_placeholders(
|
||||
action.email.body,
|
||||
correspondent,
|
||||
document_type,
|
||||
owner_username,
|
||||
added,
|
||||
filename,
|
||||
current_filename,
|
||||
created,
|
||||
title,
|
||||
doc_url,
|
||||
)
|
||||
try:
|
||||
n_messages = send_email(
|
||||
@@ -1113,7 +1071,7 @@ def run_workflows(
|
||||
f"Error occurred parsing webhook params: {e}",
|
||||
extra={"group": logging_group},
|
||||
)
|
||||
elif action.webhook.body:
|
||||
else:
|
||||
data = parse_w_workflow_placeholders(
|
||||
action.webhook.body,
|
||||
correspondent,
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user