Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a10f8fc81f | ||
|
|
5ac6ae52de | ||
|
|
235be8d15f | ||
|
|
69af169575 | ||
|
|
6aecbac2c4 | ||
|
|
0bc7221e12 | ||
|
|
95474bf1ae | ||
|
|
e9bedd9343 | ||
|
|
cee6519a06 | ||
|
|
c7b94952d2 | ||
|
|
bfae3ef82d | ||
|
|
7b90eb5069 | ||
|
|
e15dad04bb | ||
|
|
d0731ce892 | ||
|
|
dda0c93e80 | ||
|
|
a09ea28636 | ||
|
|
d5a9c502fc | ||
|
|
b989b74140 | ||
|
|
5194f47291 | ||
|
|
714885d7a5 | ||
|
|
73e777a48c | ||
|
|
e9141366bb | ||
|
|
7813375123 | ||
|
|
0132c7bd6e | ||
|
|
4d5897ec80 | ||
|
|
f197d09b3e | ||
|
|
937feb1bef | ||
|
|
f5ff18326d | ||
|
|
d52cc1b609 | ||
|
|
3f5f4f3ed4 | ||
|
|
9a47b20d2a | ||
|
|
05905287b3 | ||
|
|
d65de00ca1 | ||
|
|
f287a4cb8c | ||
|
|
a415d1bf74 | ||
|
|
4b1434f876 | ||
|
|
43fd109bc6 | ||
|
|
ec70e4f423 | ||
|
|
340118ad51 | ||
|
|
3899e0f0d6 | ||
|
|
d648526858 |
@@ -0,0 +1,187 @@
|
||||
---
|
||||
name: paperless-benchmarking
|
||||
description: Use when profiling paperless-ngx performance, running `manage.py benchmark`, investigating a slow query or endpoint, or deciding whether a profiling finding should become a permanent registered scenario. Covers command reference, the fork/merge-back branch workflow for perf investigations, and how to read query-plan output.
|
||||
---
|
||||
|
||||
# Paperless-ngx Benchmarking
|
||||
|
||||
This repo has a built-in benchmarking/profiling tool: `manage.py benchmark`, in
|
||||
the `paperless_benchmark` Django app. It replaces ad hoc standalone scripts —
|
||||
use it instead of writing new one-off seed/timing scripts.
|
||||
|
||||
## Command reference
|
||||
|
||||
```
|
||||
manage.py benchmark seed --tier {home,medium,large} [--reset --yes-i-know-this-wipes-the-database] [--seed N]
|
||||
manage.py benchmark run --repeat 5 [--label baseline]
|
||||
manage.py benchmark profile <scenario_name> [--repeat 5] [--explain]
|
||||
manage.py benchmark list-scenarios
|
||||
```
|
||||
|
||||
- **`seed`** builds a realistic dataset at one of three scales: `home` (500
|
||||
documents — fast, use this for iteration), `medium` (20,000 — the default
|
||||
when `--tier` is omitted; a multi-minute seed), `large` (360,000 — matches
|
||||
the scale reported in real large-install bug reports; slow, only use it
|
||||
when a finding needs confirming at real scale). `--reset` wipes any
|
||||
previously-seeded benchmark data first — but it is destructive and
|
||||
irreversible: it deletes **all** users, **all** groups, and **all**
|
||||
documents/tags/correspondents/document types/storage paths in the target
|
||||
database, not just benchmark-created rows. Only run it against a disposable
|
||||
benchmark database, never a real install. Because of that, `--reset` also
|
||||
requires passing `--yes-i-know-this-wipes-the-database` in the same
|
||||
invocation, or the command raises an error and does nothing. Omit both
|
||||
flags if you want to layer more data onto an existing seed instead. `seed`
|
||||
creates two named users, `perf_target` (mixed owned/shared documents,
|
||||
realistic guardian permission grants) and `perf_admin` (superuser), plus a
|
||||
general user/group pool with realistic permission-row ratios.
|
||||
- **`run`** times the 3 built-in API endpoint benchmarks
|
||||
(`/api/documents/`, `/api/documents/?page_size=50`, `/api/tags/?page_size=100000`) for both
|
||||
`perf_target` and `perf_admin`, reporting min/median/max wall-clock and SQL
|
||||
query count. Requires `seed` to have already run — it reuses that data, it
|
||||
does not seed its own.
|
||||
- **`profile`** times one named scenario from the registry (see
|
||||
`list-scenarios`) via best-of-N repeat timing and SQL query count, against
|
||||
`perf_target`. `--explain` additionally captures and prints the query plan:
|
||||
real `EXPLAIN ANALYZE` execution stats on PostgreSQL/MariaDB, or
|
||||
`EXPLAIN QUERY PLAN` (plan only, no real timing/row counts — clearly labeled
|
||||
as such) on SQLite. Like `run`, `profile` requires `seed` to have already
|
||||
run — it does not seed its own data either.
|
||||
- Every `run`/`profile` invocation appends a JSON line to
|
||||
`benchmark_results/history.jsonl` at the repo root (local-only, gitignored —
|
||||
never commit this file). Use it to compare a `before`/`after` pair across
|
||||
two invocations without hand-copying numbers.
|
||||
- Full chain example: `seed --reset` once, then `run` and `profile` as many
|
||||
times as you want against that same seeded data — no need to reseed between
|
||||
them.
|
||||
|
||||
## Adding a new scenario
|
||||
|
||||
A "scenario" is a named, registered query/operation that `profile` can time
|
||||
and explain. To add one, edit `src/paperless_benchmark/scenarios.py`: write a
|
||||
`_<name>_run(user)` function (returns whatever `run_profile` should time) and
|
||||
optionally a `_<name>_queryset(user)` function (returns the `QuerySet` for
|
||||
`--explain` to analyze), then `register(Scenario(name=..., describe=...,
|
||||
run=..., queryset_for_explain=...))` at module level. Both functions receive
|
||||
the already-seeded `perf_target` user — they should not seed their own data.
|
||||
|
||||
## Branch workflow
|
||||
|
||||
`tools/benchmark-management-commands` is a **long-lived tooling branch**, not
|
||||
a feature branch that gets merged and closed:
|
||||
|
||||
1. It is periodically brought up to date with `dev` (merge `dev` into it) so
|
||||
the tooling doesn't drift from the schema/codebase it profiles. Do this
|
||||
before starting a new investigation if it's been a while since the last
|
||||
sync.
|
||||
2. **Every performance investigation forks its own branch from
|
||||
`tools/benchmark-management-commands`** (not from `dev`). Do the
|
||||
investigation there: write throwaway profiling code, try fixes, capture
|
||||
before/after numbers.
|
||||
3. **That investigation branch never merges into `dev` or production.** Its
|
||||
only job is to produce evidence and, optionally, a reusable scenario.
|
||||
4. If the investigation turns up a scenario worth keeping permanently (see
|
||||
"When to graduate a scenario" below), open a PR that adds **just that
|
||||
scenario** back into `tools/benchmark-management-commands` — not the rest
|
||||
of the investigation branch's throwaway code.
|
||||
5. Any actual production fix the investigation motivates (e.g. an ORM query
|
||||
change) goes into its own normal feature branch off `dev`, following the
|
||||
project's regular contribution process — profiling evidence informs that
|
||||
PR's description, but the profiling code itself does not travel with it.
|
||||
|
||||
## Reading query-plan output
|
||||
|
||||
- **PostgreSQL** `EXPLAIN ANALYZE`: look for `Seq Scan` on a large table
|
||||
(missing index), a large gap between `rows=N` (planner's estimate) and the
|
||||
actual row count in parentheses (stale statistics or a bad cardinality
|
||||
estimate), and nested-loop joins driven by an outer relation with many
|
||||
rows (usually the N+1 pattern this tool exists to catch).
|
||||
- **MariaDB**: verified against a real MariaDB 12.3 container that MariaDB
|
||||
does NOT accept MySQL 8.0.18+'s `EXPLAIN ANALYZE` syntax (it's a 1064
|
||||
syntax error) -- `capture_explain()` instead runs MariaDB's own
|
||||
`ANALYZE <statement>` form (no `EXPLAIN` keyword), which returns a
|
||||
tabular plan with real per-row execution columns: `rows` (estimate) vs.
|
||||
`r_rows` (actual), and `filtered` vs. `r_filtered`. A large gap between
|
||||
`rows` and `r_rows`, or `type: ALL` (full table scan) on a large table,
|
||||
are the signals to look for -- the same underlying concerns as Postgres's
|
||||
`Seq Scan`/estimate-vs-actual gap, just in MariaDB's column-based output
|
||||
instead of Postgres's nested-tree text format.
|
||||
- **SQLite** `EXPLAIN QUERY PLAN`: no real timing/row-count data, only the
|
||||
chosen access path (`SCAN` vs `SEARCH`, which index if any). Useful for
|
||||
confirming an index is even being considered, not for judging real-world
|
||||
cost — corroborate any SQLite finding against Postgres/MariaDB before
|
||||
trusting it, since planner behavior differs meaningfully between them.
|
||||
- Compare query **count**, not just timing, between before/after: a fix that
|
||||
keeps the same wall-clock time but drops query count from O(n) to O(1) is
|
||||
still a real, durable improvement — timing alone is noisy and
|
||||
environment-dependent, query count is not.
|
||||
|
||||
## Cleaning up after an interrupted run
|
||||
|
||||
If a `seed`/`run`/`profile` invocation gets killed mid-run (Ctrl-C, `kill -9`,
|
||||
a timed-out SSH session, etc.), check whether it left anything behind before
|
||||
trusting the next benchmark's numbers. This was verified for real: a
|
||||
`benchmark seed --tier large --reset ...` was started against both a fresh
|
||||
PostgreSQL 18 container and a fresh MariaDB 12.3 container and `kill -9`'d a
|
||||
few seconds into document seeding. In both cases, the database-side
|
||||
connection disappeared immediately -- no stuck backend, no lingering query,
|
||||
no held lock was observed in either backend once the killed process's PID
|
||||
was confirmed gone. That said, this was one interruption point (mid
|
||||
bulk-seed, between chunks); a run killed mid-query, or a driver/network
|
||||
hiccup that doesn't cleanly close the socket, could behave differently, so
|
||||
still check before trusting a number if any run in the session was
|
||||
interrupted:
|
||||
|
||||
- **PostgreSQL**: look for leftover connections against the benchmark
|
||||
database:
|
||||
|
||||
```sql
|
||||
SELECT pid, state, query, query_start
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = current_database() AND pid <> pg_backend_pid();
|
||||
```
|
||||
|
||||
If a stuck backend shows up, clear it with:
|
||||
|
||||
```sql
|
||||
SELECT pg_terminate_backend(pid)
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = current_database() AND pid <> pg_backend_pid();
|
||||
```
|
||||
|
||||
- **MariaDB**: look for leftover connections/queries:
|
||||
|
||||
```sql
|
||||
SHOW FULL PROCESSLIST;
|
||||
```
|
||||
|
||||
If a stuck connection shows up (anything other than your current admin
|
||||
session), clear it with:
|
||||
|
||||
```sql
|
||||
KILL <id>;
|
||||
```
|
||||
|
||||
A stray connection left running concurrently with a subsequent benchmark run
|
||||
would add real, contaminating load (extra queries competing for the same
|
||||
rows, possibly held locks slowing the next run's timings) -- cheap to rule
|
||||
out, expensive to silently trust a number that was actually measured
|
||||
alongside a zombie connection.
|
||||
|
||||
## When to graduate a one-off finding into a permanent scenario
|
||||
|
||||
Register a scenario (rather than leaving it as throwaway code on the
|
||||
investigation branch) when **both** are true:
|
||||
|
||||
- The query pattern is one this codebase is likely to regress on again (e.g.
|
||||
it involves a permission-check join, a bulk operation, or anything else
|
||||
with an easy-to-reintroduce N+1) — not a one-time fluke specific to this
|
||||
investigation.
|
||||
- Re-running it later, against a fresh seed, would still produce a
|
||||
meaningful signal (it doesn't depend on investigation-specific throwaway
|
||||
data or a fix that's already permanently landed and can't regress the same
|
||||
way).
|
||||
|
||||
If a finding doesn't meet both bars, keep it as disposable code on the
|
||||
investigation branch and let the branch's evidence (captured in the PR
|
||||
description of whatever production fix it motivates) be the permanent
|
||||
record instead.
|
||||
@@ -72,11 +72,9 @@ jobs:
|
||||
'You are welcome to open a new issue that describes the problem you observed in your own words.'
|
||||
: 'This issue was automatically closed because it was not opened using our bug report form. ' +
|
||||
'Issues have to be created through the form so that the details we need to investigate are included.\n\n' +
|
||||
`If the problem is still there, please [open a new issue](${newIssue}) using the form — that is all it takes ` +
|
||||
'to get it looked at, and no other action is needed here.\n\n' +
|
||||
`If the problem is still there, please [open a new issue](${newIssue}) using the form. No other action is needed here.\n\n` +
|
||||
'If any part of your report was written by an AI tool or agent, you must say so: undisclosed AI-generated ' +
|
||||
`contributions are a violation of our [Code of Conduct](${codeOfConduct}), and such reports must describe the ` +
|
||||
`behavior you observed only, without code analysis or suggested fixes. See our [contributing guidelines](${contributing}).`;
|
||||
`contributions are a violation of our [Code of Conduct](${codeOfConduct}).`;
|
||||
|
||||
await github.rest.issues.createComment({ ...common, body });
|
||||
await github.rest.issues.addLabels({ ...common, labels: ['ai'] });
|
||||
|
||||
@@ -25,6 +25,10 @@ jobs:
|
||||
pr-bot:
|
||||
name: Automated PR Bot
|
||||
runs-on: ubuntu-latest
|
||||
# Runs after Anti-slop so the welcome comment can see whether the PR was closed
|
||||
# instead of racing it. Still runs if that job fails, so labeling is not lost.
|
||||
needs: Anti-slop
|
||||
if: ${{ !cancelled() }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
@@ -99,8 +103,25 @@ jobs:
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
const user = pr.user.login;
|
||||
const user = context.payload.pull_request.user.login;
|
||||
|
||||
// Re-read the PR: Anti-slop may have closed and labeled it after the webhook
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.pull_request.number,
|
||||
});
|
||||
|
||||
if (pr.state === 'closed') {
|
||||
core.info('Skipping comment: PR is already closed');
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = pr.labels.map((label) => (typeof label === 'string' ? label : label.name));
|
||||
if (labels.includes('ai')) {
|
||||
core.info('Skipping comment: PR is labeled ai');
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: members } = await github.rest.orgs.listMembers({
|
||||
org: 'paperless-ngx',
|
||||
|
||||
@@ -115,3 +115,6 @@ celerybeat-schedule*
|
||||
|
||||
# Git worktree local folder
|
||||
.worktrees
|
||||
|
||||
# Benchmark tooling output (local only, never committed)
|
||||
/benchmark_results/
|
||||
|
||||
@@ -138,7 +138,9 @@ for suggested generation and embedding models.
|
||||
With AI enabled, Paperless-ngx can suggest a title, tags, correspondent, document type,
|
||||
storage path and dates by sending the document to the LLM. This is **opt-in per request**
|
||||
and surfaces through the "Suggest" control on the document detail page, alongside the
|
||||
classic classifier-based suggestions — it does not disable them. Suggestion output
|
||||
classic classifier-based suggestions — it does not disable them. Suggestions are requested
|
||||
automatically when you open a document that carries an inbox tag unless "Automatically request
|
||||
suggestions for inbox documents" under Settings > Documents is disabled. Suggestion output
|
||||
language can be steered with
|
||||
[`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE`](configuration.md#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE)
|
||||
(otherwise it follows the user's UI language).
|
||||
|
||||
@@ -317,6 +317,8 @@ a "document already exists" message.
|
||||
|
||||
Paperless-ngx can suggest tags, correspondents, document types and storage paths for documents based on the content of the document. This is done using a (non-LLM) machine learning model that is trained on the documents in your database. The suggestions are shown in the document detail page and can be accepted or rejected by the user.
|
||||
|
||||
Suggestions are requested automatically when you open a document that still has an inbox tag. To only request them by pressing the "Suggest" button instead, turn off "Automatically request suggestions for inbox documents" under Settings > Documents.
|
||||
|
||||
## AI Features
|
||||
|
||||
Paperless-ngx includes several features that use AI to enhance the document management experience. These features are optional and can be enabled or disabled in the settings. If you are using the AI features, you may want to also enable the "LLM index" feature, which supports Retrieval-Augmented Generation (RAG) designed to improve the quality of AI responses. The LLM index feature is not enabled by default and requires additional configuration.
|
||||
@@ -684,7 +686,8 @@ It requires [AI features](configuration.md#ai) to be enabled. You can specify:
|
||||
never replace the document's existing tags.
|
||||
|
||||
The action works with every trigger **except Consumption Started**, because suggestions are made from
|
||||
the document's text, which does not exist until after the document has been processed.
|
||||
the document's text, which does not exist until after the document has been processed. Documents whose
|
||||
processed text is empty or contains only whitespace are skipped.
|
||||
|
||||
Because the query to the AI service is slow, the action is queued and runs in the background rather
|
||||
than as part of the workflow run itself. The document is updated once the suggestions come back.
|
||||
|
||||
@@ -32,21 +32,21 @@ dependencies = [
|
||||
"django-cors-headers~=4.9.0",
|
||||
"django-extensions~=4.1",
|
||||
"django-filter~=25.1",
|
||||
"django-guardian~=3.3.3",
|
||||
"django-guardian>=3.3.3,<3.5",
|
||||
"django-multiselectfield~=1.0.1",
|
||||
"django-rich~=2.2.0",
|
||||
"django-soft-delete~=1.0.18",
|
||||
"django-treenode>=0.24",
|
||||
"djangorestframework~=3.16",
|
||||
"drf-spectacular~=0.30",
|
||||
"drf-spectacular-sidecar~=2026.7.1",
|
||||
"drf-spectacular-sidecar>=2026.7.1,<2026.9",
|
||||
"drf-writable-nested~=0.7.1",
|
||||
"filelock~=3.32.0",
|
||||
"flower>=2.0.1,<2.2",
|
||||
"gotenberg-client[httpx]~=1.0",
|
||||
"httpx-oauth~=0.17",
|
||||
"ijson>=3.5.1",
|
||||
"imap-tools~=1.14.0",
|
||||
"imap-tools>=1.14,<1.16",
|
||||
"jinja2~=3.1.6",
|
||||
"langdetect~=1.0.9",
|
||||
"llama-index-core>=0.14.23",
|
||||
@@ -56,7 +56,7 @@ dependencies = [
|
||||
"llama-index-llms-ollama>=0.9.1",
|
||||
"llama-index-llms-openai-like>=0.7.1",
|
||||
"nltk~=3.10.0",
|
||||
"ocrmypdf>=17.7,<17.11",
|
||||
"ocrmypdf>=17.7,<17.12",
|
||||
"openai>=2.48",
|
||||
"pathvalidate~=3.3.1",
|
||||
"pdf2image~=1.17.0",
|
||||
@@ -103,17 +103,17 @@ docs = [
|
||||
"zensical>=0.0.51",
|
||||
]
|
||||
lint = [
|
||||
"prek~=0.4.11",
|
||||
"prek>=0.4.11,<0.6",
|
||||
"ruff~=0.16.1",
|
||||
]
|
||||
testing = [
|
||||
"daphne",
|
||||
"factory-boy~=3.3.1",
|
||||
"faker~=40.36.0",
|
||||
"faker>=40.36,<40.38",
|
||||
"imagehash",
|
||||
"pytest~=9.1.1",
|
||||
"pytest-cov~=7.1.0",
|
||||
"pytest-django~=4.12.0",
|
||||
"pytest-django>=4.12,<4.15",
|
||||
"pytest-env~=1.7.0",
|
||||
"pytest-httpx",
|
||||
"pytest-mock~=3.15.1",
|
||||
|
||||
@@ -71,8 +71,10 @@
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"localize": true,
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/apple-touch-icon.png",
|
||||
"src/icon-192.png",
|
||||
"src/icon-512.png",
|
||||
"src/icon-512-maskable.png",
|
||||
"src/assets",
|
||||
"src/manifest.webmanifest",
|
||||
{
|
||||
|
||||
@@ -23,17 +23,30 @@
|
||||
<div class="col">
|
||||
<div class="card bg-light">
|
||||
<div class="card-body">
|
||||
<div class="card-title d-flex align-items-center">
|
||||
<div class="card-title d-flex align-items-center flex-wrap">
|
||||
<h6 class="mb-0">
|
||||
{{option.title}}
|
||||
</h6>
|
||||
<a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer">
|
||||
<i-bs name="info-circle"></i-bs>
|
||||
</a>
|
||||
@if (isExternallyConfigured(option.config_key)) {
|
||||
@if (isSet(option.key)) {
|
||||
<span class="badge rounded-pill bg-body-secondary text-dark fw-normal" title="This value overrides {{option.config_key}}, which is set outside Paperless." i18n-title>Overrides external</span>
|
||||
} @else {
|
||||
<span class="badge rounded-pill bg-body-secondary text-dark fw-normal" title="{{option.config_key}} is set outside Paperless. Enter a value here to override it." i18n-title>Set externally</span>
|
||||
}
|
||||
}
|
||||
@if (isSet(option.key)) {
|
||||
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
|
||||
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
|
||||
</button>
|
||||
@if (isExternallyConfigured(option.config_key)) {
|
||||
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Use the externally configured value" i18n-title (click)="resetOption(option.key)">
|
||||
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset to external</ng-container>
|
||||
</button>
|
||||
} @else {
|
||||
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
|
||||
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="mb-n3">
|
||||
|
||||
@@ -163,6 +163,19 @@ describe('ConfigComponent', () => {
|
||||
expect(component.configForm.get('barcodes_enabled').value).toBeNull()
|
||||
})
|
||||
|
||||
it('should identify externally configured options', () => {
|
||||
component.externallyConfiguredVariables = new Set([
|
||||
'PAPERLESS_OCR_LANGUAGE',
|
||||
])
|
||||
|
||||
expect(
|
||||
component.isExternallyConfigured('PAPERLESS_OCR_LANGUAGE')
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
component.isExternallyConfigured('PAPERLESS_OCR_OUTPUT_TYPE')
|
||||
).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should group options into sections within a category, or not', () => {
|
||||
const sections = component.getCategorySections(ConfigCategory.OCR)
|
||||
expect(sections).toEqual([null, ConfigSection.RemoteOCR])
|
||||
|
||||
@@ -69,6 +69,7 @@ export class ConfigComponent
|
||||
public configForm = new FormGroup({})
|
||||
|
||||
public errors = {}
|
||||
public externallyConfiguredVariables = new Set<string>()
|
||||
|
||||
get optionCategories(): string[] {
|
||||
return Object.values(ConfigCategory)
|
||||
@@ -152,6 +153,9 @@ export class ConfigComponent
|
||||
}
|
||||
|
||||
private initialize(config: PaperlessConfig) {
|
||||
this.externallyConfiguredVariables = new Set(
|
||||
config.externally_configured_variables ?? []
|
||||
)
|
||||
if (!this.store) {
|
||||
this.store = new BehaviorSubject(config)
|
||||
|
||||
@@ -162,7 +166,9 @@ export class ConfigComponent
|
||||
this.configForm.patchValue(state, { emitEvent: false })
|
||||
})
|
||||
|
||||
this.isDirty$ = dirtyCheck(this.configForm, this.store.asObservable())
|
||||
this.isDirty$ = dirtyCheck(this.configForm, this.store.asObservable(), {
|
||||
excludeKeys: ['externally_configured_variables'],
|
||||
})
|
||||
}
|
||||
this.configForm.patchValue(config)
|
||||
|
||||
@@ -227,6 +233,10 @@ export class ConfigComponent
|
||||
return this.configForm.get(key).value != null
|
||||
}
|
||||
|
||||
public isExternallyConfigured(configKey: string): boolean {
|
||||
return this.externallyConfiguredVariables.has(configKey)
|
||||
}
|
||||
|
||||
public resetOption(key: string) {
|
||||
this.configForm.get(key).setValue(null)
|
||||
}
|
||||
|
||||
@@ -237,6 +237,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<pngx-input-check i18n-title title="Automatically request suggestions for inbox documents" i18n-hint hint="If un-checked, suggestions must be requested via the Suggest button." formControlName="documentEditingAutoSuggest"></pngx-input-check>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<pngx-input-check i18n-title title="Show document thumbnail during loading" formControlName="documentEditingOverlayThumbnail"></pngx-input-check>
|
||||
|
||||
@@ -267,7 +267,7 @@ describe('SettingsComponent', () => {
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(storeSpy).toHaveBeenCalled()
|
||||
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
||||
expect(setSpy).toHaveBeenCalledTimes(32)
|
||||
expect(setSpy).toHaveBeenCalledTimes(33)
|
||||
|
||||
// succeed
|
||||
storeSpy.mockReturnValueOnce(of(true))
|
||||
|
||||
@@ -168,6 +168,7 @@ export class SettingsComponent
|
||||
pdfEditorDefaultEditMode: new FormControl(null),
|
||||
documentEditingRemoveInboxTags: new FormControl(null),
|
||||
documentEditingOverlayThumbnail: new FormControl(null),
|
||||
documentEditingAutoSuggest: new FormControl(null),
|
||||
documentDetailsHiddenFields: new FormControl([]),
|
||||
searchDbOnly: new FormControl(null),
|
||||
searchLink: new FormControl(null),
|
||||
@@ -368,6 +369,9 @@ export class SettingsComponent
|
||||
documentEditingOverlayThumbnail: this.settings.get(
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL
|
||||
),
|
||||
documentEditingAutoSuggest: this.settings.get(
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST
|
||||
),
|
||||
documentDetailsHiddenFields: this.settings.get(
|
||||
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS
|
||||
),
|
||||
@@ -565,6 +569,10 @@ export class SettingsComponent
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL,
|
||||
this.settingsForm.value.documentEditingOverlayThumbnail
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST,
|
||||
this.settingsForm.value.documentEditingAutoSuggest
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
|
||||
this.settingsForm.value.documentDetailsHiddenFields
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { of } from 'rxjs'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import {
|
||||
MailAction,
|
||||
MailMetadataCorrespondentOption,
|
||||
@@ -15,6 +15,7 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||
import { MailAccountService } from 'src/app/services/rest/mail-account.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { CheckComponent } from '../../input/check/check.component'
|
||||
import { NumberComponent } from '../../input/number/number.component'
|
||||
import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component'
|
||||
@@ -81,6 +82,41 @@ describe('MailRuleEditDialogComponent', () => {
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should use empty related object lists when retrieval fails', () => {
|
||||
const failed = () => throwError(() => new Error('Forbidden'))
|
||||
const toastSpy = jest.spyOn(TestBed.inject(ToastService), 'showError')
|
||||
jest
|
||||
.spyOn(TestBed.inject(MailAccountService), 'listAll')
|
||||
.mockReturnValue(failed())
|
||||
jest
|
||||
.spyOn(TestBed.inject(CorrespondentService), 'listAll')
|
||||
.mockReturnValue(failed())
|
||||
jest
|
||||
.spyOn(TestBed.inject(DocumentTypeService), 'listAll')
|
||||
.mockReturnValue(failed())
|
||||
|
||||
const failedFixture = TestBed.createComponent(MailRuleEditDialogComponent)
|
||||
const failedComponent = failedFixture.componentInstance
|
||||
|
||||
expect(failedComponent.accounts()).toEqual([])
|
||||
expect(failedComponent.correspondents()).toEqual([])
|
||||
expect(failedComponent.documentTypes()).toEqual([])
|
||||
expect(() => failedFixture.detectChanges()).not.toThrow()
|
||||
expect(toastSpy).toHaveBeenCalledTimes(3)
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
'Error retrieving mail accounts',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
'Error retrieving correspondents',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
'Error retrieving document types',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('should support create and edit modes', () => {
|
||||
component.dialogMode.set(EditDialogMode.CREATE)
|
||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { map } from 'rxjs'
|
||||
import { catchError, map, of } from 'rxjs'
|
||||
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
||||
import { Correspondent } from 'src/app/data/correspondent'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
@@ -26,6 +26,7 @@ import { MailAccountService } from 'src/app/services/rest/mail-account.service'
|
||||
import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { CheckComponent } from '../../input/check/check.component'
|
||||
import { NumberComponent } from '../../input/number/number.component'
|
||||
import { SelectComponent } from '../../input/select/select.component'
|
||||
@@ -158,17 +159,45 @@ export class MailRuleEditDialogComponent extends EditDialogComponent<MailRule> {
|
||||
private readonly accountService = inject(MailAccountService)
|
||||
private readonly correspondentService = inject(CorrespondentService)
|
||||
private readonly documentTypeService = inject(DocumentTypeService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
|
||||
readonly accounts = toSignal(
|
||||
this.accountService.listAll().pipe(map((result) => result.results)),
|
||||
this.accountService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error retrieving mail accounts`,
|
||||
error
|
||||
)
|
||||
return of([])
|
||||
})
|
||||
),
|
||||
{ initialValue: undefined as MailAccount[] }
|
||||
)
|
||||
readonly correspondents = toSignal(
|
||||
this.correspondentService.listAll().pipe(map((result) => result.results)),
|
||||
this.correspondentService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error retrieving correspondents`,
|
||||
error
|
||||
)
|
||||
return of([])
|
||||
})
|
||||
),
|
||||
{ initialValue: undefined as Correspondent[] }
|
||||
)
|
||||
readonly documentTypes = toSignal(
|
||||
this.documentTypeService.listAll().pipe(map((result) => result.results)),
|
||||
this.documentTypeService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error retrieving document types`,
|
||||
error
|
||||
)
|
||||
return of([])
|
||||
})
|
||||
),
|
||||
{ initialValue: undefined as DocumentType[] }
|
||||
)
|
||||
|
||||
|
||||
@@ -81,6 +81,23 @@ describe('UserEditDialogComponent', () => {
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should use an empty group list when retrieval fails', () => {
|
||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||
jest
|
||||
.spyOn(TestBed.inject(GroupService), 'listAll')
|
||||
.mockReturnValue(throwError(() => new Error('Forbidden')))
|
||||
|
||||
const failedFixture = TestBed.createComponent(UserEditDialogComponent)
|
||||
const failedComponent = failedFixture.componentInstance
|
||||
|
||||
expect(failedComponent.groups()).toEqual([])
|
||||
expect(() => failedFixture.detectChanges()).not.toThrow()
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
'Error retrieving groups',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('should support create and edit modes', () => {
|
||||
component.dialogMode.set(EditDialogMode.CREATE)
|
||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { first, map } from 'rxjs'
|
||||
import { catchError, first, map, of } from 'rxjs'
|
||||
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
||||
import { Group } from 'src/app/data/group'
|
||||
import { User } from 'src/app/data/user'
|
||||
@@ -42,7 +42,13 @@ export class UserEditDialogComponent
|
||||
private readonly groupsService = inject(GroupService)
|
||||
|
||||
readonly groups = toSignal(
|
||||
this.groupsService.listAll().pipe(map((result) => result.results)),
|
||||
this.groupsService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => {
|
||||
this.toastService.showError($localize`Error retrieving groups`, error)
|
||||
return of([])
|
||||
})
|
||||
),
|
||||
{ initialValue: undefined as Group[] }
|
||||
)
|
||||
readonly passwordIsSet = signal(false)
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '@angular/forms'
|
||||
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { of } from 'rxjs'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { CustomFieldQueriesModel } from 'src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component'
|
||||
import { CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import { CustomFieldQueryLogicalOperator } from 'src/app/data/custom-field-query'
|
||||
@@ -39,6 +39,7 @@ import { DocumentTypeService } from 'src/app/services/rest/document-type.service
|
||||
import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
|
||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element'
|
||||
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
|
||||
import { NumberComponent } from '../../input/number/number.component'
|
||||
@@ -206,6 +207,44 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
settingsService.set(SETTINGS_KEYS.AI_ENABLED, ai)
|
||||
}
|
||||
|
||||
it('should use empty related object lists when access is forbidden', () => {
|
||||
const forbidden = () => throwError(() => new Error('Forbidden'))
|
||||
const toastSpy = jest.spyOn(TestBed.inject(ToastService), 'showError')
|
||||
jest
|
||||
.spyOn(TestBed.inject(CorrespondentService), 'listAll')
|
||||
.mockReturnValue(forbidden())
|
||||
jest
|
||||
.spyOn(TestBed.inject(DocumentTypeService), 'listAll')
|
||||
.mockReturnValue(forbidden())
|
||||
jest
|
||||
.spyOn(TestBed.inject(StoragePathService), 'listAll')
|
||||
.mockReturnValue(forbidden())
|
||||
jest
|
||||
.spyOn(TestBed.inject(MailRuleService), 'listAll')
|
||||
.mockReturnValue(forbidden())
|
||||
jest
|
||||
.spyOn(TestBed.inject(CustomFieldsService), 'listAll')
|
||||
.mockReturnValue(forbidden())
|
||||
|
||||
const forbiddenFixture = TestBed.createComponent(
|
||||
WorkflowEditDialogComponent
|
||||
)
|
||||
const forbiddenComponent = forbiddenFixture.componentInstance
|
||||
|
||||
expect(forbiddenComponent.correspondents()).toEqual([])
|
||||
expect(forbiddenComponent.documentTypes()).toEqual([])
|
||||
expect(forbiddenComponent.storagePaths()).toEqual([])
|
||||
expect(forbiddenComponent.mailRules()).toEqual([])
|
||||
expect(forbiddenComponent.customFields()).toEqual([])
|
||||
expect(forbiddenComponent.dateCustomFields()).toEqual([])
|
||||
expect(() => forbiddenFixture.detectChanges()).not.toThrow()
|
||||
expect(toastSpy).toHaveBeenCalledTimes(1)
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
'Some workflow options could not be loaded.',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('should support create and edit modes, support adding triggers and actions on new workflow', () => {
|
||||
component.dialogMode.set(EditDialogMode.CREATE)
|
||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from '@angular/forms'
|
||||
import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { Subscription, map, takeUntil } from 'rxjs'
|
||||
import { Subscription, catchError, map, of, takeUntil } from 'rxjs'
|
||||
import { Correspondent } from 'src/app/data/correspondent'
|
||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
@@ -48,6 +48,7 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { WorkflowService } from 'src/app/services/rest/workflow.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element'
|
||||
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
|
||||
import {
|
||||
@@ -512,26 +513,43 @@ export class WorkflowEditDialogComponent
|
||||
private readonly storagePathService = inject(StoragePathService)
|
||||
private readonly mailRuleService = inject(MailRuleService)
|
||||
private readonly customFieldsService = inject(CustomFieldsService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
private relatedObjectLoadErrorShown = false
|
||||
|
||||
readonly templates = signal<Workflow[]>(undefined)
|
||||
readonly correspondents = toSignal(
|
||||
this.correspondentService.listAll().pipe(map((result) => result.results)),
|
||||
this.correspondentService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => this.handleRelatedObjectLoadError(error))
|
||||
),
|
||||
{ initialValue: undefined as Correspondent[] }
|
||||
)
|
||||
readonly documentTypes = toSignal(
|
||||
this.documentTypeService.listAll().pipe(map((result) => result.results)),
|
||||
this.documentTypeService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => this.handleRelatedObjectLoadError(error))
|
||||
),
|
||||
{ initialValue: undefined as DocumentType[] }
|
||||
)
|
||||
readonly storagePaths = toSignal(
|
||||
this.storagePathService.listAll().pipe(map((result) => result.results)),
|
||||
this.storagePathService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => this.handleRelatedObjectLoadError(error))
|
||||
),
|
||||
{ initialValue: undefined as StoragePath[] }
|
||||
)
|
||||
readonly mailRules = toSignal(
|
||||
this.mailRuleService.listAll().pipe(map((result) => result.results)),
|
||||
this.mailRuleService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => this.handleRelatedObjectLoadError(error))
|
||||
),
|
||||
{ initialValue: undefined as MailRule[] }
|
||||
)
|
||||
readonly customFields = toSignal(
|
||||
this.customFieldsService.listAll().pipe(map((result) => result.results)),
|
||||
this.customFieldsService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => this.handleRelatedObjectLoadError(error))
|
||||
),
|
||||
{ initialValue: undefined as CustomField[] }
|
||||
)
|
||||
readonly dateCustomFields = computed(() =>
|
||||
@@ -545,6 +563,17 @@ export class WorkflowEditDialogComponent
|
||||
SETTINGS_KEYS.AI_ENABLED
|
||||
)
|
||||
|
||||
private handleRelatedObjectLoadError(error) {
|
||||
if (!this.relatedObjectLoadErrorShown) {
|
||||
this.relatedObjectLoadErrorShown = true
|
||||
this.toastService.showError(
|
||||
$localize`Some workflow options could not be loaded.`,
|
||||
error
|
||||
)
|
||||
}
|
||||
return of([])
|
||||
}
|
||||
|
||||
expandedItem: number = null
|
||||
|
||||
private readonly triggerFilterOptionsMap = new WeakMap<
|
||||
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { of } from 'rxjs'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { GroupService } from 'src/app/services/rest/group.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { PermissionsGroupComponent } from './permissions-group.component'
|
||||
|
||||
describe('PermissionsGroupComponent', () => {
|
||||
@@ -60,4 +61,19 @@ describe('PermissionsGroupComponent', () => {
|
||||
expect(component.value).toEqual({ id: 2, name: 'Group 2' })
|
||||
expect(groupServiceSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use an empty group list when retrieval fails', () => {
|
||||
const toastSpy = jest.spyOn(TestBed.inject(ToastService), 'showError')
|
||||
groupServiceSpy.mockReturnValue(throwError(() => new Error('Forbidden')))
|
||||
|
||||
const failedFixture = TestBed.createComponent(PermissionsGroupComponent)
|
||||
const failedComponent = failedFixture.componentInstance
|
||||
|
||||
expect(failedComponent.groups()).toEqual([])
|
||||
expect(() => failedFixture.detectChanges()).not.toThrow()
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
'Error retrieving groups',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,9 +6,10 @@ import {
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { NgSelectComponent } from '@ng-select/ng-select'
|
||||
import { map } from 'rxjs/operators'
|
||||
import { catchError, map, of } from 'rxjs'
|
||||
import { Group } from 'src/app/data/group'
|
||||
import { GroupService } from 'src/app/services/rest/group.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { AbstractInputComponent } from '../../abstract-input'
|
||||
|
||||
@Component({
|
||||
@@ -26,8 +27,15 @@ import { AbstractInputComponent } from '../../abstract-input'
|
||||
})
|
||||
export class PermissionsGroupComponent extends AbstractInputComponent<Group> {
|
||||
private readonly groupService = inject(GroupService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
readonly groups = toSignal(
|
||||
this.groupService.listAll().pipe(map((result) => result.results)),
|
||||
this.groupService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => {
|
||||
this.toastService.showError($localize`Error retrieving groups`, error)
|
||||
return of([])
|
||||
})
|
||||
),
|
||||
{ initialValue: undefined as Group[] }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { of } from 'rxjs'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { PermissionsUserComponent } from './permissions-user.component'
|
||||
|
||||
describe('PermissionsUserComponent', () => {
|
||||
@@ -60,4 +61,19 @@ describe('PermissionsUserComponent', () => {
|
||||
expect(component.value).toEqual({ id: 2, name: 'User 2' })
|
||||
expect(userServiceSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use an empty user list when retrieval fails', () => {
|
||||
const toastSpy = jest.spyOn(TestBed.inject(ToastService), 'showError')
|
||||
userServiceSpy.mockReturnValue(throwError(() => new Error('Forbidden')))
|
||||
|
||||
const failedFixture = TestBed.createComponent(PermissionsUserComponent)
|
||||
const failedComponent = failedFixture.componentInstance
|
||||
|
||||
expect(failedComponent.users()).toEqual([])
|
||||
expect(() => failedFixture.detectChanges()).not.toThrow()
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
'Error retrieving users',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,9 +6,10 @@ import {
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { NgSelectComponent } from '@ng-select/ng-select'
|
||||
import { map } from 'rxjs/operators'
|
||||
import { catchError, map, of } from 'rxjs'
|
||||
import { User } from 'src/app/data/user'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { AbstractInputComponent } from '../../abstract-input'
|
||||
|
||||
@Component({
|
||||
@@ -26,8 +27,15 @@ import { AbstractInputComponent } from '../../abstract-input'
|
||||
})
|
||||
export class PermissionsUserComponent extends AbstractInputComponent<User[]> {
|
||||
private readonly userService = inject(UserService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
readonly users = toSignal(
|
||||
this.userService.listAll().pipe(map((result) => result.results)),
|
||||
this.userService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => {
|
||||
this.toastService.showError($localize`Error retrieving users`, error)
|
||||
return of([])
|
||||
})
|
||||
),
|
||||
{ initialValue: undefined as User[] }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { of } from 'rxjs'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { PermissionsFormComponent } from '../input/permissions/permissions-form/permissions-form.component'
|
||||
import { PermissionsGroupComponent } from '../input/permissions/permissions-group/permissions-group.component'
|
||||
import { PermissionsUserComponent } from '../input/permissions/permissions-user/permissions-user.component'
|
||||
@@ -77,6 +78,23 @@ describe('PermissionsDialogComponent', () => {
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should use an empty user list when retrieval fails', () => {
|
||||
const toastSpy = jest.spyOn(TestBed.inject(ToastService), 'showError')
|
||||
jest
|
||||
.spyOn(TestBed.inject(UserService), 'listAll')
|
||||
.mockReturnValue(throwError(() => new Error('Forbidden')))
|
||||
|
||||
const failedFixture = TestBed.createComponent(PermissionsDialogComponent)
|
||||
const failedComponent = failedFixture.componentInstance
|
||||
|
||||
expect(failedComponent.users()).toEqual([])
|
||||
expect(() => failedFixture.detectChanges()).not.toThrow()
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
'Error retrieving users',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('should return permissions', () => {
|
||||
expect(component.permissions).toEqual({
|
||||
owner: null,
|
||||
|
||||
@@ -14,10 +14,11 @@ import {
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { map } from 'rxjs'
|
||||
import { catchError, map, of } from 'rxjs'
|
||||
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
||||
import { User } from 'src/app/data/user'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { PermissionsFormComponent } from '../input/permissions/permissions-form/permissions-form.component'
|
||||
import { SwitchComponent } from '../input/switch/switch.component'
|
||||
|
||||
@@ -35,9 +36,16 @@ import { SwitchComponent } from '../input/switch/switch.component'
|
||||
export class PermissionsDialogComponent {
|
||||
activeModal = inject(NgbActiveModal)
|
||||
private userService = inject(UserService)
|
||||
private toastService = inject(ToastService)
|
||||
|
||||
readonly users = toSignal(
|
||||
this.userService.listAll().pipe(map((r) => r.results)),
|
||||
this.userService.listAll().pipe(
|
||||
map((r) => r.results),
|
||||
catchError((error) => {
|
||||
this.toastService.showError($localize`Error retrieving users`, error)
|
||||
return of([])
|
||||
})
|
||||
),
|
||||
{ initialValue: undefined as User[] }
|
||||
)
|
||||
readonly title = signal($localize`Set permissions`)
|
||||
|
||||
@@ -1473,6 +1473,35 @@ describe('DocumentDetailComponent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should not automatically get suggestions if auto-suggest is disabled', () => {
|
||||
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST, false)
|
||||
const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions')
|
||||
suggestionsSpy.mockReturnValue(of({ tags: [42] }))
|
||||
initNormally()
|
||||
expect(suggestionsSpy).not.toHaveBeenCalled()
|
||||
|
||||
// still available on demand
|
||||
component.getSuggestions()
|
||||
expect(suggestionsSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not automatically get AI suggestions if auto-suggest is disabled', () => {
|
||||
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST, false)
|
||||
const getSetting = settingsService.get.bind(settingsService)
|
||||
jest
|
||||
.spyOn(settingsService, 'get')
|
||||
.mockImplementation((key) =>
|
||||
key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key)
|
||||
)
|
||||
const aiSuggestionsSpy = jest.spyOn(documentService, 'getAiSuggestions')
|
||||
aiSuggestionsSpy.mockReturnValue(of({ tags: [42] }))
|
||||
initNormally()
|
||||
expect(aiSuggestionsSpy).not.toHaveBeenCalled()
|
||||
|
||||
component.getSuggestions()
|
||||
expect(aiSuggestionsSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reset the suggestions loading state if the document changes mid-request', () => {
|
||||
const getSetting = settingsService.get.bind(settingsService)
|
||||
jest
|
||||
|
||||
@@ -237,6 +237,9 @@ export class DocumentDetailComponent
|
||||
this.settings.getSignal<boolean>(
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL
|
||||
)
|
||||
private readonly autoSuggestSetting = this.settings.getSignal<boolean>(
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST
|
||||
)
|
||||
private readonly hiddenFieldsSetting = this.settings.getSignal<
|
||||
DocumentDetailFieldID[]
|
||||
>(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
|
||||
@@ -357,6 +360,10 @@ export class DocumentDetailComponent
|
||||
return this.aiEnabledSetting()
|
||||
}
|
||||
|
||||
get autoSuggest(): boolean {
|
||||
return this.autoSuggestSetting()
|
||||
}
|
||||
|
||||
get archiveContentRenderType(): ContentRenderType {
|
||||
const hasArchiveVersion =
|
||||
this.metadata()?.has_archive_version ??
|
||||
@@ -904,6 +911,7 @@ export class DocumentDetailComponent
|
||||
this.updateFormForCustomFields()
|
||||
this.loadMetadataForSelectedVersion()
|
||||
if (
|
||||
this.autoSuggest &&
|
||||
this.permissionsService.currentUserHasObjectPermissions(
|
||||
PermissionAction.Change,
|
||||
doc
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (textFilterTarget === 'asn') {
|
||||
@if (textFilterTarget === 'asn' || textFilterTarget === 'duplicates') {
|
||||
<select class="form-select flex-grow-0 w-auto" [(ngModel)]="textFilterModifier" (change)="textFilterModifierChange()">
|
||||
@for (m of textFilterModifiers; track m) {
|
||||
<option ngbDropdownItem [value]="m.id">{{m.label}}</option>
|
||||
@@ -23,7 +23,7 @@
|
||||
</button>
|
||||
}
|
||||
<input #textFilterInput class="form-control form-control-sm" type="text"
|
||||
[disabled]="textFilterModifierIsNull"
|
||||
[disabled]="textFilterInputDisabled"
|
||||
[(ngModel)]="textFilter"
|
||||
(keydown)="textFilterKeydown($event)"
|
||||
[ngbTypeahead]="searchAutoComplete"
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
FILTER_HAS_CUSTOM_FIELDS_ALL,
|
||||
FILTER_HAS_CUSTOM_FIELDS_ANY,
|
||||
FILTER_HAS_DOCUMENT_TYPE_ANY,
|
||||
FILTER_HAS_DUPLICATES,
|
||||
FILTER_HAS_STORAGE_PATH_ANY,
|
||||
FILTER_HAS_TAGS_ALL,
|
||||
FILTER_HAS_TAGS_ANY,
|
||||
@@ -427,6 +428,38 @@ describe('FilterEditorComponent', () => {
|
||||
expect(component.textFilterTarget).toEqual('mime-type') // TEXT_FILTER_TARGET_MIME_TYPE
|
||||
})
|
||||
|
||||
it('should ingest filter rules for documents with duplicates', () => {
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_HAS_DUPLICATES,
|
||||
value: 'true',
|
||||
},
|
||||
]
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.textFilterTarget).toEqual('duplicates')
|
||||
expect(component.textFilterModifier).toEqual('has-duplicates')
|
||||
expect(component.textFilterInputDisabled).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should ingest filter rules for documents without duplicates', () => {
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_HAS_DUPLICATES,
|
||||
value: 'false',
|
||||
},
|
||||
]
|
||||
|
||||
expect(component.textFilterTarget).toEqual('duplicates')
|
||||
expect(component.textFilterModifier).toEqual('does-not-have-duplicates')
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_HAS_DUPLICATES,
|
||||
value: 'false',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should ingest text filter rules for fulltext query', () => {
|
||||
expect(component.textFilter).toEqual(null)
|
||||
component.filterRules = [
|
||||
@@ -1390,6 +1423,33 @@ describe('FilterEditorComponent', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('should convert duplicate target input to the correct filter rule', () => {
|
||||
const textFieldTargetDropdown = fixture.debugElement.queryAll(
|
||||
By.directive(NgbDropdownItem)
|
||||
)[5]
|
||||
textFieldTargetDropdown.triggerEventHandler('click')
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.textFilterTarget).toEqual('duplicates')
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_HAS_DUPLICATES,
|
||||
value: 'true',
|
||||
},
|
||||
])
|
||||
|
||||
const textFieldModifierSelect = fixture.debugElement.query(By.css('select'))
|
||||
textFieldModifierSelect.nativeElement.value = 'does-not-have-duplicates'
|
||||
textFieldModifierSelect.nativeElement.dispatchEvent(new Event('change'))
|
||||
fixture.detectChanges()
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_HAS_DUPLICATES,
|
||||
value: 'false',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should convert user input to correct filter rules on full text query', () => {
|
||||
component.textFilterInput.nativeElement.value = 'foo'
|
||||
component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
|
||||
@@ -2178,6 +2238,22 @@ describe('FilterEditorComponent', () => {
|
||||
]
|
||||
expect(component.generateFilterName()).toEqual('Without any tag')
|
||||
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_HAS_DUPLICATES,
|
||||
value: 'true',
|
||||
},
|
||||
]
|
||||
expect(component.generateFilterName()).toEqual('With duplicates')
|
||||
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_HAS_DUPLICATES,
|
||||
value: 'false',
|
||||
},
|
||||
]
|
||||
expect(component.generateFilterName()).toEqual('Without duplicates')
|
||||
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
FILTER_HAS_CUSTOM_FIELDS_ALL,
|
||||
FILTER_HAS_CUSTOM_FIELDS_ANY,
|
||||
FILTER_HAS_DOCUMENT_TYPE_ANY,
|
||||
FILTER_HAS_DUPLICATES,
|
||||
FILTER_HAS_STORAGE_PATH_ANY,
|
||||
FILTER_HAS_TAGS_ALL,
|
||||
FILTER_HAS_TAGS_ANY,
|
||||
@@ -129,12 +130,15 @@ const TEXT_FILTER_TARGET_FULLTEXT_QUERY = 'fulltext-query'
|
||||
const TEXT_FILTER_TARGET_FULLTEXT_MORELIKE = 'fulltext-morelike'
|
||||
const TEXT_FILTER_TARGET_CUSTOM_FIELDS = 'custom-fields'
|
||||
const TEXT_FILTER_TARGET_MIME_TYPE = 'mime-type'
|
||||
const TEXT_FILTER_TARGET_DUPLICATES = 'duplicates'
|
||||
|
||||
const TEXT_FILTER_MODIFIER_EQUALS = 'equals'
|
||||
const TEXT_FILTER_MODIFIER_NULL = 'is null'
|
||||
const TEXT_FILTER_MODIFIER_NOTNULL = 'not null'
|
||||
const TEXT_FILTER_MODIFIER_GT = 'greater'
|
||||
const TEXT_FILTER_MODIFIER_LT = 'less'
|
||||
const TEXT_FILTER_MODIFIER_HAS_DUPLICATES = 'has-duplicates'
|
||||
const TEXT_FILTER_MODIFIER_DOES_NOT_HAVE_DUPLICATES = 'does-not-have-duplicates'
|
||||
|
||||
const RELATIVE_DATE_QUERY_REGEXP_CREATED = /created:[\["]([^\]]+)[\]"]/g
|
||||
const RELATIVE_DATE_QUERY_REGEXP_ADDED = /added:[\["]([^\]]+)[\]"]/g
|
||||
@@ -205,6 +209,7 @@ const DEFAULT_TEXT_FILTER_TARGET_OPTIONS = [
|
||||
id: TEXT_FILTER_TARGET_FULLTEXT_QUERY,
|
||||
name: $localize`Advanced search`,
|
||||
},
|
||||
{ id: TEXT_FILTER_TARGET_DUPLICATES, name: $localize`Duplicates` },
|
||||
]
|
||||
|
||||
const DEPRECATED_CUSTOM_FIELDS_TEXT_FILTER_TARGET_OPTION = {
|
||||
@@ -241,6 +246,17 @@ const DEFAULT_TEXT_FILTER_MODIFIER_OPTIONS = [
|
||||
},
|
||||
]
|
||||
|
||||
const DUPLICATES_FILTER_MODIFIER_OPTIONS = [
|
||||
{
|
||||
id: TEXT_FILTER_MODIFIER_HAS_DUPLICATES,
|
||||
label: $localize`exist`,
|
||||
},
|
||||
{
|
||||
id: TEXT_FILTER_MODIFIER_DOES_NOT_HAVE_DUPLICATES,
|
||||
label: $localize`do not exist`,
|
||||
},
|
||||
]
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-filter-editor',
|
||||
templateUrl: './filter-editor.component.html',
|
||||
@@ -320,6 +336,12 @@ export class FilterEditorComponent
|
||||
if (rule.value == 'false') {
|
||||
return $localize`Without any tag`
|
||||
}
|
||||
break
|
||||
|
||||
case FILTER_HAS_DUPLICATES:
|
||||
return rule.value == 'false'
|
||||
? $localize`Without duplicates`
|
||||
: $localize`With duplicates`
|
||||
|
||||
case FILTER_CUSTOM_FIELDS_QUERY:
|
||||
return $localize`Custom fields query`
|
||||
@@ -390,7 +412,9 @@ export class FilterEditorComponent
|
||||
public textFilterModifier: string
|
||||
|
||||
get textFilterModifiers() {
|
||||
return DEFAULT_TEXT_FILTER_MODIFIER_OPTIONS
|
||||
return this.textFilterTarget === TEXT_FILTER_TARGET_DUPLICATES
|
||||
? DUPLICATES_FILTER_MODIFIER_OPTIONS
|
||||
: DEFAULT_TEXT_FILTER_MODIFIER_OPTIONS
|
||||
}
|
||||
|
||||
get textFilterModifierIsNull(): boolean {
|
||||
@@ -399,6 +423,13 @@ export class FilterEditorComponent
|
||||
)
|
||||
}
|
||||
|
||||
get textFilterInputDisabled(): boolean {
|
||||
return (
|
||||
this.textFilterModifierIsNull ||
|
||||
this.textFilterTarget === TEXT_FILTER_TARGET_DUPLICATES
|
||||
)
|
||||
}
|
||||
|
||||
tagSelectionModel = new FilterableDropdownSelectionModel(true)
|
||||
correspondentSelectionModel = new FilterableDropdownSelectionModel()
|
||||
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
|
||||
@@ -444,6 +475,7 @@ export class FilterEditorComponent
|
||||
this.customFieldQueriesModel.clear(false)
|
||||
this._textFilter = null
|
||||
this._moreLikeId = null
|
||||
this.textFilterTarget = TEXT_FILTER_TARGET_TITLE_CONTENT
|
||||
this.dateAddedTo = null
|
||||
this.dateAddedFrom = null
|
||||
this.dateCreatedTo = null
|
||||
@@ -477,6 +509,13 @@ export class FilterEditorComponent
|
||||
this.textFilterTarget = TEXT_FILTER_TARGET_MIME_TYPE
|
||||
this._textFilter = rule.value
|
||||
break
|
||||
case FILTER_HAS_DUPLICATES:
|
||||
this.textFilterTarget = TEXT_FILTER_TARGET_DUPLICATES
|
||||
this.textFilterModifier =
|
||||
rule.value == 'false' || rule.value == '0'
|
||||
? TEXT_FILTER_MODIFIER_DOES_NOT_HAVE_DUPLICATES
|
||||
: TEXT_FILTER_MODIFIER_HAS_DUPLICATES
|
||||
break
|
||||
case FILTER_FULLTEXT_QUERY:
|
||||
let allQueryArgs = rule.value.split(',')
|
||||
let textQueryArgs = []
|
||||
@@ -800,6 +839,14 @@ export class FilterEditorComponent
|
||||
value: this._textFilter.trim(),
|
||||
})
|
||||
}
|
||||
if (this.textFilterTarget == TEXT_FILTER_TARGET_DUPLICATES) {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_HAS_DUPLICATES,
|
||||
value: (
|
||||
this.textFilterModifier == TEXT_FILTER_MODIFIER_HAS_DUPLICATES
|
||||
).toString(),
|
||||
})
|
||||
}
|
||||
if (this._textFilter && this.textFilterTarget == TEXT_FILTER_TARGET_TITLE) {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_SIMPLE_TITLE,
|
||||
@@ -1163,7 +1210,7 @@ export class FilterEditorComponent
|
||||
}
|
||||
|
||||
get textFilter() {
|
||||
return this.textFilterModifierIsNull ? '' : this._textFilter
|
||||
return this.textFilterInputDisabled ? '' : this._textFilter
|
||||
}
|
||||
|
||||
set textFilter(value) {
|
||||
@@ -1363,12 +1410,24 @@ export class FilterEditorComponent
|
||||
this._textFilter = ''
|
||||
}
|
||||
this.textFilterTarget = target
|
||||
if (target == TEXT_FILTER_TARGET_DUPLICATES) {
|
||||
this._textFilter = ''
|
||||
this.textFilterModifier = TEXT_FILTER_MODIFIER_HAS_DUPLICATES
|
||||
} else if (
|
||||
[
|
||||
TEXT_FILTER_MODIFIER_HAS_DUPLICATES,
|
||||
TEXT_FILTER_MODIFIER_DOES_NOT_HAVE_DUPLICATES,
|
||||
].includes(this.textFilterModifier)
|
||||
) {
|
||||
this.textFilterModifier = TEXT_FILTER_MODIFIER_EQUALS
|
||||
}
|
||||
this.textFilterInput.nativeElement.focus()
|
||||
this.updateRules()
|
||||
}
|
||||
|
||||
textFilterModifierChange() {
|
||||
if (
|
||||
this.textFilterTarget == TEXT_FILTER_TARGET_DUPLICATES ||
|
||||
this.textFilterModifierIsNull ||
|
||||
([
|
||||
TEXT_FILTER_MODIFIER_EQUALS,
|
||||
|
||||
@@ -49,6 +49,7 @@ export const FILTER_MODIFIED_AFTER = 16
|
||||
export const FILTER_TITLE_CONTENT = 19 // Deprecated in favor of Tantivy-backed `text` filtervar. Keep for now for existing saved views
|
||||
export const FILTER_SIMPLE_TITLE = 48
|
||||
export const FILTER_SIMPLE_TEXT = 49
|
||||
export const FILTER_HAS_DUPLICATES = 50
|
||||
export const FILTER_FULLTEXT_QUERY = 20
|
||||
export const FILTER_FULLTEXT_MORELIKE = 21
|
||||
|
||||
@@ -382,6 +383,13 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
|
||||
datatype: 'string',
|
||||
multi: false,
|
||||
},
|
||||
{
|
||||
id: FILTER_HAS_DUPLICATES,
|
||||
filtervar: 'has_duplicates',
|
||||
datatype: 'boolean',
|
||||
multi: false,
|
||||
default: true,
|
||||
},
|
||||
]
|
||||
|
||||
export interface FilterRuleType {
|
||||
|
||||
@@ -422,6 +422,7 @@ export const PaperlessConfigOptions: ConfigOption[] = [
|
||||
]
|
||||
|
||||
export interface PaperlessConfig extends ObjectWithId {
|
||||
externally_configured_variables: string[]
|
||||
output_type: OutputTypeConfig
|
||||
pages: number
|
||||
language: string
|
||||
|
||||
@@ -84,6 +84,8 @@ export const SETTINGS_KEYS = {
|
||||
'general-settings:document-editing:remove-inbox-tags',
|
||||
DOCUMENT_EDITING_OVERLAY_THUMBNAIL:
|
||||
'general-settings:document-editing:overlay-thumbnail',
|
||||
DOCUMENT_EDITING_AUTO_SUGGEST:
|
||||
'general-settings:document-editing:auto-suggest',
|
||||
DOCUMENT_DETAILS_HIDDEN_FIELDS:
|
||||
'general-settings:document-details:hidden-fields',
|
||||
SEARCH_DB_ONLY: 'general-settings:search:db-only',
|
||||
@@ -300,6 +302,11 @@ export const SETTINGS: UiSetting[] = [
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST,
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
|
||||
type: 'array',
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
FILTER_HAS_ANY_TAG,
|
||||
FILTER_HAS_CUSTOM_FIELDS_ALL,
|
||||
FILTER_HAS_CUSTOM_FIELDS_ANY,
|
||||
FILTER_HAS_DUPLICATES,
|
||||
FILTER_HAS_TAGS_ALL,
|
||||
FILTER_SIMPLE_TEXT,
|
||||
FILTER_SIMPLE_TITLE,
|
||||
@@ -132,6 +133,16 @@ describe('QueryParams Utils', () => {
|
||||
is_tagged: 0,
|
||||
})
|
||||
|
||||
params = queryParamsFromFilterRules([
|
||||
{
|
||||
rule_type: FILTER_HAS_DUPLICATES,
|
||||
value: 'false',
|
||||
},
|
||||
])
|
||||
expect(params).toEqual({
|
||||
has_duplicates: 0,
|
||||
})
|
||||
|
||||
params = queryParamsFromFilterRules([
|
||||
{
|
||||
rule_type: FILTER_TITLE_CONTENT,
|
||||
@@ -247,6 +258,18 @@ describe('QueryParams Utils', () => {
|
||||
},
|
||||
])
|
||||
|
||||
rules = filterRulesFromQueryParams(
|
||||
convertToParamMap({
|
||||
has_duplicates: 'true',
|
||||
})
|
||||
)
|
||||
expect(rules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_HAS_DUPLICATES,
|
||||
value: 'true',
|
||||
},
|
||||
])
|
||||
|
||||
rules = filterRulesFromQueryParams(
|
||||
convertToParamMap({
|
||||
correspondent__isnull: '1',
|
||||
|
||||
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 7.6 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.4 238.9" style="enable-background:new 0 0 198.4 238.9" xml:space="preserve">
|
||||
<path d="M194.7 0C164.211 70.943 17.64 79.733 64.55 194.06c.59 1.468-10.848 17-18.47 29.897-1.758-6.453-3.816-13.486-3.516-14.075 38.109-45.141-27.26-70.643-30.776-107.583-16.423 29.318-22.286 80.623 27.25 110.23.29 0 2.637 11.138 3.816 16.712-1.169 2.348-2.348 4.695-2.927 6.454-1.168 2.926 7.622 2.637 7.622 3.226.879-.29 21.697-36.94 22.276-37.23C187.667 174.711 208.485 68.596 194.699 0zm-60.096 74.749c-55.11 49.246-64.49 85.897-62.732 103.777-18.47-43.682 35.772-91.76 62.732-103.777zM28.2 145.102c10.548 9.67 28.14 39.278 13.196 56.58 3.506-7.912 4.684-25.793-13.196-56.58z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 727 B |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2897.4 896.6" style="enable-background:new 0 0 2897.4 896.6" xml:space="preserve">
|
||||
<path d="M1022.3 428.7c-17.8-19.9-42.7-29.8-74.7-29.8-22.3 0-42.4 5.7-60.5 17.3-18.1 11.6-32.3 27.5-42.5 47.8s-15.3 42.9-15.3 67.8 5.1 47.5 15.3 67.8c10.3 20.3 24.4 36.2 42.5 47.8 18.1 11.5 38.3 17.3 60.5 17.3 32 0 56.9-9.9 74.7-29.8V655.5h84.5V408.3h-84.5v20.4zM1010.5 575c-10.2 11.7-23.6 17.6-40.2 17.6s-29.9-5.9-40-17.6-15.1-26.1-15.1-43.3c0-17.1 5-31.6 15.1-43.3s23.4-17.6 40-17.6 30 5.9 40.2 17.6 15.3 26.1 15.3 43.3-5.1 31.6-15.3 43.3zM1381 416.1c-18.1-11.5-38.3-17.3-60.5-17.4-32 0-56.9 9.9-74.7 29.8v-20.4h-84.5v390.7h84.5v-164c17.8 19.9 42.7 29.8 74.7 29.8 22.3 0 42.4-5.7 60.5-17.3s32.3-27.5 42.5-47.8c10.2-20.3 15.3-42.9 15.3-67.8s-5.1-47.5-15.3-67.8c-10.3-20.3-24.4-36.2-42.5-47.8zM1337.9 575c-10.1 11.7-23.4 17.6-40 17.6s-29.9-5.9-40-17.6-15.1-26.1-15.1-43.3c0-17.1 5-31.6 15.1-43.3s23.4-17.6 40-17.6 29.9 5.9 40 17.6 15.1 26.1 15.1 43.3-5.1 31.6-15.1 43.3zM1672.2 416.8c-20.5-12-43-18-67.6-18-24.9 0-47.6 5.9-68 17.6-20.4 11.7-36.5 27.7-48.2 48s-17.6 42.7-17.6 67.3c.3 25.2 6.2 47.8 17.8 68 11.5 20.2 28 36 49.3 47.6 21.3 11.5 45.9 17.3 73.8 17.3 48.6 0 86.8-14.7 114.7-44l-52.5-48.9c-8.6 8.3-17.6 14.6-26.7 19-9.3 4.3-21.1 6.4-35.3 6.4-11.6 0-22.5-3.6-32.7-10.9-10.3-7.3-17.1-16.5-20.7-27.8h180l.4-11.6c0-29.6-6-55.7-18-78.2s-28.3-39.8-48.7-51.8zm-113.9 86.4c2.1-12.1 7.5-21.8 16.2-29.1s18.7-10.9 30-10.9 21.2 3.6 29.8 10.9c8.6 7.2 13.9 16.9 16 29.1h-92zM1895.3 411.7c-11 5.6-20.3 13.7-28 24.4h-.1v-28h-84.5v247.3h84.5V536.3c0-22.6 4.7-38.1 14.2-46.5 9.5-8.5 22.7-12.7 39.6-12.7 6.2 0 13.5 1 21.8 3.1l10.7-72c-5.9-3.3-14.5-4.9-25.8-4.9-10.6 0-21.4 2.8-32.4 8.4zM1985 277.4h84.5v377.8H1985zM2313.2 416.8c-20.5-12-43-18-67.6-18-24.9 0-47.6 5.9-68 17.6s-36.5 27.7-48.2 48c-11.7 20.3-17.6 42.7-17.6 67.3.3 25.2 6.2 47.8 17.8 68 11.5 20.2 28 36 49.3 47.6 21.3 11.5 45.9 17.3 73.8 17.3 48.6 0 86.8-14.7 114.7-44l-52.5-48.9c-8.6 8.3-17.6 14.6-26.7 19-9.3 4.3-21.1 6.4-35.3 6.4-11.6 0-22.5-3.6-32.7-10.9-10.3-7.3-17.1-16.5-20.7-27.8h180l.4-11.6c0-29.6-6-55.7-18-78.2s-28.3-39.8-48.7-51.8zm-113.9 86.4c2.1-12.1 7.5-21.8 16.2-29.1s18.7-10.9 30-10.9 21.2 3.6 29.8 10.9c8.6 7.2 13.9 16.9 16 29.1h-92zM2583.6 507.7c-13.8-4.4-30.6-8.1-50.5-11.1-15.1-2.7-26.1-5.2-32.9-7.6-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7 6.7-10.9c4.4-2.2 11.5-3.3 21.3-3.3 11.6 0 24.3 2.4 38.1 7.2 13.9 4.8 26.2 11 36.9 18.4l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8-18.7-7.1-39.6-10.7-62.7-10.7-33.7 0-60.2 7.6-79.3 22.7-19.1 15.1-28.7 36.1-28.7 63.1 0 19 4.8 33.9 14.4 44.7 9.6 10.8 21 18.5 34 22.9 13.1 4.5 28.9 8.3 47.6 11.6 14.6 2.7 25.1 5.3 31.6 7.8s9.8 6.5 9.8 11.8c0 10.4-9.7 15.6-29.3 15.6-13.7 0-28.5-2.3-44.7-6.9-16.1-4.6-29.2-11.3-39.3-20.2l-33.3 60c9.2 7.4 24.6 14.7 46.2 22 21.7 7.3 45.2 10.9 70.7 10.9 34.7 0 62.9-7.4 84.5-22.4 21.7-15 32.5-37.3 32.5-66.9 0-19.3-5-34.2-15.1-44.9s-22-18.3-35.8-22.7zM2883.4 575.3c0-19.3-5-34.2-15.1-44.9s-22-18.3-35.8-22.7c-13.8-4.4-30.6-8.1-50.5-11.1-15.1-2.7-26.1-5.2-32.9-7.6-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7 6.7-10.9c4.4-2.2 11.5-3.3 21.3-3.3 11.6 0 24.3 2.4 38.1 7.2 13.9 4.8 26.2 11 36.9 18.4l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8-18.7-7.1-39.6-10.7-62.7-10.7-33.7 0-60.2 7.6-79.3 22.7-19.1 15.1-28.7 36.1-28.7 63.1 0 19 4.8 33.9 14.4 44.7 9.6 10.8 21 18.5 34 22.9 13.1 4.5 28.9 8.3 47.6 11.6 14.6 2.7 25.1 5.3 31.6 7.8s9.8 6.5 9.8 11.8c0 10.4-9.7 15.6-29.3 15.6-13.7 0-28.5-2.3-44.7-6.9-16.1-4.6-29.2-11.3-39.3-20.2l-33.3 60c9.2 7.4 24.6 14.7 46.2 22 21.7 7.3 45.2 10.9 70.7 10.9 34.7 0 62.9-7.4 84.5-22.4 21.7-15 32.5-37.3 32.5-66.9zM2460.7 738.7h59.6v17.2h-59.6zM2596.5 706.4c-5.7 0-11 1-15.8 3s-9 5-12.5 8.9v-9.4h-19.4v93.6h19.4v-52c0-8.6 2.1-15.3 6.3-20 4.2-4.7 9.5-7.1 15.9-7.1 7.8 0 13.4 2.3 16.8 6.7 3.4 4.5 5.1 11.3 5.1 20.5v52h19.4v-56.8c0-12.8-3.2-22.6-9.5-29.3-6.4-6.7-14.9-10.1-25.7-10.1zM2733.8 717.7c-3.6-3.4-7.9-6.1-13.1-8.2s-10.6-3.1-16.2-3.1c-8.7 0-16.5 2.1-23.5 6.3s-12.5 10-16.5 17.3c-4 7.3-6 15.4-6 24.4 0 8.9 2 17.1 6 24.3 4 7.3 9.5 13 16.5 17.2s14.9 6.3 23.5 6.3c5.6 0 11-1 16.2-3.1 5.1-2.1 9.5-4.8 13.1-8.2v24.4c0 8.5-2.5 14.8-7.6 18.7-5 3.9-11 5.9-18 5.9-6.7 0-12.4-1.6-17.3-4.7-4.8-3.1-7.6-7.7-8.3-13.8h-19.4c.6 7.7 2.9 14.2 7.1 19.5s9.6 9.3 16.2 12c6.6 2.7 13.8 4 21.7 4 12.8 0 23.5-3.4 32-10.1 8.6-6.7 12.8-17.1 12.8-31.1V708.9h-19.2v8.8zm-1.6 52.4c-2.5 4.7-6 8.3-10.4 11.2-4.4 2.7-9.4 4-14.9 4-5.7 0-10.8-1.4-15.2-4.3s-7.8-6.7-10.2-11.4c-2.3-4.8-3.5-9.8-3.5-15.2 0-5.5 1.1-10.6 3.5-15.3s5.8-8.5 10.2-11.3 9.5-4.2 15.2-4.2c5.5 0 10.5 1.4 14.9 4s7.9 6.3 10.4 11 3.8 10 3.8 15.8-1.3 11-3.8 15.7zM2867.9 708.9h-21.4l-25.6 33-25.4-33h-22.4l36 46.1-37.6 47.5h21.4l27.2-34.6 27.1 34.7h22.4l-37.6-48.2zM757.6 293.7c-20-10.8-42.6-16.2-67.8-16.2H600c-8.5 39.2-21.1 76.4-37.6 111.3-9.9 20.8-21.1 40.6-33.6 59.4v207.2h88.9V521.5h72c25.2 0 47.8-5.4 67.8-16.2s35.7-25.6 47.1-44.2c11.4-18.7 17.1-39.1 17.1-61.3.1-22.7-5.6-43.3-17-61.9-11.4-18.7-27.1-33.4-47.1-44.2zm-41 140.6c-9.3 8.9-21.6 13.3-36.7 13.3l-62.2.4v-92.5l62.2-.4c15.1 0 27.3 4.4 36.7 13.3 9.4 8.9 14 19.9 14 32.9 0 13.2-4.6 24.1-14 33z"/>
|
||||
<path d="M140 713.7c-3.4-16.4-10.3-49.1-11.2-49.1C-16.9 577.5.4 426.6 48.6 340.4 59 449 251.2 524 139.1 656.8c-.9 1.7 5.2 22.4 10.3 41.4 22.4-37.9 56-83.6 54.3-87.9C65.9 273.9 496.9 248.1 586.6 39.4c40.5 201.8-20.7 513.9-367.2 593.2-1.7.9-62.9 108.6-65.5 109.5 0-1.7-25.9-.9-22.4-9.5 1.6-5.2 5.1-12 8.5-18.9zm-4.3-81.1c44-50.9-7.8-137.9-38.8-166.4 52.6 90.5 49.1 143.1 38.8 166.4z" style="fill:#17541f"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.4 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="264.567" height="318.552" viewBox="0 0 70 84.284">
|
||||
<path style="fill:#17541f;stroke-width:1.10017" d="M752.438 82.365C638.02 348.605 87.938 381.61 263.964 810.674c2.2 5.5-40.706 63.81-69.31 112.217-6.602-24.204-14.304-50.607-13.204-52.807C324.473 700.658 79.136 604.944 65.934 466.322 4.324 576.34-17.678 768.868 168.25 879.984c1.1 0 9.902 41.808 14.303 62.711-4.4 8.802-8.802 17.602-11.002 24.203-4.4 11.002 28.603 9.902 28.603 12.102 3.3-1.1 81.413-138.62 83.614-139.72 442.267-101.216 520.377-499.476 468.67-756.915ZM526.904 362.906c-206.831 184.828-242.036 322.35-235.435 389.46-69.31-163.926 134.22-344.353 235.435-389.46ZM127.543 626.947c39.606 36.306 105.616 147.422 49.508 212.332 13.202-29.704 17.602-96.814-49.508-212.332z" transform="matrix(.094 0 0 .094 -2.042 -7.742)" fill="#17541F"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 855 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="264.567" height="318.552" viewBox="0 0 70 84.284">
|
||||
<path style="fill:#fff;stroke-width:1.10017" d="M752.438 82.365C638.02 348.605 87.938 381.61 263.964 810.674c2.2 5.5-40.706 63.81-69.31 112.217-6.602-24.204-14.304-50.607-13.204-52.807C324.473 700.658 79.136 604.944 65.934 466.322 4.324 576.34-17.678 768.868 168.25 879.984c1.1 0 9.902 41.808 14.303 62.711-4.4 8.802-8.802 17.602-11.002 24.203-4.4 11.002 28.603 9.902 28.603 12.102 3.3-1.1 81.413-138.62 83.614-139.72 442.267-101.216 520.377-499.476 468.67-756.915ZM526.904 362.906c-206.831 184.828-242.036 322.35-235.435 389.46-69.31-163.926 134.22-344.353 235.435-389.46ZM127.543 626.947c39.606 36.306 105.616 147.422 49.508 212.332 13.202-29.704 17.602-96.814-49.508-212.332z" transform="matrix(.094 0 0 .094 -2.042 -7.742)" fill="#fff"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 849 B |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2897.4 896.6" style="enable-background:new 0 0 2897.4 896.6" xml:space="preserve">
|
||||
<path d="M1022.3 428.7c-17.8-19.9-42.7-29.8-74.7-29.8-22.3 0-42.4 5.7-60.5 17.3-18.1 11.6-32.3 27.5-42.5 47.8s-15.3 42.9-15.3 67.8 5.1 47.5 15.3 67.8c10.3 20.3 24.4 36.2 42.5 47.8 18.1 11.5 38.3 17.3 60.5 17.3 32 0 56.9-9.9 74.7-29.8V655.5h84.5V408.3h-84.5v20.4zM1010.5 575c-10.2 11.7-23.6 17.6-40.2 17.6s-29.9-5.9-40-17.6-15.1-26.1-15.1-43.3c0-17.1 5-31.6 15.1-43.3s23.4-17.6 40-17.6 30 5.9 40.2 17.6 15.3 26.1 15.3 43.3-5.1 31.6-15.3 43.3zM1381 416.1c-18.1-11.5-38.3-17.3-60.5-17.4-32 0-56.9 9.9-74.7 29.8v-20.4h-84.5v390.7h84.5v-164c17.8 19.9 42.7 29.8 74.7 29.8 22.3 0 42.4-5.7 60.5-17.3s32.3-27.5 42.5-47.8c10.2-20.3 15.3-42.9 15.3-67.8s-5.1-47.5-15.3-67.8c-10.3-20.3-24.4-36.2-42.5-47.8zM1337.9 575c-10.1 11.7-23.4 17.6-40 17.6s-29.9-5.9-40-17.6-15.1-26.1-15.1-43.3c0-17.1 5-31.6 15.1-43.3s23.4-17.6 40-17.6 29.9 5.9 40 17.6 15.1 26.1 15.1 43.3-5.1 31.6-15.1 43.3zM1672.2 416.8c-20.5-12-43-18-67.6-18-24.9 0-47.6 5.9-68 17.6-20.4 11.7-36.5 27.7-48.2 48s-17.6 42.7-17.6 67.3c.3 25.2 6.2 47.8 17.8 68 11.5 20.2 28 36 49.3 47.6 21.3 11.5 45.9 17.3 73.8 17.3 48.6 0 86.8-14.7 114.7-44l-52.5-48.9c-8.6 8.3-17.6 14.6-26.7 19-9.3 4.3-21.1 6.4-35.3 6.4-11.6 0-22.5-3.6-32.7-10.9-10.3-7.3-17.1-16.5-20.7-27.8h180l.4-11.6c0-29.6-6-55.7-18-78.2s-28.3-39.8-48.7-51.8zm-113.9 86.4c2.1-12.1 7.5-21.8 16.2-29.1s18.7-10.9 30-10.9 21.2 3.6 29.8 10.9c8.6 7.2 13.9 16.9 16 29.1h-92zM1895.3 411.7c-11 5.6-20.3 13.7-28 24.4h-.1v-28h-84.5v247.3h84.5V536.3c0-22.6 4.7-38.1 14.2-46.5 9.5-8.5 22.7-12.7 39.6-12.7 6.2 0 13.5 1 21.8 3.1l10.7-72c-5.9-3.3-14.5-4.9-25.8-4.9-10.6 0-21.4 2.8-32.4 8.4zM1985 277.4h84.5v377.8H1985zM2313.2 416.8c-20.5-12-43-18-67.6-18-24.9 0-47.6 5.9-68 17.6s-36.5 27.7-48.2 48c-11.7 20.3-17.6 42.7-17.6 67.3.3 25.2 6.2 47.8 17.8 68 11.5 20.2 28 36 49.3 47.6 21.3 11.5 45.9 17.3 73.8 17.3 48.6 0 86.8-14.7 114.7-44l-52.5-48.9c-8.6 8.3-17.6 14.6-26.7 19-9.3 4.3-21.1 6.4-35.3 6.4-11.6 0-22.5-3.6-32.7-10.9-10.3-7.3-17.1-16.5-20.7-27.8h180l.4-11.6c0-29.6-6-55.7-18-78.2s-28.3-39.8-48.7-51.8zm-113.9 86.4c2.1-12.1 7.5-21.8 16.2-29.1s18.7-10.9 30-10.9 21.2 3.6 29.8 10.9c8.6 7.2 13.9 16.9 16 29.1h-92zM2583.6 507.7c-13.8-4.4-30.6-8.1-50.5-11.1-15.1-2.7-26.1-5.2-32.9-7.6-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7 6.7-10.9c4.4-2.2 11.5-3.3 21.3-3.3 11.6 0 24.3 2.4 38.1 7.2 13.9 4.8 26.2 11 36.9 18.4l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8-18.7-7.1-39.6-10.7-62.7-10.7-33.7 0-60.2 7.6-79.3 22.7-19.1 15.1-28.7 36.1-28.7 63.1 0 19 4.8 33.9 14.4 44.7 9.6 10.8 21 18.5 34 22.9 13.1 4.5 28.9 8.3 47.6 11.6 14.6 2.7 25.1 5.3 31.6 7.8s9.8 6.5 9.8 11.8c0 10.4-9.7 15.6-29.3 15.6-13.7 0-28.5-2.3-44.7-6.9-16.1-4.6-29.2-11.3-39.3-20.2l-33.3 60c9.2 7.4 24.6 14.7 46.2 22 21.7 7.3 45.2 10.9 70.7 10.9 34.7 0 62.9-7.4 84.5-22.4 21.7-15 32.5-37.3 32.5-66.9 0-19.3-5-34.2-15.1-44.9s-22-18.3-35.8-22.7zM2883.4 575.3c0-19.3-5-34.2-15.1-44.9s-22-18.3-35.8-22.7c-13.8-4.4-30.6-8.1-50.5-11.1-15.1-2.7-26.1-5.2-32.9-7.6-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7 6.7-10.9c4.4-2.2 11.5-3.3 21.3-3.3 11.6 0 24.3 2.4 38.1 7.2 13.9 4.8 26.2 11 36.9 18.4l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8-18.7-7.1-39.6-10.7-62.7-10.7-33.7 0-60.2 7.6-79.3 22.7-19.1 15.1-28.7 36.1-28.7 63.1 0 19 4.8 33.9 14.4 44.7 9.6 10.8 21 18.5 34 22.9 13.1 4.5 28.9 8.3 47.6 11.6 14.6 2.7 25.1 5.3 31.6 7.8s9.8 6.5 9.8 11.8c0 10.4-9.7 15.6-29.3 15.6-13.7 0-28.5-2.3-44.7-6.9-16.1-4.6-29.2-11.3-39.3-20.2l-33.3 60c9.2 7.4 24.6 14.7 46.2 22 21.7 7.3 45.2 10.9 70.7 10.9 34.7 0 62.9-7.4 84.5-22.4 21.7-15 32.5-37.3 32.5-66.9zM2460.7 738.7h59.6v17.2h-59.6zM2596.5 706.4c-5.7 0-11 1-15.8 3s-9 5-12.5 8.9v-9.4h-19.4v93.6h19.4v-52c0-8.6 2.1-15.3 6.3-20 4.2-4.7 9.5-7.1 15.9-7.1 7.8 0 13.4 2.3 16.8 6.7 3.4 4.5 5.1 11.3 5.1 20.5v52h19.4v-56.8c0-12.8-3.2-22.6-9.5-29.3-6.4-6.7-14.9-10.1-25.7-10.1zM2733.8 717.7c-3.6-3.4-7.9-6.1-13.1-8.2s-10.6-3.1-16.2-3.1c-8.7 0-16.5 2.1-23.5 6.3s-12.5 10-16.5 17.3c-4 7.3-6 15.4-6 24.4 0 8.9 2 17.1 6 24.3 4 7.3 9.5 13 16.5 17.2s14.9 6.3 23.5 6.3c5.6 0 11-1 16.2-3.1 5.1-2.1 9.5-4.8 13.1-8.2v24.4c0 8.5-2.5 14.8-7.6 18.7-5 3.9-11 5.9-18 5.9-6.7 0-12.4-1.6-17.3-4.7-4.8-3.1-7.6-7.7-8.3-13.8h-19.4c.6 7.7 2.9 14.2 7.1 19.5s9.6 9.3 16.2 12c6.6 2.7 13.8 4 21.7 4 12.8 0 23.5-3.4 32-10.1 8.6-6.7 12.8-17.1 12.8-31.1V708.9h-19.2v8.8zm-1.6 52.4c-2.5 4.7-6 8.3-10.4 11.2-4.4 2.7-9.4 4-14.9 4-5.7 0-10.8-1.4-15.2-4.3s-7.8-6.7-10.2-11.4c-2.3-4.8-3.5-9.8-3.5-15.2 0-5.5 1.1-10.6 3.5-15.3s5.8-8.5 10.2-11.3 9.5-4.2 15.2-4.2c5.5 0 10.5 1.4 14.9 4s7.9 6.3 10.4 11 3.8 10 3.8 15.8-1.3 11-3.8 15.7zM2867.9 708.9h-21.4l-25.6 33-25.4-33h-22.4l36 46.1-37.6 47.5h21.4l27.2-34.6 27.1 34.7h22.4l-37.6-48.2zM757.6 293.7c-20-10.8-42.6-16.2-67.8-16.2H600c-8.5 39.2-21.1 76.4-37.6 111.3-9.9 20.8-21.1 40.6-33.6 59.4v207.2h88.9V521.5h72c25.2 0 47.8-5.4 67.8-16.2s35.7-25.6 47.1-44.2c11.4-18.7 17.1-39.1 17.1-61.3.1-22.7-5.6-43.3-17-61.9-11.4-18.7-27.1-33.4-47.1-44.2zm-41 140.6c-9.3 8.9-21.6 13.3-36.7 13.3l-62.2.4v-92.5l62.2-.4c15.1 0 27.3 4.4 36.7 13.3 9.4 8.9 14 19.9 14 32.9 0 13.2-4.6 24.1-14 33z"/>
|
||||
<path d="M140 713.7c-3.4-16.4-10.3-49.1-11.2-49.1C-16.9 577.5.4 426.6 48.6 340.4 59 449 251.2 524 139.1 656.8c-.9 1.7 5.2 22.4 10.3 41.4 22.4-37.9 56-83.6 54.3-87.9C65.9 273.9 496.9 248.1 586.6 39.4c40.5 201.8-20.7 513.9-367.2 593.2-1.7.9-62.9 108.6-65.5 109.5 0-1.7-25.9-.9-22.4-9.5 1.6-5.2 5.1-12 8.5-18.9zm-4.3-81.1c44-50.9-7.8-137.9-38.8-166.4 52.6 90.5 49.1 143.1 38.8 166.4z" style="fill:#17541f"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
@@ -8,7 +8,6 @@
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<meta name="theme-color" content="#17541f" />
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -4,12 +4,20 @@
|
||||
"display": "standalone",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "256x256"
|
||||
"src": "icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "assets/logo-notext.svg",
|
||||
"sizes": "any"
|
||||
"src": "icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icon-512-maskable.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"name": "Paperless-ngx",
|
||||
|
||||
@@ -12,7 +12,6 @@ from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import FieldError
|
||||
from django.db.models import Case
|
||||
from django.db.models import CharField
|
||||
from django.db.models import Count
|
||||
@@ -25,6 +24,7 @@ from django.db.models import Sum
|
||||
from django.db.models import Value
|
||||
from django.db.models import When
|
||||
from django.db.models.functions import Cast
|
||||
from django.db.models.functions import NullIf
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_filters import DateFilter
|
||||
from django_filters.rest_framework import BooleanFilter
|
||||
@@ -50,7 +50,9 @@ from documents.models import ShareLink
|
||||
from documents.models import ShareLinkBundle
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.permissions import permitted_document_ids
|
||||
from documents.permissions import permitted_object_ids
|
||||
from documents.versioning import annotate_effective_content
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
@@ -180,14 +182,9 @@ class TitleContentFilter(Filter):
|
||||
logger.warning(
|
||||
"Deprecated document filter parameter 'title_content' used; use `text` instead.",
|
||||
)
|
||||
try:
|
||||
return qs.filter(
|
||||
Q(title__icontains=value) | Q(effective_content__icontains=value),
|
||||
)
|
||||
except FieldError:
|
||||
return qs.filter(
|
||||
Q(title__icontains=value) | Q(content__icontains=value),
|
||||
)
|
||||
return annotate_effective_content(qs).filter(
|
||||
Q(title__icontains=value) | Q(effective_content__icontains=value),
|
||||
)
|
||||
else:
|
||||
return qs
|
||||
|
||||
@@ -198,14 +195,9 @@ class EffectiveContentFilter(Filter):
|
||||
value = value.strip() if isinstance(value, str) else value
|
||||
if not value:
|
||||
return qs
|
||||
try:
|
||||
return qs.filter(
|
||||
**{f"effective_content__{self.lookup_expr}": value},
|
||||
)
|
||||
except FieldError:
|
||||
return qs.filter(
|
||||
**{f"content__{self.lookup_expr}": value},
|
||||
)
|
||||
return annotate_effective_content(qs).filter(
|
||||
**{f"effective_content__{self.lookup_expr}": value},
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_field(serializers.BooleanField)
|
||||
@@ -793,6 +785,12 @@ class CustomFieldQueryFilter(Filter):
|
||||
|
||||
|
||||
class DocumentFilterSet(FilterSet):
|
||||
has_duplicates = BooleanFilter(method="filter_has_duplicates")
|
||||
|
||||
def __init__(self, *args: Any, user: Any = None, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._user = user
|
||||
|
||||
is_tagged = BooleanFilter(
|
||||
label="Is tagged",
|
||||
field_name="tags",
|
||||
@@ -852,6 +850,38 @@ class DocumentFilterSet(FilterSet):
|
||||
|
||||
mime_type = MimeTypeFilter()
|
||||
|
||||
def filter_has_duplicates(self, queryset, name, value):
|
||||
if value is None:
|
||||
return queryset
|
||||
|
||||
user = (
|
||||
self._user
|
||||
if self._user is not None
|
||||
else getattr(self.request, "user", None)
|
||||
)
|
||||
queryset = queryset.alias(
|
||||
nonempty_archive_checksum=NullIf("archive_checksum", Value("")),
|
||||
)
|
||||
|
||||
visible_root_documents = Document.global_objects.filter(
|
||||
root_document__isnull=True,
|
||||
pk__in=permitted_document_ids(
|
||||
user,
|
||||
include_deleted=True,
|
||||
),
|
||||
).exclude(pk=OuterRef("pk"))
|
||||
# see serialisers._get_viewable_duplicates().
|
||||
matching_duplicates = visible_root_documents.filter(
|
||||
Q(checksum=OuterRef("checksum"))
|
||||
| Q(checksum=OuterRef("nonempty_archive_checksum"))
|
||||
| Q(archive_checksum=OuterRef("checksum"))
|
||||
| Q(archive_checksum=OuterRef("nonempty_archive_checksum")),
|
||||
)
|
||||
|
||||
return queryset.alias(
|
||||
has_visible_duplicates=Exists(matching_duplicates),
|
||||
).filter(has_visible_duplicates=value)
|
||||
|
||||
# Backwards compatibility
|
||||
created__date__gt = DateFilter(field_name="created", lookup_expr="gt")
|
||||
created__date__gte = DateFilter(field_name="created", lookup_expr="gte")
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Generated by Django 5.2.16 on 2026-09-05 16:29
|
||||
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("documents", "0025_workflowaction_apply_ai_suggestions"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="document",
|
||||
name="archive_checksum",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
db_index=True,
|
||||
editable=False,
|
||||
help_text="The checksum of the archived document.",
|
||||
max_length=64,
|
||||
null=True,
|
||||
verbose_name="archive checksum",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="savedviewfilterrule",
|
||||
name="rule_type",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[
|
||||
(0, "title contains"),
|
||||
(1, "content contains"),
|
||||
(2, "ASN is"),
|
||||
(3, "correspondent is"),
|
||||
(4, "document type is"),
|
||||
(5, "is in inbox"),
|
||||
(6, "has tag"),
|
||||
(7, "has any tag"),
|
||||
(8, "created before"),
|
||||
(9, "created after"),
|
||||
(10, "created year is"),
|
||||
(11, "created month is"),
|
||||
(12, "created day is"),
|
||||
(13, "added before"),
|
||||
(14, "added after"),
|
||||
(15, "modified before"),
|
||||
(16, "modified after"),
|
||||
(17, "does not have tag"),
|
||||
(18, "does not have ASN"),
|
||||
(19, "title or content contains"),
|
||||
(20, "fulltext query"),
|
||||
(21, "more like this"),
|
||||
(22, "has tags in"),
|
||||
(23, "ASN greater than"),
|
||||
(24, "ASN less than"),
|
||||
(25, "storage path is"),
|
||||
(26, "has correspondent in"),
|
||||
(27, "does not have correspondent in"),
|
||||
(28, "has document type in"),
|
||||
(29, "does not have document type in"),
|
||||
(30, "has storage path in"),
|
||||
(31, "does not have storage path in"),
|
||||
(32, "owner is"),
|
||||
(33, "has owner in"),
|
||||
(34, "does not have owner"),
|
||||
(35, "does not have owner in"),
|
||||
(36, "has custom field value"),
|
||||
(37, "is shared by me"),
|
||||
(38, "has custom fields"),
|
||||
(39, "has custom field in"),
|
||||
(40, "does not have custom field in"),
|
||||
(41, "does not have custom field"),
|
||||
(42, "custom fields query"),
|
||||
(43, "created to"),
|
||||
(44, "created from"),
|
||||
(45, "added to"),
|
||||
(46, "added from"),
|
||||
(47, "mime type is"),
|
||||
(48, "simple title search"),
|
||||
(49, "simple text search"),
|
||||
(50, "has duplicates"),
|
||||
],
|
||||
verbose_name="rule type",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -227,6 +227,7 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
||||
editable=False,
|
||||
blank=True,
|
||||
null=True,
|
||||
db_index=True,
|
||||
help_text=_("The checksum of the archived document."),
|
||||
)
|
||||
|
||||
@@ -706,6 +707,7 @@ class SavedViewFilterRule(models.Model):
|
||||
(47, _("mime type is")),
|
||||
(48, _("simple title search")),
|
||||
(49, _("simple text search")),
|
||||
(50, _("has duplicates")),
|
||||
]
|
||||
|
||||
saved_view = models.ForeignKey(
|
||||
|
||||
@@ -284,6 +284,46 @@ class WriteBatch:
|
||||
tantivy.Query.term_query(self._backend._schema, "id", doc_id),
|
||||
)
|
||||
|
||||
def add_or_update_ids(self, ids: Sequence[int]) -> None:
|
||||
"""
|
||||
Add or update multiple documents in the batch by primary key.
|
||||
|
||||
Unlike calling ``add_or_update()`` once per document, this resolves
|
||||
viewer permissions and effective (versioned) content in bulk against
|
||||
the ids as a whole, instead of once per document -- see
|
||||
``_DocumentViewerStream`` and ``annotate_effective_content``. Use
|
||||
this whenever more than one document is being written in the same
|
||||
batch.
|
||||
|
||||
An id with no matching document (e.g. deleted between the caller
|
||||
collecting ids and the batch running) is silently skipped, matching
|
||||
``add_or_update()``'s existing single-document deferred-task behavior
|
||||
rather than erroring or leaving a stale index entry.
|
||||
|
||||
Args:
|
||||
ids: Primary keys of Document instances to index
|
||||
"""
|
||||
from documents.models import Document
|
||||
from documents.versioning import annotate_effective_content
|
||||
|
||||
ids = list(ids)
|
||||
if not ids:
|
||||
return
|
||||
|
||||
queryset = annotate_effective_content(
|
||||
Document.objects.filter(pk__in=ids)
|
||||
.select_related("correspondent", "document_type", "storage_path", "owner")
|
||||
.prefetch_related("tags", "notes__user", "custom_fields__field"),
|
||||
)
|
||||
for document, grant in _DocumentViewerStream(queryset, chunk_size=1000):
|
||||
self.remove(document.pk)
|
||||
doc = self._backend._build_tantivy_doc(
|
||||
document,
|
||||
viewer_ids=grant.viewer_ids,
|
||||
viewer_group_ids=grant.viewer_group_ids,
|
||||
)
|
||||
self._writer.add_document(doc)
|
||||
|
||||
|
||||
class TantivyBackend:
|
||||
"""
|
||||
|
||||
@@ -674,6 +674,9 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
|
||||
ordering = ordering or (Lower("name"),)
|
||||
children = children.order_by(*ordering)
|
||||
|
||||
if not children:
|
||||
return []
|
||||
|
||||
serializer = TagSerializer(
|
||||
children,
|
||||
many=True,
|
||||
|
||||
@@ -312,7 +312,10 @@ def bulk_update_documents(document_ids) -> None:
|
||||
from documents.search import get_backend
|
||||
|
||||
document_ids = list(document_ids)
|
||||
# Annotated so indexing below doesn't query the versions of each document
|
||||
# Annotated so the signal handlers below (e.g. matching) don't query the
|
||||
# versions of each document. Indexing re-queries and re-annotates its own
|
||||
# copy via add_or_update_ids() below, after these signals (and any
|
||||
# workflow they trigger) have had a chance to mutate the documents.
|
||||
documents = annotate_effective_content(
|
||||
Document.objects.filter(id__in=document_ids),
|
||||
)
|
||||
@@ -328,8 +331,7 @@ def bulk_update_documents(document_ids) -> None:
|
||||
post_save.send(Document, instance=doc, created=False)
|
||||
|
||||
with get_backend().batch_update() as batch:
|
||||
for doc in documents:
|
||||
batch.add_or_update(doc)
|
||||
batch.add_or_update_ids(document_ids)
|
||||
|
||||
ai_config = AIConfig()
|
||||
if ai_config.llm_index_enabled:
|
||||
|
||||
@@ -10,7 +10,7 @@ import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from guardian.shortcuts import clear_ct_cache
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
from pytest_django.fixtures import Settings
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from documents.tests.factories import DocumentFactory
|
||||
@@ -100,7 +100,7 @@ def sample_doc(
|
||||
@pytest.fixture()
|
||||
def _search_index(
|
||||
tmp_path: Path,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
) -> Generator[None, None, None]:
|
||||
"""Create a temp index directory and point INDEX_DIR at it.
|
||||
|
||||
@@ -118,7 +118,7 @@ def _search_index(
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def settings_timezone(settings: SettingsWrapper) -> zoneinfo.ZoneInfo:
|
||||
def settings_timezone(settings: Settings) -> zoneinfo.ZoneInfo:
|
||||
return zoneinfo.ZoneInfo(settings.TIME_ZONE)
|
||||
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ def clear_lru_cache() -> Generator[None, None, None]:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_date_parser_settings(settings: pytest_django.fixtures.SettingsWrapper) -> Any:
|
||||
def mock_date_parser_settings(settings: pytest_django.fixtures.Settings) -> Any:
|
||||
"""
|
||||
Override Django settings for the duration of date parser tests.
|
||||
"""
|
||||
|
||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import pytest_mock
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
from pytest_django.fixtures import Settings
|
||||
|
||||
from documents.export.sinks import DirectoryExportSink
|
||||
from documents.export.sinks import ExportSink
|
||||
@@ -242,7 +242,7 @@ class TestZipExportSink:
|
||||
self,
|
||||
tmp_path: Path,
|
||||
source_file: Path,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
scratch_dir = tmp_path / "scratch"
|
||||
settings.SCRATCH_DIR = scratch_dir
|
||||
@@ -261,7 +261,7 @@ class TestZipExportSink:
|
||||
def test_abort_after_manifest_written_cleans_up_pending_tmp(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
scratch_dir = tmp_path / "scratch"
|
||||
settings.SCRATCH_DIR = scratch_dir
|
||||
|
||||
@@ -15,11 +15,11 @@ if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
from pytest_django.fixtures import Settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index_dir(tmp_path: Path, settings: SettingsWrapper) -> Path:
|
||||
def index_dir(tmp_path: Path, settings: Settings) -> Path:
|
||||
path = tmp_path / "index"
|
||||
path.mkdir()
|
||||
settings.INDEX_DIR = path
|
||||
|
||||
@@ -4,6 +4,8 @@ from pathlib import Path
|
||||
import pytest
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import connection
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from guardian.shortcuts import assign_perm
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
@@ -102,6 +104,191 @@ class TestWriteBatch:
|
||||
assert len(backend.search_ids("indexable", user=None)) == 1
|
||||
|
||||
|
||||
class TestAddOrUpdateIds:
|
||||
"""Test WriteBatch.add_or_update_ids(), the bulk id-based upsert path.
|
||||
|
||||
Unlike add_or_update() called once per document, this resolves viewer
|
||||
permissions and effective (versioned) content in bulk against the ids as
|
||||
a whole, so it must produce identical indexed output to the per-document
|
||||
path while issuing a constant number of queries regardless of batch size.
|
||||
"""
|
||||
|
||||
def test_missing_id_is_skipped_not_errored(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
) -> None:
|
||||
doc = Document.objects.create(
|
||||
title="doc",
|
||||
content="present",
|
||||
checksum="EXIST1",
|
||||
pk=1,
|
||||
)
|
||||
missing_pk = 999
|
||||
|
||||
with backend.batch_update() as batch:
|
||||
batch.add_or_update_ids([doc.pk, missing_pk])
|
||||
|
||||
assert backend.search_ids("present", user=None) == [doc.pk]
|
||||
|
||||
def test_query_count_does_not_scale_with_batch_size(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
) -> None:
|
||||
"""Each query count must stay far below N, not merely match between
|
||||
two runs -- an exact-equality assertion between two measurements is
|
||||
at the mercy of incidental process-level caches (e.g. Django's
|
||||
ContentType.objects.get_for_model) warming on whichever run happens
|
||||
first, which makes counts differ by a query for reasons unrelated to
|
||||
batch size. A generous fixed bound sidesteps that: the old
|
||||
per-document path issued roughly 8 queries per document, so 50
|
||||
documents under a bound this low proves the fix regardless of cache
|
||||
state.
|
||||
"""
|
||||
max_queries_for_any_batch_size = 15
|
||||
|
||||
small_docs = [
|
||||
Document.objects.create(
|
||||
title="doc",
|
||||
content=f"unique{i}",
|
||||
checksum=f"SMALL{i}",
|
||||
pk=i,
|
||||
)
|
||||
for i in range(1, 3)
|
||||
]
|
||||
with CaptureQueriesContext(connection) as ctx_small:
|
||||
with backend.batch_update() as batch:
|
||||
batch.add_or_update_ids([d.pk for d in small_docs])
|
||||
assert len(ctx_small.captured_queries) <= max_queries_for_any_batch_size
|
||||
|
||||
large_docs = [
|
||||
Document.objects.create(
|
||||
title="doc",
|
||||
content=f"unique{i}",
|
||||
checksum=f"LARGE{i}",
|
||||
pk=i,
|
||||
)
|
||||
for i in range(100, 150)
|
||||
]
|
||||
with CaptureQueriesContext(connection) as ctx_large:
|
||||
with backend.batch_update() as batch:
|
||||
batch.add_or_update_ids([d.pk for d in large_docs])
|
||||
assert len(ctx_large.captured_queries) <= max_queries_for_any_batch_size
|
||||
|
||||
for doc in large_docs:
|
||||
assert backend.search_ids(f"unique{doc.pk}", user=None) == [doc.pk]
|
||||
|
||||
def test_resolves_direct_user_grant_in_bulk(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
) -> None:
|
||||
owner = UserFactory()
|
||||
user = UserFactory()
|
||||
doc = Document.objects.create(
|
||||
title="doc",
|
||||
checksum="PERM1",
|
||||
pk=1,
|
||||
owner=owner,
|
||||
)
|
||||
assign_perm("view_document", user, doc)
|
||||
|
||||
with backend.batch_update() as batch:
|
||||
batch.add_or_update_ids([doc.pk])
|
||||
|
||||
assert backend.search_ids("doc", user=user) == [doc.pk]
|
||||
other = UserFactory()
|
||||
assert backend.search_ids("doc", user=other) == []
|
||||
|
||||
def test_resolves_group_grant_in_bulk(self, backend: TantivyBackend) -> None:
|
||||
owner = UserFactory()
|
||||
group = Group.objects.create(name="reviewers")
|
||||
user = UserFactory()
|
||||
user.groups.add(group)
|
||||
doc = Document.objects.create(
|
||||
title="doc",
|
||||
checksum="GPERM1",
|
||||
pk=1,
|
||||
owner=owner,
|
||||
)
|
||||
assign_perm("view_document", group, doc)
|
||||
|
||||
with backend.batch_update() as batch:
|
||||
batch.add_or_update_ids([doc.pk])
|
||||
|
||||
assert backend.search_ids("doc", user=user) == [doc.pk]
|
||||
other = UserFactory()
|
||||
assert backend.search_ids("doc", user=other) == []
|
||||
|
||||
def test_indexes_notes_and_custom_fields(self, backend: TantivyBackend) -> None:
|
||||
note_author = UserFactory(username="noter")
|
||||
field = CustomField.objects.create(
|
||||
name="Invoice Number",
|
||||
data_type=CustomField.FieldDataType.STRING,
|
||||
)
|
||||
doc = Document.objects.create(title="doc", checksum="RICH1", pk=1)
|
||||
Note.objects.create(document=doc, note="Reviewed", user=note_author)
|
||||
CustomFieldInstance.objects.create(
|
||||
document=doc,
|
||||
field=field,
|
||||
value_text="INV-42",
|
||||
)
|
||||
|
||||
with backend.batch_update() as batch:
|
||||
batch.add_or_update_ids([doc.pk])
|
||||
|
||||
assert backend.search_ids("notes.user:noter", user=None) == [doc.pk]
|
||||
assert backend.search_ids("custom_fields.value:INV-42", user=None) == [
|
||||
doc.pk,
|
||||
]
|
||||
|
||||
def test_uses_effective_content_for_versioned_documents(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
) -> None:
|
||||
root = Document.objects.create(
|
||||
title="Statement",
|
||||
content="stale text",
|
||||
checksum="ROOT1",
|
||||
pk=1,
|
||||
)
|
||||
Document.objects.create(
|
||||
title="Statement",
|
||||
content="latest version text",
|
||||
checksum="VER1",
|
||||
pk=2,
|
||||
root_document=root,
|
||||
version_index=1,
|
||||
)
|
||||
|
||||
with backend.batch_update() as batch:
|
||||
batch.add_or_update_ids([root.pk])
|
||||
|
||||
assert backend.search_ids("latest", user=None) == [root.pk]
|
||||
assert backend.search_ids("stale", user=None) == []
|
||||
|
||||
def test_reindexes_documents_already_in_the_index(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
) -> None:
|
||||
"""add_or_update_ids must upsert, matching add_or_update's behaviour."""
|
||||
doc = Document.objects.create(
|
||||
title="doc",
|
||||
content="original",
|
||||
checksum="UP1",
|
||||
pk=1,
|
||||
)
|
||||
backend.add_or_update(doc)
|
||||
assert backend.search_ids("original", user=None) == [doc.pk]
|
||||
|
||||
doc.content = "updated"
|
||||
doc.save()
|
||||
|
||||
with backend.batch_update() as batch:
|
||||
batch.add_or_update_ids([doc.pk])
|
||||
|
||||
assert backend.search_ids("original", user=None) == []
|
||||
assert backend.search_ids("updated", user=None) == [doc.pk]
|
||||
|
||||
|
||||
class TestSearch:
|
||||
"""Test search query parsing and matching via search_ids."""
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ from documents.search._schema import needs_rebuild
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
from pytest_django.fixtures import Settings
|
||||
|
||||
|
||||
pytestmark = pytest.mark.search
|
||||
|
||||
@@ -25,7 +26,7 @@ class TestNeedsRebuild:
|
||||
def test_returns_false_when_version_and_language_match(
|
||||
self,
|
||||
index_dir: Path,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
settings.SEARCH_LANGUAGE = "en"
|
||||
(index_dir / ".index_settings.json").write_text(
|
||||
@@ -36,7 +37,7 @@ class TestNeedsRebuild:
|
||||
def test_returns_true_on_schema_version_mismatch(
|
||||
self,
|
||||
index_dir: Path,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
settings.SEARCH_LANGUAGE = None
|
||||
(index_dir / ".index_settings.json").write_text(
|
||||
@@ -47,7 +48,7 @@ class TestNeedsRebuild:
|
||||
def test_returns_true_when_version_is_not_an_integer(
|
||||
self,
|
||||
index_dir: Path,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
settings.SEARCH_LANGUAGE = None
|
||||
(index_dir / ".index_settings.json").write_text(
|
||||
@@ -58,7 +59,7 @@ class TestNeedsRebuild:
|
||||
def test_returns_true_when_language_key_missing(
|
||||
self,
|
||||
index_dir: Path,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
settings.SEARCH_LANGUAGE = "en"
|
||||
(index_dir / ".index_settings.json").write_text(
|
||||
@@ -69,7 +70,7 @@ class TestNeedsRebuild:
|
||||
def test_returns_true_when_language_differs(
|
||||
self,
|
||||
index_dir: Path,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
settings.SEARCH_LANGUAGE = "de"
|
||||
(index_dir / ".index_settings.json").write_text(
|
||||
|
||||
@@ -35,7 +35,8 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
||||
THEN:
|
||||
- Existing config
|
||||
"""
|
||||
response = self.client.get(self.ENDPOINT, format="json")
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
response = self.client.get(self.ENDPOINT, format="json")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
@@ -45,6 +46,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
||||
response.data[0],
|
||||
{
|
||||
"id": 1,
|
||||
"externally_configured_variables": [],
|
||||
"output_type": None,
|
||||
"pages": None,
|
||||
"language": None,
|
||||
@@ -76,7 +78,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
||||
"remote_ocr_api_key": None,
|
||||
"remote_ocr_endpoint": None,
|
||||
"remote_ocr_mode": None,
|
||||
"ai_enabled": False,
|
||||
"ai_enabled": None,
|
||||
"llm_embedding_backend": None,
|
||||
"llm_embedding_model": None,
|
||||
"llm_embedding_endpoint": None,
|
||||
@@ -91,6 +93,31 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
||||
},
|
||||
)
|
||||
|
||||
def test_api_get_config_reports_external_configuration_without_values(self) -> None:
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"PAPERLESS_OCR_LANGUAGE": "eng",
|
||||
"PAPERLESS_REMOTE_OCR_API_KEY": "secret-value",
|
||||
"PAPERLESS_FUTURE_SETTING": "future-value",
|
||||
"UNRELATED_SETTING": "unrelated-value",
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
response = self.client.get(self.ENDPOINT, format="json")
|
||||
|
||||
self.assertCountEqual(
|
||||
response.data[0]["externally_configured_variables"],
|
||||
[
|
||||
"PAPERLESS_FUTURE_SETTING",
|
||||
"PAPERLESS_OCR_LANGUAGE",
|
||||
"PAPERLESS_REMOTE_OCR_API_KEY",
|
||||
],
|
||||
)
|
||||
self.assertNotContains(response, "secret-value")
|
||||
self.assertNotContains(response, "future-value")
|
||||
self.assertNotContains(response, "UNRELATED_SETTING")
|
||||
|
||||
def test_api_get_ui_settings_with_config(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -949,6 +976,26 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
||||
)
|
||||
mock_update.assert_called_once()
|
||||
|
||||
@override_settings(AI_ENABLED=True, LLM_EMBEDDING_BACKEND=None)
|
||||
def test_external_ai_setting_triggers_index_update(self) -> None:
|
||||
config = ApplicationConfiguration.objects.first()
|
||||
assert config is not None
|
||||
config.ai_enabled = None
|
||||
config.llm_embedding_backend = None
|
||||
config.save()
|
||||
|
||||
with (
|
||||
patch("documents.tasks.llmindex_index.apply_async") as mock_update,
|
||||
patch("paperless.views.llm_index_exists", return_value=False),
|
||||
):
|
||||
self.client.patch(
|
||||
f"{self.ENDPOINT}1/",
|
||||
json.dumps({"llm_embedding_backend": "openai-like"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
mock_update.assert_called_once()
|
||||
|
||||
def test_update_llm_embedding_chunk_size_triggers_rebuild(self) -> None:
|
||||
config = ApplicationConfiguration.objects.first()
|
||||
assert config is not None
|
||||
|
||||
@@ -717,6 +717,44 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(args[0], [self.doc2.id])
|
||||
self.assertEqual(kwargs["storage_path"], self.sp1.id)
|
||||
|
||||
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
|
||||
def test_api_bulk_edit_with_all_true_resolves_owned_duplicates(self, m) -> None:
|
||||
self.setup_mock(m, "set_storage_path")
|
||||
user = User.objects.create_user(username="duplicate-owner")
|
||||
user.user_permissions.add(
|
||||
Permission.objects.get(codename="change_document"),
|
||||
)
|
||||
first_duplicate = Document.objects.create(
|
||||
checksum="owned-duplicate",
|
||||
title="First duplicate",
|
||||
owner=user,
|
||||
)
|
||||
second_duplicate = Document.objects.create(
|
||||
checksum="owned-duplicate",
|
||||
title="Second duplicate",
|
||||
owner=user,
|
||||
)
|
||||
self.client.force_authenticate(user=user)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/documents/bulk_edit/",
|
||||
json.dumps(
|
||||
{
|
||||
"all": True,
|
||||
"filters": {"has_duplicates": True},
|
||||
"method": "set_storage_path",
|
||||
"parameters": {"storage_path": self.sp1.id},
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
m.assert_called_once()
|
||||
args, kwargs = m.call_args
|
||||
self.assertCountEqual(args[0], [first_duplicate.id, second_duplicate.id])
|
||||
self.assertEqual(kwargs["storage_path"], self.sp1.id)
|
||||
|
||||
@mock.patch("documents.search.get_backend")
|
||||
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
|
||||
def test_api_bulk_edit_with_all_true_resolves_documents_from_search_filters(
|
||||
|
||||
@@ -2,14 +2,12 @@ from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest import TestCase
|
||||
from unittest import mock
|
||||
|
||||
from auditlog.models import LogEntry # type: ignore[import-untyped]
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import FieldError
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase as DjangoTestCase
|
||||
from django.utils import timezone
|
||||
@@ -22,6 +20,7 @@ from documents.filters import TitleContentFilter
|
||||
from documents.models import Document
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.tests.utils import read_streaming_response
|
||||
from documents.versioning import annotate_effective_content
|
||||
from documents.views import DocumentSelectionMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -598,6 +597,7 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(input_doc.root_document_id, root.id)
|
||||
self.assertEqual(input_doc.source, DocumentSource.ApiUpload)
|
||||
self.assertEqual(overrides.version_label, "New Version")
|
||||
self.assertEqual(overrides.owner_id, self.user.id)
|
||||
self.assertEqual(overrides.actor_id, self.user.id)
|
||||
|
||||
def test_update_version_with_version_pk_normalizes_to_root(self) -> None:
|
||||
@@ -891,32 +891,104 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestVersionAwareFilters(TestCase):
|
||||
def test_title_content_filter_falls_back_to_content(self) -> None:
|
||||
queryset = mock.Mock()
|
||||
fallback_queryset = mock.Mock()
|
||||
queryset.filter.side_effect = [FieldError("missing field"), fallback_queryset]
|
||||
class TestVersionAwareFilters(DjangoTestCase):
|
||||
"""
|
||||
The filters annotate effective_content themselves rather than relying on
|
||||
the caller's queryset carrying it, so they stay version-aware on a plain
|
||||
Document queryset (e.g. the bulk-edit "select all matching" path).
|
||||
"""
|
||||
|
||||
result = TitleContentFilter().filter(queryset, " latest ")
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.root = Document.objects.create(
|
||||
title="root",
|
||||
checksum="root",
|
||||
mime_type="application/pdf",
|
||||
content="superseded-content",
|
||||
)
|
||||
Document.objects.create(
|
||||
title="version",
|
||||
checksum="version",
|
||||
mime_type="application/pdf",
|
||||
root_document=self.root,
|
||||
version_index=1,
|
||||
content="latest-content",
|
||||
)
|
||||
self.unversioned = Document.objects.create(
|
||||
title="unversioned",
|
||||
checksum="unversioned",
|
||||
mime_type="application/pdf",
|
||||
content="latest-content",
|
||||
)
|
||||
|
||||
self.assertIs(result, fallback_queryset)
|
||||
self.assertEqual(queryset.filter.call_count, 2)
|
||||
|
||||
def test_effective_content_filter_falls_back_to_content_lookup(self) -> None:
|
||||
queryset = mock.Mock()
|
||||
fallback_queryset = mock.Mock()
|
||||
queryset.filter.side_effect = [FieldError("missing field"), fallback_queryset]
|
||||
|
||||
result = EffectiveContentFilter(lookup_expr="icontains").filter(
|
||||
queryset,
|
||||
def test_title_content_filter_matches_latest_version_content(self) -> None:
|
||||
result = TitleContentFilter().filter(
|
||||
Document.objects.filter(root_document__isnull=True),
|
||||
" latest ",
|
||||
)
|
||||
|
||||
self.assertIs(result, fallback_queryset)
|
||||
first_kwargs = queryset.filter.call_args_list[0].kwargs
|
||||
second_kwargs = queryset.filter.call_args_list[1].kwargs
|
||||
self.assertEqual(first_kwargs, {"effective_content__icontains": "latest"})
|
||||
self.assertEqual(second_kwargs, {"content__icontains": "latest"})
|
||||
self.assertCountEqual(
|
||||
[doc.id for doc in result],
|
||||
[self.root.id, self.unversioned.id],
|
||||
)
|
||||
|
||||
def test_effective_content_filter_matches_latest_version_content(self) -> None:
|
||||
result = EffectiveContentFilter(lookup_expr="icontains").filter(
|
||||
Document.objects.filter(root_document__isnull=True),
|
||||
" latest ",
|
||||
)
|
||||
|
||||
self.assertCountEqual(
|
||||
[doc.id for doc in result],
|
||||
[self.root.id, self.unversioned.id],
|
||||
)
|
||||
|
||||
def test_effective_content_filter_ignores_superseded_content(self) -> None:
|
||||
result = EffectiveContentFilter(lookup_expr="icontains").filter(
|
||||
Document.objects.filter(root_document__isnull=True),
|
||||
"superseded",
|
||||
)
|
||||
|
||||
self.assertEqual(list(result), [])
|
||||
|
||||
def test_filters_reuse_an_existing_annotation(self) -> None:
|
||||
"""
|
||||
Annotating twice under the same alias is an error, so an already
|
||||
annotated queryset (the search path) has to be left alone.
|
||||
"""
|
||||
annotated = annotate_effective_content(
|
||||
Document.objects.filter(root_document__isnull=True),
|
||||
)
|
||||
self.assertIs(annotate_effective_content(annotated), annotated)
|
||||
|
||||
result = EffectiveContentFilter(lookup_expr="icontains").filter(
|
||||
annotated,
|
||||
"latest",
|
||||
)
|
||||
|
||||
self.assertCountEqual(
|
||||
[doc.id for doc in result],
|
||||
[self.root.id, self.unversioned.id],
|
||||
)
|
||||
|
||||
def test_bulk_selection_does_not_match_superseded_content(self) -> None:
|
||||
"""
|
||||
Bulk edit's "select all matching" builds its own queryset, so before
|
||||
the filters annotated for themselves it matched the root document's
|
||||
superseded content -- selecting documents the list view, filtered by
|
||||
the same term, does not show.
|
||||
"""
|
||||
user = User.objects.create_superuser(username="bulk_selection")
|
||||
|
||||
selected = DocumentSelectionMixin()._resolve_document_ids(
|
||||
user=user,
|
||||
validated_data={
|
||||
"all": True,
|
||||
"filters": {"content__icontains": "superseded"},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(selected, [])
|
||||
|
||||
def test_effective_content_filter_returns_input_for_empty_values(self) -> None:
|
||||
queryset = mock.Mock()
|
||||
|
||||
@@ -981,6 +981,128 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0]["id"], doc.id)
|
||||
|
||||
def test_has_duplicates_filter(self) -> None:
|
||||
original_match = Document.objects.create(
|
||||
title="original match",
|
||||
checksum="same-original",
|
||||
)
|
||||
second_original_match = Document.objects.create(
|
||||
title="second original match",
|
||||
checksum="same-original",
|
||||
)
|
||||
archive_match = Document.objects.create(
|
||||
title="archive match",
|
||||
checksum="archive-source",
|
||||
archive_checksum="same-archive",
|
||||
)
|
||||
original_to_archive_match = Document.objects.create(
|
||||
title="original to archive match",
|
||||
checksum="same-archive",
|
||||
)
|
||||
first_archive_match = Document.objects.create(
|
||||
title="first archive match",
|
||||
checksum="first-archive-source",
|
||||
archive_checksum="same-archive-only",
|
||||
)
|
||||
second_archive_match = Document.objects.create(
|
||||
title="second archive match",
|
||||
checksum="second-archive-source",
|
||||
archive_checksum="same-archive-only",
|
||||
)
|
||||
first_empty_archive = Document.objects.create(
|
||||
title="first empty archive",
|
||||
checksum="first-empty-archive",
|
||||
archive_checksum="",
|
||||
)
|
||||
second_empty_archive = Document.objects.create(
|
||||
title="second empty archive",
|
||||
checksum="second-empty-archive",
|
||||
archive_checksum="",
|
||||
)
|
||||
unique = Document.objects.create(title="unique", checksum="unique")
|
||||
version_root = Document.objects.create(
|
||||
title="version root",
|
||||
checksum="version-root",
|
||||
)
|
||||
Document.objects.create(
|
||||
title="version",
|
||||
checksum=unique.checksum,
|
||||
root_document=version_root,
|
||||
version_index=1,
|
||||
)
|
||||
trash_match = Document.objects.create(
|
||||
title="trash match",
|
||||
checksum="trash-match",
|
||||
)
|
||||
trashed_duplicate = Document.objects.create(
|
||||
title="trashed duplicate",
|
||||
checksum="trash-match",
|
||||
)
|
||||
trashed_duplicate.delete()
|
||||
|
||||
response = self.client.get("/api/documents/?has_duplicates=true")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertCountEqual(
|
||||
[document["id"] for document in response.data["results"]],
|
||||
[
|
||||
original_match.id,
|
||||
second_original_match.id,
|
||||
archive_match.id,
|
||||
original_to_archive_match.id,
|
||||
first_archive_match.id,
|
||||
second_archive_match.id,
|
||||
trash_match.id,
|
||||
],
|
||||
)
|
||||
|
||||
response = self.client.get("/api/documents/?has_duplicates=false")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertCountEqual(
|
||||
[document["id"] for document in response.data["results"]],
|
||||
[
|
||||
unique.id,
|
||||
version_root.id,
|
||||
first_empty_archive.id,
|
||||
second_empty_archive.id,
|
||||
],
|
||||
)
|
||||
|
||||
response = self.client.get(f"/api/documents/{first_empty_archive.id}/")
|
||||
self.assertEqual(response.data["duplicate_documents"], [])
|
||||
|
||||
def test_has_duplicates_filter_respects_document_permissions(self) -> None:
|
||||
owner = User.objects.create_user(username="duplicate-owner")
|
||||
requester = User.objects.create_user(username="duplicate-requester")
|
||||
requester.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
visible_document = Document.objects.create(
|
||||
title="visible document",
|
||||
checksum="permission-match",
|
||||
owner=requester,
|
||||
)
|
||||
hidden_duplicate = Document.objects.create(
|
||||
title="hidden duplicate",
|
||||
checksum="permission-match",
|
||||
owner=owner,
|
||||
)
|
||||
self.client.force_authenticate(user=requester)
|
||||
|
||||
response = self.client.get("/api/documents/?has_duplicates=true")
|
||||
self.assertNotIn(
|
||||
visible_document.id,
|
||||
[document["id"] for document in response.data["results"]],
|
||||
)
|
||||
|
||||
assign_perm("view_document", requester, hidden_duplicate)
|
||||
response = self.client.get("/api/documents/?has_duplicates=true")
|
||||
self.assertIn(
|
||||
visible_document.id,
|
||||
[document["id"] for document in response.data["results"]],
|
||||
)
|
||||
|
||||
def test_custom_fields_icontains_filter_no_duplicates(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
@@ -1947,6 +1947,29 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(len(response.data["documents"]), 1)
|
||||
self.assertEqual(response.data["documents"][0]["id"], title_match.id)
|
||||
|
||||
def test_global_search_returns_latest_version_content(self) -> None:
|
||||
root = Document.objects.create(
|
||||
title="bank statement",
|
||||
content="superseded content",
|
||||
checksum="GSV1",
|
||||
pk=23,
|
||||
)
|
||||
Document.objects.create(
|
||||
title="bank statement v2",
|
||||
content="latest content",
|
||||
checksum="GSV2",
|
||||
pk=24,
|
||||
root_document=root,
|
||||
version_index=1,
|
||||
)
|
||||
|
||||
self.client.force_authenticate(self.user)
|
||||
|
||||
response = self.client.get("/api/search/?query=bank&db_only=true")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
returned = {doc["id"]: doc["content"] for doc in response.data["documents"]}
|
||||
self.assertEqual(returned.get(root.id), "latest content")
|
||||
|
||||
def test_global_search_filters_owned_mail_objects(self) -> None:
|
||||
user1 = User.objects.create_user("mail-search-user")
|
||||
user2 = User.objects.create_user("other-mail-search-user")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import pytest
|
||||
from django.core.checks import Error
|
||||
from django.core.checks import Warning
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
from pytest_django.fixtures import Settings
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from documents.checks import filename_format_check
|
||||
@@ -47,7 +47,7 @@ class TestFilenameFormatCheck:
|
||||
)
|
||||
def test_warns_on_old_style_format(
|
||||
self,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
filename_format: str,
|
||||
expected_hint: str,
|
||||
) -> None:
|
||||
|
||||
@@ -43,7 +43,7 @@ if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
from pytest_django.fixtures import Settings
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
|
||||
@@ -605,7 +605,7 @@ class TestCommandValidation:
|
||||
|
||||
def test_raises_for_missing_consumption_dir(
|
||||
self,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
"""Test command raises error when directory is not provided."""
|
||||
settings.CONSUMPTION_DIR = None
|
||||
@@ -639,7 +639,7 @@ class TestCommandOneshot:
|
||||
scratch_dir: Path,
|
||||
sample_pdf: Path,
|
||||
mock_consume_file_delay: MagicMock,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
"""Test oneshot mode processes existing files."""
|
||||
target = consumption_dir / "document.pdf"
|
||||
@@ -659,7 +659,7 @@ class TestCommandOneshot:
|
||||
scratch_dir: Path,
|
||||
sample_pdf: Path,
|
||||
mock_consume_file_delay: MagicMock,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
"""Test oneshot mode processes files recursively."""
|
||||
subdir = consumption_dir / "subdir"
|
||||
@@ -681,7 +681,7 @@ class TestCommandOneshot:
|
||||
consumption_dir: Path,
|
||||
scratch_dir: Path,
|
||||
mock_consume_file_delay: MagicMock,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
"""Test oneshot mode ignores unsupported file extensions."""
|
||||
target = consumption_dir / "document.xyz"
|
||||
@@ -1256,7 +1256,7 @@ class TestProcessExistingFilesQueued:
|
||||
consumption_dir: Path,
|
||||
sample_pdf: Path,
|
||||
mock_consume_file_delay: MagicMock,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
"""The set returned seeds the rescan's queued set, avoiding re-queue."""
|
||||
target = consumption_dir / "document.pdf"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from collections.abc import Generator
|
||||
|
||||
import pytest
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
from pytest_django.fixtures import Settings
|
||||
|
||||
from documents.parsers import get_default_file_extension
|
||||
from documents.parsers import get_supported_file_extensions
|
||||
@@ -14,7 +14,7 @@ from paperless.parsers.tika import TikaDocumentParser
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def _tika_registry(settings: SettingsWrapper) -> Generator[None, None, None]:
|
||||
def _tika_registry(settings: Settings) -> Generator[None, None, None]:
|
||||
"""
|
||||
Rebuild the parser registry with Tika enabled for the duration of the
|
||||
test, then reset on exit so other tests see the default (Tika-disabled)
|
||||
|
||||
@@ -23,6 +23,7 @@ from guardian.shortcuts import get_users_with_perms
|
||||
from httpx import ConnectError
|
||||
from httpx import HTTPError
|
||||
from httpx import HTTPStatusError
|
||||
from pytest_django.fixtures import Settings
|
||||
from pytest_httpx import HTTPXMock
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework.test import APITestCase
|
||||
@@ -38,7 +39,6 @@ from paperless_ai.exceptions import LLMTimeoutError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.db.models import QuerySet
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
|
||||
from documents import tasks
|
||||
from documents.data_models import ConsumableDocument
|
||||
@@ -5356,7 +5356,7 @@ class TestDateWorkflowLocalization(
|
||||
def test_document_consumption_workflow_localization(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
title_template: str,
|
||||
expected_title: str,
|
||||
) -> None:
|
||||
@@ -5711,6 +5711,39 @@ class TestApplyAISuggestionsWorkflowAction(
|
||||
self.assertEqual(changed, [])
|
||||
self.assertIn("AI is not enabled", "".join(cm.output))
|
||||
|
||||
def test_document_without_content_does_nothing(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document whose OCR content is empty or whitespace-only
|
||||
WHEN:
|
||||
- AI suggestions are applied by a workflow
|
||||
THEN:
|
||||
- The classifier is not called and the document is left unchanged
|
||||
"""
|
||||
action = self.make_action(ai_overwrite_existing=True)
|
||||
|
||||
for content in ("", " \n\t"):
|
||||
with self.subTest(content=content):
|
||||
self.doc.content = content
|
||||
self.doc.save(update_fields=["content"])
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"documents.workflows.ai.get_ai_document_classification",
|
||||
) as get_classification,
|
||||
self.assertLogs(
|
||||
"paperless.workflows.ai",
|
||||
level="WARNING",
|
||||
) as cm,
|
||||
):
|
||||
changed = apply_ai_suggestions_to_document(action, self.doc)
|
||||
|
||||
self.assertEqual(changed, [])
|
||||
get_classification.assert_not_called()
|
||||
self.assertIn("has no content", "".join(cm.output))
|
||||
self.doc.refresh_from_db()
|
||||
self.assertEqual(self.doc.title, "original.pdf")
|
||||
|
||||
def test_invalid_configuration_leaves_document_untouched(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
@@ -27,10 +27,13 @@ def versions_newest_first(documents: QuerySet[Document]) -> QuerySet[Document]:
|
||||
|
||||
def annotate_effective_content(documents: QuerySet[Document]) -> QuerySet[Document]:
|
||||
"""
|
||||
Annotates documents with the content of their newest version, falling back
|
||||
to their own, so get_effective_content() can answer from the row rather
|
||||
than querying for the versions of each document
|
||||
Annotates documents with the content of their newest version unless the
|
||||
queryset already carries the annotation, falling back to their own, so
|
||||
get_effective_content() can answer from the row rather than querying for
|
||||
the versions of each document.
|
||||
"""
|
||||
if "effective_content" in documents.query.annotations:
|
||||
return documents
|
||||
return documents.annotate(
|
||||
effective_content=Coalesce(
|
||||
Subquery(
|
||||
|
||||
@@ -232,6 +232,7 @@ from documents.tasks import train_classifier
|
||||
from documents.tasks import update_document_parent_tags
|
||||
from documents.utils import get_boolean
|
||||
from documents.versioning import VersionResolutionError
|
||||
from documents.versioning import annotate_effective_content
|
||||
from documents.versioning import get_latest_version_for_root
|
||||
from documents.versioning import get_request_version_param
|
||||
from documents.versioning import get_root_document
|
||||
@@ -2091,6 +2092,7 @@ class DocumentViewSet(
|
||||
if version_label:
|
||||
overrides.version_label = version_label.strip()
|
||||
if request.user is not None:
|
||||
overrides.owner_id = request.user.id
|
||||
overrides.actor_id = request.user.id
|
||||
|
||||
async_task = consume_file.apply_async(
|
||||
@@ -2815,6 +2817,7 @@ class DocumentSelectionMixin:
|
||||
filtered_documents = DocumentFilterSet(
|
||||
data=orm_filters,
|
||||
queryset=permitted_documents,
|
||||
user=user,
|
||||
).qs.distinct()
|
||||
# tantivy-filtered docs (if search params provided)
|
||||
search_filtered_ids = self._get_search_document_ids(
|
||||
@@ -3630,8 +3633,13 @@ class GlobalSearchView(PassUserMixin):
|
||||
OBJECT_LIMIT = 3
|
||||
docs = []
|
||||
if request.user.has_perm("documents.view_document"):
|
||||
all_docs = Document.objects.filter(
|
||||
id__in=permitted_document_ids(request.user),
|
||||
# Never more than OBJECT_LIMIT rows come back here, so annotating
|
||||
# is cheap -- and without it these results show the root
|
||||
# document's superseded content.
|
||||
all_docs = annotate_effective_content(
|
||||
Document.objects.filter(
|
||||
id__in=permitted_document_ids(request.user),
|
||||
),
|
||||
)
|
||||
if db_only:
|
||||
docs = all_docs.filter(title__icontains=query)[:OBJECT_LIMIT]
|
||||
@@ -5467,8 +5475,7 @@ class TrashView(ListModelMixin, PassUserMixin):
|
||||
from documents.search import get_backend
|
||||
|
||||
with get_backend().batch_update() as batch:
|
||||
for doc in restored:
|
||||
batch.add_or_update(doc)
|
||||
batch.add_or_update_ids([doc.pk for doc in restored])
|
||||
elif action == "empty":
|
||||
if doc_ids is None:
|
||||
doc_ids = [doc.id for doc in docs]
|
||||
|
||||
@@ -138,6 +138,16 @@ def apply_ai_suggestions_to_document(
|
||||
)
|
||||
return []
|
||||
|
||||
if not document.content.strip():
|
||||
logger.warning(
|
||||
"Document %s has no content, skipping AI suggestions for workflow "
|
||||
"action %s",
|
||||
document.pk,
|
||||
action.pk,
|
||||
extra={"group": logging_group},
|
||||
)
|
||||
return []
|
||||
|
||||
# Workflows run without a user, so we use the document owner
|
||||
owner = document.owner
|
||||
|
||||
|
||||
@@ -133,21 +133,27 @@ class BarcodeConfig(BaseConfig):
|
||||
app_config = self._get_config_instance()
|
||||
|
||||
self.barcodes_enabled = (
|
||||
app_config.barcodes_enabled or settings.CONSUMER_ENABLE_BARCODES
|
||||
app_config.barcodes_enabled
|
||||
if app_config.barcodes_enabled is not None
|
||||
else settings.CONSUMER_ENABLE_BARCODES
|
||||
)
|
||||
self.barcode_enable_tiff_support = (
|
||||
app_config.barcode_enable_tiff_support
|
||||
or settings.CONSUMER_BARCODE_TIFF_SUPPORT
|
||||
if app_config.barcode_enable_tiff_support is not None
|
||||
else settings.CONSUMER_BARCODE_TIFF_SUPPORT
|
||||
)
|
||||
self.barcode_string = (
|
||||
app_config.barcode_string or settings.CONSUMER_BARCODE_STRING
|
||||
)
|
||||
self.barcode_retain_split_pages = (
|
||||
app_config.barcode_retain_split_pages
|
||||
or settings.CONSUMER_BARCODE_RETAIN_SPLIT_PAGES
|
||||
if app_config.barcode_retain_split_pages is not None
|
||||
else settings.CONSUMER_BARCODE_RETAIN_SPLIT_PAGES
|
||||
)
|
||||
self.barcode_enable_asn = (
|
||||
app_config.barcode_enable_asn or settings.CONSUMER_ENABLE_ASN_BARCODE
|
||||
app_config.barcode_enable_asn
|
||||
if app_config.barcode_enable_asn is not None
|
||||
else settings.CONSUMER_ENABLE_ASN_BARCODE
|
||||
)
|
||||
self.barcode_asn_prefix = (
|
||||
app_config.barcode_asn_prefix or settings.CONSUMER_ASN_BARCODE_PREFIX
|
||||
@@ -160,13 +166,17 @@ class BarcodeConfig(BaseConfig):
|
||||
app_config.barcode_max_pages or settings.CONSUMER_BARCODE_MAX_PAGES
|
||||
)
|
||||
self.barcode_enable_tag = (
|
||||
app_config.barcode_enable_tag or settings.CONSUMER_ENABLE_TAG_BARCODE
|
||||
app_config.barcode_enable_tag
|
||||
if app_config.barcode_enable_tag is not None
|
||||
else settings.CONSUMER_ENABLE_TAG_BARCODE
|
||||
)
|
||||
self.barcode_tag_mapping = (
|
||||
app_config.barcode_tag_mapping or settings.CONSUMER_TAG_BARCODE_MAPPING
|
||||
)
|
||||
self.barcode_tag_split = (
|
||||
app_config.barcode_tag_split or settings.CONSUMER_TAG_BARCODE_SPLIT
|
||||
app_config.barcode_tag_split
|
||||
if app_config.barcode_tag_split is not None
|
||||
else settings.CONSUMER_TAG_BARCODE_SPLIT
|
||||
)
|
||||
|
||||
|
||||
@@ -248,7 +258,11 @@ class AIConfig(BaseConfig):
|
||||
def __post_init__(self) -> None:
|
||||
app_config = self._get_config_instance()
|
||||
|
||||
self.ai_enabled = app_config.ai_enabled or settings.AI_ENABLED
|
||||
self.ai_enabled = (
|
||||
app_config.ai_enabled
|
||||
if app_config.ai_enabled is not None
|
||||
else settings.AI_ENABLED
|
||||
)
|
||||
self.llm_embedding_backend = (
|
||||
app_config.llm_embedding_backend or settings.LLM_EMBEDDING_BACKEND
|
||||
)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
def normalize_ai_enabled(apps, schema_editor):
|
||||
application_configuration = apps.get_model(
|
||||
"paperless",
|
||||
"ApplicationConfiguration",
|
||||
)
|
||||
application_configuration.objects.filter(ai_enabled=False).update(ai_enabled=None)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("paperless", "0015_applicationconfiguration_remote_ocr_mode"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="applicationconfiguration",
|
||||
name="ai_enabled",
|
||||
field=models.BooleanField(
|
||||
null=True,
|
||||
verbose_name="Enables AI features",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(normalize_ai_enabled, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -348,7 +348,6 @@ class ApplicationConfiguration(AbstractSingletonModel):
|
||||
ai_enabled = models.BooleanField(
|
||||
verbose_name=_("Enables AI features"),
|
||||
null=True,
|
||||
default=False,
|
||||
)
|
||||
|
||||
llm_embedding_backend = models.CharField(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import os
|
||||
from io import BytesIO
|
||||
|
||||
import magic
|
||||
@@ -212,6 +213,7 @@ class ProfileSerializer(PasswordValidationMixin, serializers.ModelSerializer[Use
|
||||
class ApplicationConfigurationSerializer(
|
||||
serializers.ModelSerializer[ApplicationConfiguration],
|
||||
):
|
||||
externally_configured_variables = serializers.SerializerMethodField()
|
||||
user_args = serializers.JSONField(binary=True, allow_null=True)
|
||||
barcode_tag_mapping = serializers.JSONField(binary=True, allow_null=True)
|
||||
llm_api_key = ObfuscatedPasswordField(
|
||||
@@ -227,6 +229,12 @@ class ApplicationConfigurationSerializer(
|
||||
|
||||
OBFUSCATED_FIELDS = ("llm_api_key", "remote_ocr_api_key")
|
||||
|
||||
def get_externally_configured_variables(
|
||||
self,
|
||||
instance: ApplicationConfiguration,
|
||||
) -> list[str]:
|
||||
return sorted(name for name in os.environ if name.startswith("PAPERLESS_"))
|
||||
|
||||
def run_validation(self, data):
|
||||
# Empty strings treated as None to avoid unexpected behavior
|
||||
if "user_args" in data and data["user_args"] == "":
|
||||
|
||||
@@ -150,6 +150,7 @@ INSTALLED_APPS = [
|
||||
"drf_spectacular",
|
||||
"drf_spectacular_sidecar",
|
||||
"treenode",
|
||||
"paperless_benchmark.apps.PaperlessBenchmarkConfig",
|
||||
*env_apps,
|
||||
]
|
||||
|
||||
@@ -701,6 +702,9 @@ CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
|
||||
CELERY_BROKER_TRANSPORT_OPTIONS = {
|
||||
"global_keyprefix": _REDIS_KEY_PREFIX,
|
||||
}
|
||||
CELERY_RESULT_BACKEND_TRANSPORT_OPTIONS = {
|
||||
"global_keyprefix": _REDIS_KEY_PREFIX,
|
||||
}
|
||||
|
||||
CELERY_TASK_TRACK_STARTED = True
|
||||
CELERY_TASK_TIME_LIMIT: Final[int] = get_int_from_env("PAPERLESS_WORKER_TIMEOUT", 1800)
|
||||
|
||||
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 16 KiB |
@@ -24,7 +24,7 @@ if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
from pytest_django.fixtures import Settings
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
#: Type for the ``make_tesseract_parser`` fixture factory.
|
||||
@@ -131,9 +131,9 @@ def empty_remote_ocr_app_config(mocker: MockerFixture) -> MagicMock:
|
||||
|
||||
@pytest.fixture()
|
||||
def azure_settings(
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
empty_remote_ocr_app_config: MagicMock,
|
||||
) -> SettingsWrapper:
|
||||
) -> Settings:
|
||||
"""Configure Django settings for a valid Azure AI OCR engine.
|
||||
|
||||
Sets ``REMOTE_OCR_ENGINE``, ``REMOTE_OCR_API_KEY``, and
|
||||
@@ -142,7 +142,7 @@ def azure_settings(
|
||||
|
||||
Returns
|
||||
-------
|
||||
SettingsWrapper
|
||||
Settings
|
||||
The modified settings object (for chaining further overrides).
|
||||
"""
|
||||
settings.REMOTE_OCR_ENGINE = "azureai"
|
||||
@@ -153,14 +153,14 @@ def azure_settings(
|
||||
|
||||
@pytest.fixture()
|
||||
def no_engine_settings(
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
empty_remote_ocr_app_config: MagicMock,
|
||||
) -> SettingsWrapper:
|
||||
) -> Settings:
|
||||
"""Configure Django settings with no remote engine configured.
|
||||
|
||||
Returns
|
||||
-------
|
||||
SettingsWrapper
|
||||
Settings
|
||||
The modified settings object.
|
||||
"""
|
||||
settings.REMOTE_OCR_ENGINE = None
|
||||
|
||||
@@ -7,7 +7,7 @@ import httpx
|
||||
import pytest
|
||||
from django.test.html import parse_html
|
||||
from django.utils import timezone
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
from pytest_django.fixtures import Settings
|
||||
from pytest_httpx import HTTPXMock
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
@@ -428,7 +428,7 @@ class TestTikaHtmlParse:
|
||||
|
||||
def test_tika_parse_unreachable(
|
||||
self,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
mail_parser: MailDocumentParser,
|
||||
) -> None:
|
||||
"""
|
||||
|
||||
@@ -30,7 +30,7 @@ if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
from pytest_django.fixtures import Settings
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ def make_azure_mock() -> Callable[[str], Mock]:
|
||||
|
||||
@pytest.fixture()
|
||||
def azure_client(
|
||||
azure_settings: SettingsWrapper,
|
||||
azure_settings: Settings,
|
||||
make_azure_mock: Callable[[str], Mock],
|
||||
mocker: MockerFixture,
|
||||
) -> Mock:
|
||||
@@ -83,7 +83,7 @@ def azure_client(
|
||||
|
||||
@pytest.fixture()
|
||||
def failing_azure_client(
|
||||
azure_settings: SettingsWrapper,
|
||||
azure_settings: Settings,
|
||||
mocker: MockerFixture,
|
||||
) -> Mock:
|
||||
"""Patch the Azure DI client to raise RuntimeError on every call.
|
||||
@@ -199,7 +199,7 @@ class TestRemoteParserScore:
|
||||
|
||||
def test_score_returns_none_when_api_key_missing(
|
||||
self,
|
||||
no_engine_settings: SettingsWrapper,
|
||||
no_engine_settings: Settings,
|
||||
) -> None:
|
||||
no_engine_settings.REMOTE_OCR_ENGINE = "azureai"
|
||||
no_engine_settings.REMOTE_OCR_ENDPOINT = (
|
||||
@@ -210,7 +210,7 @@ class TestRemoteParserScore:
|
||||
|
||||
def test_score_returns_none_when_endpoint_missing(
|
||||
self,
|
||||
no_engine_settings: SettingsWrapper,
|
||||
no_engine_settings: Settings,
|
||||
) -> None:
|
||||
no_engine_settings.REMOTE_OCR_ENGINE = "azureai"
|
||||
no_engine_settings.REMOTE_OCR_API_KEY = "key"
|
||||
@@ -231,7 +231,7 @@ class TestRemoteParserScore:
|
||||
@pytest.mark.django_db
|
||||
def test_score_uses_app_config_when_env_unset(
|
||||
self,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
"""The app config alone is enough to activate the parser."""
|
||||
settings.REMOTE_OCR_ENGINE = None
|
||||
|
||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from httpx import codes
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
from pytest_django.fixtures import Settings
|
||||
from pytest_httpx import HTTPXMock
|
||||
|
||||
from documents.parsers import ParseError
|
||||
@@ -27,7 +27,7 @@ class TestTikaParserRegistryInterface:
|
||||
|
||||
def test_score_returns_none_when_tika_disabled(
|
||||
self,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
settings.TIKA_ENABLED = False
|
||||
result = TikaDocumentParser.score(
|
||||
@@ -38,7 +38,7 @@ class TestTikaParserRegistryInterface:
|
||||
|
||||
def test_score_returns_int_when_tika_enabled(
|
||||
self,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
settings.TIKA_ENABLED = True
|
||||
result = TikaDocumentParser.score(
|
||||
@@ -49,7 +49,7 @@ class TestTikaParserRegistryInterface:
|
||||
|
||||
def test_score_returns_none_for_unsupported_mime(
|
||||
self,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
settings.TIKA_ENABLED = True
|
||||
result = TikaDocumentParser.score("application/pdf", "doc.pdf")
|
||||
@@ -90,7 +90,7 @@ class TestTikaParser:
|
||||
def test_parse(
|
||||
self,
|
||||
httpx_mock: HTTPXMock,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
tika_parser: TikaDocumentParser,
|
||||
sample_odt_file: Path,
|
||||
) -> None:
|
||||
@@ -179,7 +179,7 @@ class TestTikaParser:
|
||||
setting_value: str,
|
||||
expected_form_value: str,
|
||||
httpx_mock: HTTPXMock,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
sample_odt_file: Path,
|
||||
) -> None:
|
||||
"""
|
||||
|
||||
@@ -10,7 +10,7 @@ from django.contrib.auth.models import User
|
||||
from django.forms import ValidationError
|
||||
from django.http import HttpRequest
|
||||
from django.urls import reverse
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
from pytest_django.fixtures import Settings
|
||||
from pytest_mock import MockerFixture
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
@@ -19,7 +19,7 @@ from paperless.adapter import DrfTokenStrategy
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestCustomAccountAdapter:
|
||||
def test_is_open_for_signup(self, settings: SettingsWrapper) -> None:
|
||||
def test_is_open_for_signup(self, settings: Settings) -> None:
|
||||
adapter = get_adapter()
|
||||
|
||||
# With no accounts, signups should be allowed
|
||||
@@ -33,7 +33,7 @@ class TestCustomAccountAdapter:
|
||||
settings.ACCOUNT_ALLOW_SIGNUPS = False
|
||||
assert not adapter.is_open_for_signup(None)
|
||||
|
||||
def test_is_safe_url(self, settings: SettingsWrapper) -> None:
|
||||
def test_is_safe_url(self, settings: Settings) -> None:
|
||||
request = HttpRequest()
|
||||
request.get_host = lambda: "example.com"
|
||||
with context.request_context(request):
|
||||
@@ -55,7 +55,7 @@ class TestCustomAccountAdapter:
|
||||
|
||||
def test_pre_authenticate(
|
||||
self,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
mocker.patch("allauth.core.internal.ratelimit.consume", return_value=True)
|
||||
@@ -70,7 +70,7 @@ class TestCustomAccountAdapter:
|
||||
with pytest.raises(ValidationError):
|
||||
adapter.pre_authenticate(request)
|
||||
|
||||
def test_get_reset_password_from_key_url(self, settings: SettingsWrapper) -> None:
|
||||
def test_get_reset_password_from_key_url(self, settings: Settings) -> None:
|
||||
request = HttpRequest()
|
||||
request.get_host = lambda: "foo.org"
|
||||
with context.request_context(request):
|
||||
@@ -87,7 +87,7 @@ class TestCustomAccountAdapter:
|
||||
|
||||
def test_save_user_adds_groups(
|
||||
self,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
settings.ACCOUNT_DEFAULT_GROUPS = ["group1", "group2"]
|
||||
@@ -130,7 +130,7 @@ class TestCustomAccountAdapter:
|
||||
|
||||
class TestCustomSocialAccountAdapter:
|
||||
@pytest.mark.django_db
|
||||
def test_is_open_for_signup(self, settings: SettingsWrapper) -> None:
|
||||
def test_is_open_for_signup(self, settings: Settings) -> None:
|
||||
adapter = get_social_adapter()
|
||||
|
||||
settings.SOCIALACCOUNT_ALLOW_SIGNUPS = True
|
||||
@@ -146,7 +146,7 @@ class TestCustomSocialAccountAdapter:
|
||||
@pytest.mark.django_db
|
||||
def test_save_user_adds_groups(
|
||||
self,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
settings.SOCIAL_ACCOUNT_DEFAULT_GROUPS = ["group1", "group2"]
|
||||
|
||||
@@ -8,7 +8,7 @@ import pytest
|
||||
from django.core.checks import ERROR
|
||||
from django.core.checks import Error
|
||||
from django.core.checks import Warning
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
from pytest_django.fixtures import Settings
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from paperless.checks import audit_log_check
|
||||
@@ -31,7 +31,7 @@ class PaperlessTestDirs:
|
||||
# TODO: consolidate with documents/tests/conftest.py PaperlessDirs/paperless_dirs
|
||||
# once the paperless and documents test suites are ready to share fixtures.
|
||||
@pytest.fixture()
|
||||
def directories(tmp_path: Path, settings: SettingsWrapper) -> PaperlessTestDirs:
|
||||
def directories(tmp_path: Path, settings: Settings) -> PaperlessTestDirs:
|
||||
data_dir = tmp_path / "data"
|
||||
media_dir = tmp_path / "media"
|
||||
consumption_dir = tmp_path / "consumption"
|
||||
@@ -54,7 +54,7 @@ class TestChecks:
|
||||
def test_binaries(self) -> None:
|
||||
assert binaries_check(None) == []
|
||||
|
||||
def test_binaries_fail(self, settings: SettingsWrapper) -> None:
|
||||
def test_binaries_fail(self, settings: Settings) -> None:
|
||||
settings.CONVERT_BINARY = "uuuhh"
|
||||
assert len(binaries_check(None)) == 1
|
||||
|
||||
@@ -62,7 +62,7 @@ class TestChecks:
|
||||
def test_paths_check(self) -> None:
|
||||
assert paths_check(None) == []
|
||||
|
||||
def test_paths_check_dont_exist(self, settings: SettingsWrapper) -> None:
|
||||
def test_paths_check_dont_exist(self, settings: Settings) -> None:
|
||||
settings.MEDIA_ROOT = Path("uuh")
|
||||
settings.DATA_DIR = Path("whatever")
|
||||
settings.CONSUMPTION_DIR = Path("idontcare")
|
||||
@@ -89,11 +89,11 @@ class TestChecks:
|
||||
for msg in msgs:
|
||||
assert msg.msg.endswith("is not writeable")
|
||||
|
||||
def test_debug_disabled(self, settings: SettingsWrapper) -> None:
|
||||
def test_debug_disabled(self, settings: Settings) -> None:
|
||||
settings.DEBUG = False
|
||||
assert debug_mode_check(None) == []
|
||||
|
||||
def test_debug_enabled(self, settings: SettingsWrapper) -> None:
|
||||
def test_debug_enabled(self, settings: Settings) -> None:
|
||||
settings.DEBUG = True
|
||||
assert len(debug_mode_check(None)) == 1
|
||||
|
||||
@@ -150,7 +150,7 @@ class TestOcrSettingsChecks:
|
||||
)
|
||||
def test_invalid_setting_produces_one_error(
|
||||
self,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
setting: str,
|
||||
value: str,
|
||||
expected_msg: str,
|
||||
@@ -173,7 +173,7 @@ class TestOcrSettingsChecks:
|
||||
|
||||
|
||||
class TestTimezoneSettingsChecks:
|
||||
def test_invalid_timezone(self, settings: SettingsWrapper) -> None:
|
||||
def test_invalid_timezone(self, settings: Settings) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Default settings
|
||||
@@ -192,7 +192,7 @@ class TestTimezoneSettingsChecks:
|
||||
|
||||
|
||||
class TestEmailCertSettingsChecks:
|
||||
def test_not_valid_file(self, settings: SettingsWrapper) -> None:
|
||||
def test_not_valid_file(self, settings: Settings) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Default settings
|
||||
@@ -215,7 +215,7 @@ class TestEmailCertSettingsChecks:
|
||||
class TestAuditLogChecks:
|
||||
def test_was_enabled_once(
|
||||
self,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
@@ -634,7 +634,7 @@ class TestTesseractChecks:
|
||||
def test_default_language(self) -> None:
|
||||
check_default_language_available(None)
|
||||
|
||||
def test_no_language(self, settings: SettingsWrapper) -> None:
|
||||
def test_no_language(self, settings: Settings) -> None:
|
||||
|
||||
settings.OCR_LANGUAGE = ""
|
||||
|
||||
@@ -649,7 +649,7 @@ class TestTesseractChecks:
|
||||
|
||||
def test_invalid_language(
|
||||
self,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
|
||||
@@ -668,7 +668,7 @@ class TestTesseractChecks:
|
||||
|
||||
def test_multi_part_language(
|
||||
self,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
@@ -692,7 +692,7 @@ class TestTesseractChecks:
|
||||
|
||||
def test_multi_part_language_bad_format(
|
||||
self,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
from django.test import TestCase
|
||||
from django.test import override_settings
|
||||
|
||||
from paperless.config import AIConfig
|
||||
from paperless.config import BarcodeConfig
|
||||
from paperless.models import ApplicationConfiguration
|
||||
|
||||
|
||||
class TestBooleanConfigPrecedence(TestCase):
|
||||
@override_settings(CONSUMER_ENABLE_BARCODES=True)
|
||||
def test_database_false_overrides_barcode_environment_setting(self) -> None:
|
||||
config, _ = ApplicationConfiguration.objects.get_or_create()
|
||||
config.barcodes_enabled = False
|
||||
config.save()
|
||||
|
||||
self.assertFalse(BarcodeConfig().barcodes_enabled)
|
||||
|
||||
@override_settings(AI_ENABLED=True)
|
||||
def test_database_false_overrides_ai_environment_setting(self) -> None:
|
||||
config, _ = ApplicationConfiguration.objects.get_or_create()
|
||||
config.ai_enabled = False
|
||||
config.save()
|
||||
|
||||
self.assertFalse(AIConfig().ai_enabled)
|
||||
|
||||
@override_settings(AI_ENABLED=True)
|
||||
def test_null_ai_setting_uses_environment_setting(self) -> None:
|
||||
config, _ = ApplicationConfiguration.objects.get_or_create()
|
||||
config.ai_enabled = None
|
||||
config.save()
|
||||
|
||||
self.assertTrue(AIConfig().ai_enabled)
|
||||
@@ -1,13 +1,13 @@
|
||||
from pathlib import Path
|
||||
|
||||
from django.test import Client
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
from pytest_django.fixtures import Settings
|
||||
|
||||
|
||||
def test_favicon_view(
|
||||
client: Client,
|
||||
tmp_path: Path,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
favicon_path = tmp_path / "paperless" / "img" / "favicon.ico"
|
||||
favicon_path.parent.mkdir(parents=True)
|
||||
@@ -24,7 +24,7 @@ def test_favicon_view(
|
||||
def test_favicon_view_missing_file(
|
||||
client: Client,
|
||||
tmp_path: Path,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
settings.STATIC_ROOT = tmp_path
|
||||
response = client.get("/favicon.ico")
|
||||
|
||||
@@ -443,8 +443,13 @@ class ApplicationConfigurationViewSet(ModelViewSet[ApplicationConfiguration]):
|
||||
new_llm_embedding_backend = (
|
||||
new_instance.llm_embedding_backend or settings.LLM_EMBEDDING_BACKEND
|
||||
)
|
||||
new_ai_enabled = (
|
||||
new_instance.ai_enabled
|
||||
if new_instance.ai_enabled is not None
|
||||
else settings.AI_ENABLED
|
||||
)
|
||||
new_ai_index_enabled = bool(
|
||||
new_instance.ai_enabled and new_llm_embedding_backend,
|
||||
new_ai_enabled and new_llm_embedding_backend,
|
||||
)
|
||||
new_llm_embedding_chunk_size = (
|
||||
new_instance.llm_embedding_chunk_size or settings.LLM_EMBEDDING_CHUNK_SIZE
|
||||
|
||||
@@ -3,11 +3,11 @@ from pathlib import Path
|
||||
import pytest
|
||||
import pytest_mock
|
||||
from llama_index.core.base.embeddings.base import BaseEmbedding
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
from pytest_django.fixtures import Settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_llm_index_dir(tmp_path: Path, settings: SettingsWrapper) -> Path:
|
||||
def temp_llm_index_dir(tmp_path: Path, settings: Settings) -> Path:
|
||||
settings.LLM_INDEX_DIR = tmp_path
|
||||
settings.LLM_INDEX_LOCK = tmp_path / "index.lock"
|
||||
settings.LLM_INDEX_RWLOCK = tmp_path / "llmindex.rwlock.db"
|
||||
|
||||
@@ -8,7 +8,7 @@ import pytest
|
||||
from django.conf import settings
|
||||
from filelock import ReadWriteLock
|
||||
from llama_index.core.schema import TextNode
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
from pytest_django.fixtures import Settings
|
||||
|
||||
from paperless_ai import indexing
|
||||
from paperless_ai.vector_store import PaperlessSqliteVecVectorStore
|
||||
@@ -69,7 +69,7 @@ class TestCompactionLock:
|
||||
def test_compaction_skips_when_a_reader_holds_the_lock(
|
||||
self,
|
||||
temp_llm_index_dir: Path,
|
||||
settings: SettingsWrapper,
|
||||
settings: Settings,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
_seed_bloated_index(temp_llm_index_dir)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class PaperlessBenchmarkConfig(AppConfig):
|
||||
name = "paperless_benchmark"
|
||||
|
||||
verbose_name = _("Paperless benchmark")
|
||||
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.db import connection
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.db.models import QuerySet
|
||||
|
||||
|
||||
def _reset_table_names() -> list[str]:
|
||||
from guardian.models import GroupObjectPermission
|
||||
from guardian.models import UserObjectPermission
|
||||
|
||||
from documents.models import Correspondent
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
|
||||
return [
|
||||
Document.tags.through._meta.db_table,
|
||||
Document._meta.db_table,
|
||||
Tag._meta.db_table,
|
||||
Correspondent._meta.db_table,
|
||||
DocumentType._meta.db_table,
|
||||
StoragePath._meta.db_table,
|
||||
UserObjectPermission._meta.db_table,
|
||||
GroupObjectPermission._meta.db_table,
|
||||
]
|
||||
|
||||
|
||||
def _delete_all_users_and_groups() -> None:
|
||||
# ASSUMPTION: this tool assumes a disposable benchmark database, never
|
||||
# point it at a real install. This deletes EVERY user and group in the
|
||||
# database (not just benchmark-created ones) -- there is no way to
|
||||
# distinguish "real" users from seeded ones, so this is only safe against
|
||||
# a database that exists solely to run this benchmarking tool. The
|
||||
# `benchmark seed --reset` CLI path requires an explicit
|
||||
# `--yes-i-know-this-wipes-the-database` flag before reaching here; do
|
||||
# not remove that guard.
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
get_user_model().objects.all().delete()
|
||||
Group.objects.all().delete()
|
||||
|
||||
|
||||
def _reset_postgresql() -> None:
|
||||
tables = _reset_table_names()
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(f"TRUNCATE TABLE {', '.join(tables)} RESTART IDENTITY CASCADE;")
|
||||
_delete_all_users_and_groups()
|
||||
|
||||
|
||||
def _reset_mariadb() -> None:
|
||||
# MariaDB's TRUNCATE has no CASCADE clause and refuses to truncate a
|
||||
# table referenced by a foreign key while checks are enabled, so
|
||||
# checks are disabled for the duration of the reset.
|
||||
tables = _reset_table_names()
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SET FOREIGN_KEY_CHECKS = 0;")
|
||||
try:
|
||||
for table in tables:
|
||||
cursor.execute(f"TRUNCATE TABLE {table};")
|
||||
finally:
|
||||
cursor.execute("SET FOREIGN_KEY_CHECKS = 1;")
|
||||
_delete_all_users_and_groups()
|
||||
|
||||
|
||||
def _reset_sqlite() -> None:
|
||||
from documents.models import Correspondent
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
|
||||
Document.global_objects.all().delete()
|
||||
Tag.objects.all().delete()
|
||||
Correspondent.objects.all().delete()
|
||||
DocumentType.objects.all().delete()
|
||||
StoragePath.objects.all().delete()
|
||||
_delete_all_users_and_groups()
|
||||
|
||||
|
||||
def reset_benchmark_data() -> None:
|
||||
"""
|
||||
Remove all previously-seeded benchmark data (documents, tags,
|
||||
correspondents, document types, storage paths, guardian permission
|
||||
rows, users, and groups) so a fresh `benchmark seed` run starts
|
||||
from an empty slate. Dispatches per-backend because TRUNCATE syntax
|
||||
and cascade behavior differ across the 3 supported databases.
|
||||
"""
|
||||
if connection.vendor == "postgresql":
|
||||
_reset_postgresql()
|
||||
elif connection.vendor == "mysql":
|
||||
# MariaDB also reports vendor == "mysql" under Django's mysql backend.
|
||||
_reset_mariadb()
|
||||
else:
|
||||
_reset_sqlite()
|
||||
|
||||
|
||||
def capture_explain(queryset: QuerySet) -> str:
|
||||
"""
|
||||
Return the query plan for `queryset` using the current backend's
|
||||
explain facility. PostgreSQL supports `EXPLAIN ANALYZE {sql}` (real
|
||||
execution stats). MariaDB does NOT accept that syntax -- verified
|
||||
against a real MariaDB 12.3 container: `EXPLAIN ANALYZE {sql}` raises a
|
||||
1064 syntax error, while MariaDB's own `ANALYZE {sql}` form (no
|
||||
`EXPLAIN` keyword) works and returns real per-row execution stats
|
||||
(`r_rows`, `r_filtered`, etc. columns) -- this is MariaDB's
|
||||
EXPLAIN-ANALYZE-equivalent, distinct from MySQL 8.0.18+'s
|
||||
`EXPLAIN ANALYZE` syntax, which MariaDB does not implement. SQLite only
|
||||
supports EXPLAIN QUERY PLAN (the chosen plan, not real timing/row
|
||||
counts) -- that output is clearly labeled rather than silently looking
|
||||
equivalent to the other two backends' output.
|
||||
"""
|
||||
sql, params = queryset.query.sql_with_params()
|
||||
with connection.cursor() as cursor:
|
||||
if connection.vendor == "postgresql":
|
||||
cursor.execute(f"EXPLAIN ANALYZE {sql}", params)
|
||||
return "\n".join(str(row[0]) for row in cursor.fetchall())
|
||||
if connection.vendor == "mysql":
|
||||
# MariaDB also reports vendor == "mysql" under Django's mysql
|
||||
# backend. Unlike MySQL 8.0.18+, MariaDB has no `EXPLAIN
|
||||
# ANALYZE` syntax -- its equivalent is `ANALYZE <statement>`.
|
||||
cursor.execute(f"ANALYZE {sql}", params)
|
||||
columns = [c[0] for c in cursor.description]
|
||||
header = " | ".join(columns)
|
||||
rows = "\n".join(
|
||||
" | ".join(str(c) for c in row) for row in cursor.fetchall()
|
||||
)
|
||||
return f"{header}\n{rows}"
|
||||
cursor.execute(f"EXPLAIN QUERY PLAN {sql}", params)
|
||||
rows = "\n".join(" | ".join(str(c) for c in row) for row in cursor.fetchall())
|
||||
return f"(plan only -- no execution stats on SQLite)\n{rows}"
|
||||
@@ -0,0 +1,81 @@
|
||||
# src/paperless_benchmark/endpoints.py
|
||||
from __future__ import annotations
|
||||
|
||||
import statistics
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.contrib.auth.models import User
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
ENDPOINTS: tuple[tuple[str, str], ...] = (
|
||||
("documents_default", "/api/documents/"),
|
||||
("documents_page50", "/api/documents/?page_size=50"),
|
||||
("tags_all", "/api/tags/?page_size=100000"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EndpointTiming:
|
||||
user_label: str
|
||||
endpoint_name: str
|
||||
query_count: int
|
||||
min_ms: float
|
||||
median_ms: float
|
||||
max_ms: float
|
||||
|
||||
|
||||
def _timed_requests(client: APIClient, url: str, n: int) -> list[float]:
|
||||
times = []
|
||||
for _ in range(n):
|
||||
t0 = time.perf_counter()
|
||||
resp = client.get(url)
|
||||
t1 = time.perf_counter()
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(
|
||||
f"GET {url} -> {resp.status_code}: {resp.content[:300]!r}",
|
||||
)
|
||||
times.append(t1 - t0)
|
||||
return times
|
||||
|
||||
|
||||
def _query_count(client: APIClient, url: str) -> int:
|
||||
from django.db import connection
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
resp = client.get(url)
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"GET {url} -> {resp.status_code}: {resp.content[:300]!r}")
|
||||
return len(ctx.captured_queries)
|
||||
|
||||
|
||||
def run_endpoint_benchmarks(
|
||||
*,
|
||||
perf_target: User,
|
||||
perf_admin: User,
|
||||
repeat: int,
|
||||
) -> list[EndpointTiming]:
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
results: list[EndpointTiming] = []
|
||||
for user_label, user in (("target", perf_target), ("admin", perf_admin)):
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=user)
|
||||
for name, url in ENDPOINTS:
|
||||
client.get(url) # warm-up request, not counted
|
||||
qcount = _query_count(client, url)
|
||||
times_ms = [t * 1000 for t in _timed_requests(client, url, repeat)]
|
||||
results.append(
|
||||
EndpointTiming(
|
||||
user_label=user_label,
|
||||
endpoint_name=name,
|
||||
query_count=qcount,
|
||||
min_ms=min(times_ms),
|
||||
median_ms=statistics.median(times_ms),
|
||||
max_ms=max(times_ms),
|
||||
),
|
||||
)
|
||||
return results
|
||||
@@ -0,0 +1,56 @@
|
||||
# src/paperless_benchmark/harness.py
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Generic
|
||||
from typing import TypeVar
|
||||
|
||||
from django.db import connection
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProfileResult(Generic[T]):
|
||||
best_seconds: float
|
||||
all_seconds: tuple[float, ...]
|
||||
query_count: int
|
||||
result: T
|
||||
|
||||
|
||||
def run_profile(fn: Callable[[], T], *, repeat: int = 5) -> ProfileResult[T]:
|
||||
"""
|
||||
Call `fn` `repeat` times, capturing wall-clock time for every call and
|
||||
the SQL query count for the final call. Returns the best (minimum)
|
||||
time across all repeats, since the first call(s) can be skewed by
|
||||
connection warm-up or cold caches.
|
||||
"""
|
||||
if repeat < 1:
|
||||
raise ValueError("repeat must be >= 1")
|
||||
|
||||
all_seconds: list[float] = []
|
||||
result: T | None = None
|
||||
query_count = 0
|
||||
for i in range(repeat):
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
start = time.perf_counter()
|
||||
result = fn()
|
||||
all_seconds.append(time.perf_counter() - start)
|
||||
if i == repeat - 1:
|
||||
query_count = len(ctx.captured_queries)
|
||||
# Purely a type-narrowing aid for the type checker: the `repeat < 1`
|
||||
# guard above already turns the one case that could leave `result`
|
||||
# unset into a clear ValueError, so this is unreachable in practice.
|
||||
assert result is not None
|
||||
return ProfileResult(
|
||||
best_seconds=min(all_seconds),
|
||||
all_seconds=tuple(all_seconds),
|
||||
query_count=query_count,
|
||||
result=result,
|
||||
)
|
||||
@@ -0,0 +1,222 @@
|
||||
# src/paperless_benchmark/management/commands/benchmark.py
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.management.base import CommandError
|
||||
from django.core.management.base import CommandParser
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Seed, run, and profile paperless-ngx performance benchmarks."
|
||||
|
||||
def add_arguments(self, parser: CommandParser) -> None:
|
||||
parser.add_argument(
|
||||
"action",
|
||||
choices=["seed", "run", "profile", "list-scenarios"],
|
||||
help="Which benchmark action to perform.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"scenario",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help="Scenario name (required for `profile`; see `list-scenarios`).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tier",
|
||||
choices=["home", "medium", "large"],
|
||||
default="medium",
|
||||
help="Dataset scale tier for `seed` (default: medium).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--reset",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="For `seed`: truncate existing benchmark data first.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--yes-i-know-this-wipes-the-database",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=(
|
||||
"Required alongside --reset: confirms you understand `seed "
|
||||
"--reset` deletes ALL users, ALL groups, and ALL documents/"
|
||||
"tags/correspondents/document types/storage paths in this "
|
||||
"database, not just benchmark-created ones."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--seed",
|
||||
type=int,
|
||||
default=42,
|
||||
help="RNG seed for reproducible datasets (default: 42).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--repeat",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Number of timed repetitions for `run`/`profile` (default: 5).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--label",
|
||||
default="baseline",
|
||||
help="Free-text tag for a `run`, printed and recorded in history only.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--explain",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="For `profile`: also capture and print the query plan.",
|
||||
)
|
||||
|
||||
def handle(self, *args: Any, **options: Any) -> None:
|
||||
action = options["action"]
|
||||
if action == "seed":
|
||||
self._handle_seed(options)
|
||||
elif action == "run":
|
||||
self._handle_run(options)
|
||||
elif action == "profile":
|
||||
self._handle_profile(options)
|
||||
else:
|
||||
self._handle_list_scenarios()
|
||||
|
||||
def _handle_seed(self, options: dict[str, Any]) -> None:
|
||||
from paperless_benchmark.db import reset_benchmark_data
|
||||
from paperless_benchmark.seeding import seed_benchmark_dataset
|
||||
|
||||
if options["reset"]:
|
||||
if not options["yes_i_know_this_wipes_the_database"]:
|
||||
raise CommandError(
|
||||
"--reset requires --yes-i-know-this-wipes-the-database. "
|
||||
"This deletes ALL users, ALL groups, and ALL documents, "
|
||||
"tags, correspondents, document types, and storage paths "
|
||||
"in this database -- not just benchmark-created ones. "
|
||||
"Only run this against a disposable benchmark database, "
|
||||
"never a real install. Re-run with "
|
||||
"--reset --yes-i-know-this-wipes-the-database to proceed.",
|
||||
)
|
||||
self.stdout.write("Resetting existing benchmark data...")
|
||||
reset_benchmark_data()
|
||||
|
||||
data = seed_benchmark_dataset(options["tier"], seed=options["seed"])
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Seeded tier={options['tier']!r}: {data.documents} documents, "
|
||||
f"{len(data.users)} users, {len(data.groups)} groups.",
|
||||
),
|
||||
)
|
||||
|
||||
def _handle_run(self, options: dict[str, Any]) -> None:
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import connection
|
||||
|
||||
from documents.models import Document
|
||||
from paperless_benchmark.endpoints import run_endpoint_benchmarks
|
||||
from paperless_benchmark.results import append_history
|
||||
|
||||
user_model = get_user_model()
|
||||
try:
|
||||
perf_target = user_model.objects.get(username="perf_target")
|
||||
perf_admin = user_model.objects.get(username="perf_admin")
|
||||
except user_model.DoesNotExist as e:
|
||||
raise CommandError(
|
||||
"No benchmark dataset found. Run `manage.py benchmark seed` first.",
|
||||
) from e
|
||||
|
||||
db_vendor = connection.vendor
|
||||
document_count = Document.objects.count()
|
||||
|
||||
results = run_endpoint_benchmarks(
|
||||
perf_target=perf_target,
|
||||
perf_admin=perf_admin,
|
||||
repeat=options["repeat"],
|
||||
)
|
||||
|
||||
self.stdout.write(f"# label={options['label']} repeat={options['repeat']}")
|
||||
self.stdout.write(
|
||||
f"{'user':7s} {'endpoint':20s} {'queries':>8s} "
|
||||
f"{'min_ms':>9s} {'median_ms':>10s} {'max_ms':>9s}",
|
||||
)
|
||||
for r in results:
|
||||
self.stdout.write(
|
||||
f"{r.user_label:7s} {r.endpoint_name:20s} {r.query_count:8d} "
|
||||
f"{r.min_ms:9.1f} {r.median_ms:10.1f} {r.max_ms:9.1f}",
|
||||
)
|
||||
append_history(
|
||||
{
|
||||
"mode": "run",
|
||||
"label": options["label"],
|
||||
"user": r.user_label,
|
||||
"endpoint": r.endpoint_name,
|
||||
"query_count": r.query_count,
|
||||
"min_ms": r.min_ms,
|
||||
"median_ms": r.median_ms,
|
||||
"max_ms": r.max_ms,
|
||||
"db_vendor": db_vendor,
|
||||
"document_count": document_count,
|
||||
},
|
||||
)
|
||||
|
||||
def _handle_profile(self, options: dict[str, Any]) -> None:
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import connection
|
||||
|
||||
from documents.models import Document
|
||||
from paperless_benchmark.db import capture_explain
|
||||
from paperless_benchmark.harness import run_profile
|
||||
from paperless_benchmark.results import append_history
|
||||
from paperless_benchmark.scenarios import get as get_scenario
|
||||
|
||||
if not options["scenario"]:
|
||||
raise CommandError(
|
||||
"`profile` requires a scenario name; see `list-scenarios`.",
|
||||
)
|
||||
|
||||
scenario = get_scenario(options["scenario"])
|
||||
|
||||
user_model = get_user_model()
|
||||
try:
|
||||
perf_target = user_model.objects.get(username="perf_target")
|
||||
except user_model.DoesNotExist as e:
|
||||
raise CommandError(
|
||||
"No benchmark dataset found. Run `manage.py benchmark seed` first.",
|
||||
) from e
|
||||
|
||||
profile = run_profile(
|
||||
lambda: scenario.run(perf_target),
|
||||
repeat=options["repeat"],
|
||||
)
|
||||
self.stdout.write(
|
||||
f"{scenario.name}: best={profile.best_seconds:.4f}s "
|
||||
f"queries={profile.query_count}",
|
||||
)
|
||||
|
||||
if options["explain"]:
|
||||
if scenario.queryset_for_explain is not None:
|
||||
plan = capture_explain(scenario.queryset_for_explain(perf_target))
|
||||
self.stdout.write(plan)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f"--explain was requested but scenario {scenario.name!r} "
|
||||
"does not support it (no queryset_for_explain); skipping.",
|
||||
),
|
||||
)
|
||||
|
||||
append_history(
|
||||
{
|
||||
"mode": "profile",
|
||||
"scenario": scenario.name,
|
||||
"best_seconds": profile.best_seconds,
|
||||
"query_count": profile.query_count,
|
||||
"db_vendor": connection.vendor,
|
||||
"document_count": Document.objects.count(),
|
||||
},
|
||||
)
|
||||
|
||||
def _handle_list_scenarios(self) -> None:
|
||||
from paperless_benchmark.scenarios import all_scenarios
|
||||
|
||||
for scenario in all_scenarios():
|
||||
self.stdout.write(f"{scenario.name}: {scenario.describe}")
|
||||
@@ -0,0 +1,39 @@
|
||||
# src/paperless_benchmark/results.py
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from datetime import UTC
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
RESULTS_DIR = Path(__file__).resolve().parent.parent.parent / "benchmark_results"
|
||||
|
||||
|
||||
def _current_git_ref() -> str:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--short", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return result.stdout.strip() or "unknown"
|
||||
|
||||
|
||||
def append_history(entry: dict[str, Any], *, code_ref: str | None = None) -> None:
|
||||
"""
|
||||
Append one line to benchmark_results/history.jsonl -- a local-only,
|
||||
append-only, cross-session record of every `benchmark run`/`profile`
|
||||
invocation. Unlike a single overwritten snapshot file, this survives
|
||||
across sessions so a benchmarking effort picked back up days later has
|
||||
a full timeline instead of only the most recent result.
|
||||
"""
|
||||
RESULTS_DIR.mkdir(exist_ok=True)
|
||||
record = {
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"code_ref": code_ref or _current_git_ref(),
|
||||
**entry,
|
||||
}
|
||||
with (RESULTS_DIR / "history.jsonl").open("a") as f:
|
||||
f.write(json.dumps(record) + "\n")
|
||||
@@ -0,0 +1,110 @@
|
||||
# src/paperless_benchmark/scenarios.py
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import QuerySet
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Scenario:
|
||||
name: str
|
||||
describe: str
|
||||
run: Callable[[User], Any]
|
||||
queryset_for_explain: Callable[[User], QuerySet] | None = None
|
||||
|
||||
|
||||
_SCENARIOS: dict[str, Scenario] = {}
|
||||
|
||||
|
||||
def register(scenario: Scenario) -> None:
|
||||
_SCENARIOS[scenario.name] = scenario
|
||||
|
||||
|
||||
def get(name: str) -> Scenario:
|
||||
from django.core.management.base import CommandError
|
||||
|
||||
try:
|
||||
return _SCENARIOS[name]
|
||||
except KeyError:
|
||||
available = ", ".join(sorted(_SCENARIOS)) or "(none registered)"
|
||||
raise CommandError(
|
||||
f"Unknown benchmark scenario {name!r}. Available: {available}",
|
||||
) from None
|
||||
|
||||
|
||||
def all_scenarios() -> tuple[Scenario, ...]:
|
||||
return tuple(_SCENARIOS.values())
|
||||
|
||||
|
||||
def _guardian_visibility_query_run(user: User) -> list[int]:
|
||||
from documents.models import Document
|
||||
from documents.permissions import get_objects_for_user_owner_aware
|
||||
|
||||
return list(
|
||||
get_objects_for_user_owner_aware(
|
||||
user,
|
||||
"documents.view_document",
|
||||
Document,
|
||||
).values_list("id", flat=True),
|
||||
)
|
||||
|
||||
|
||||
def _guardian_visibility_query_queryset(user: User) -> QuerySet:
|
||||
from documents.models import Document
|
||||
from documents.permissions import get_objects_for_user_owner_aware
|
||||
|
||||
return get_objects_for_user_owner_aware(user, "documents.view_document", Document)
|
||||
|
||||
|
||||
register(
|
||||
Scenario(
|
||||
name="guardian_visibility_query",
|
||||
describe=(
|
||||
"Document-visibility queryset for a user with mixed owned/shared "
|
||||
"documents -- exercises documents.permissions."
|
||||
"get_objects_for_user_owner_aware's guardian permission join."
|
||||
),
|
||||
run=_guardian_visibility_query_run,
|
||||
queryset_for_explain=_guardian_visibility_query_queryset,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _permitted_document_ids_run(user: User) -> list[int]:
|
||||
from documents.models import Document
|
||||
from documents.permissions import permitted_document_ids
|
||||
|
||||
return list(
|
||||
Document.objects.filter(id__in=permitted_document_ids(user)).values_list(
|
||||
"id",
|
||||
flat=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _permitted_document_ids_queryset(user: User) -> QuerySet:
|
||||
from documents.models import Document
|
||||
from documents.permissions import permitted_document_ids
|
||||
|
||||
return Document.objects.filter(id__in=permitted_document_ids(user))
|
||||
|
||||
|
||||
register(
|
||||
Scenario(
|
||||
name="permitted_document_ids",
|
||||
describe=(
|
||||
"Document-visibility query built from documents.permissions."
|
||||
"permitted_document_ids -- the resolved-ID-set alternative to "
|
||||
"guardian_visibility_query, for side-by-side comparison."
|
||||
),
|
||||
run=_permitted_document_ids_run,
|
||||
queryset_for_explain=_permitted_document_ids_queryset,
|
||||
),
|
||||
)
|
||||