Compare commits

...
Author SHA1 Message Date
stumpylog 1f8cf4cd6e Fix: consolidate and extend unicode NFC normalization for filenames and matching
Consolidates the scattered unicodedata.normalize("NFC", ...) calls introduced
by my earlier filename/path normalization work into a single
documents.utils.normalize_unicode() helper, and closes several gaps where
NFD-normalized filenames could still slip through unnormalized:

- Document.get_public_filename() now normalizes, fixing exported filenames
  built from an NFD title/correspondent name (the default, non-format export
  path was not covered by the earlier fix).
- DocumentViewSet.update_version() now normalizes the uploaded filename,
  matching PostDocumentView's existing behavior.
- ConsumerPlugin normalizes self.filename once at consumption time, covering
  the title fallback and Document.original_filename for every document
  source (consume folder, mail, API, barcode splits).
- Workflow trigger and mail rule filename/path matching (documents/matching.py,
  paperless_mail/mail.py) now normalize the document/attachment side before
  comparing, so an NFD filename matches an NFC-typed filter pattern instead of
  silently failing to match.
- WorkflowTriggerSerializer and MailRuleSerializer normalize filter_filename/
  filter_path and the attachment include/exclude patterns once at write time,
  so the read side isn't re-normalizing an already-canonical value on every
  match.
2026-09-08 16:00:16 -07:00
GitHub Actions b989b74140 Auto translate strings 2026-09-08 15:57:05 +00:00
shamoon 5194f47291 Performance: ensure version-aware content filters on querysets (#13792) 2026-09-08 15:55:45 +00:00
GitHub Actions 714885d7a5 Auto translate strings 2026-09-08 15:32:41 +00:00
Trenton H 73e777a48c Fix: skip nested TagSerializer construction when a tag has no children (#14039)
TagSerializer.get_children() built a full nested TagSerializer(many=True)
for every tag, even when it had zero children, likely the common case for
most tags and maybe even most installs. Constructing a DRF ModelSerializer isn't
free (field introspection, deepcopy of declared fields, i18n lookups
all re-run per instantiation), so this scaled GET /api/tags/ linearly
with tag count in pure Python overhead, unrelated to SQL query count.
2026-09-08 15:30:58 +00:00
GitHub Actions e9141366bb Auto translate strings 2026-09-08 14:40:59 +00:00
shamoon 7813375123 Enhancement (QoL): surface externally-set options in Config UI (#13989) 2026-09-08 14:39:22 +00:00
shamoon 0132c7bd6e Fix pr-bot timing 2026-09-07 22:59:41 -07:00
GitHub Actions 4d5897ec80 Auto translate strings 2026-09-07 22:05:44 +00:00
shamoon f197d09b3e Enhancement: allow disabling auto-suggestions for inbox documents (#13946) 2026-09-07 22:04:13 +00:00
shamoon 937feb1bef Change: skip documents with empty content in apply AI suggestions WF (#13985) 2026-09-07 21:56:37 +00:00
Trenton H f5ff18326d Performance: resolve index-write permissions and effective content in bulk (#13869)
* fix(search): resolve index-write permissions and effective content in bulk

Add WriteBatch.add_or_update_ids() and use it in bulk_update_documents
and trash restore, cutting index writes from ~8 queries per document
to a constant handful per batch

* Always these new ones with xdist, try a better condition
2026-09-07 14:43:13 -07:00
Berk D. Demir d52cc1b609 Fix: Use PAPERLESS_REDIS_PREFIX for Celery result backend keys (#14015)
PR #12741 sets the result backend for Celery to Redis, but forget to carryover
transport option `global_keyprefix`. This resulted keys with prefix
`celery-task-meta-` prefix to be created.

This fix unbreaks strict Redis ACLs that allow a single prefix.
2026-09-07 21:08:34 +00:00
GitHub Actions 3f5f4f3ed4 Auto translate strings 2026-09-07 20:48:12 +00:00
shamoon 9a47b20d2a Enhancement: duplicates filter (#13994) 2026-09-07 20:46:49 +00:00
GitHub Actions 05905287b3 Auto translate strings 2026-09-07 20:29:01 +00:00
shamoon d65de00ca1 Fix: correct setting ai_enabled to false via UI (#13987) 2026-09-07 20:27:33 +00:00
dependabot[bot]andTrenton Holmes f287a4cb8c Chore(deps): Bump the utilities-minor group across 1 directory with 11 updates (#13988)
* Chore(deps): Bump the utilities-minor group across 1 directory with 11 updates

Bumps the utilities-minor group with 11 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [django-guardian](https://github.com/django-guardian/django-guardian) | `3.3.3` | `3.4.0` |
| [django-treenode](https://github.com/fabiocaccamo/django-treenode) | `0.24.0` | `0.25.0` |
| [drf-spectacular-sidecar](https://github.com/tfranzel/drf-spectacular-sidecar) | `2026.7.1` | `2026.8.1` |
| [imap-tools](https://github.com/ikvk/imap_tools) | `1.14.0` | `1.15.0` |
| [ocrmypdf](https://github.com/ocrmypdf/OCRmyPDF) | `17.10.0` | `17.11.0` |
| [prek](https://github.com/j178/prek) | `0.4.11` | `0.5.0` |
| [faker](https://github.com/joke2k/faker) | `40.36.0` | `40.37.0` |
| [pytest-django](https://github.com/pytest-dev/pytest-django) | `4.12.0` | `4.14.0` |
| [pytest-rerunfailures](https://github.com/pytest-dev/pytest-rerunfailures) | `16.4` | `16.6` |
| [time-machine](https://github.com/adamchainz/time-machine) | `3.2.0` | `3.5.0` |
| [types-pygments](https://github.com/python/typeshed) | `2.20.0.20260408` | `2.21.0.20260819` |



Updates `django-guardian` from 3.3.3 to 3.4.0
- [Release notes](https://github.com/django-guardian/django-guardian/releases)
- [Commits](https://github.com/django-guardian/django-guardian/compare/3.3.3...3.4.0)

Updates `django-treenode` from 0.24.0 to 0.25.0
- [Release notes](https://github.com/fabiocaccamo/django-treenode/releases)
- [Changelog](https://github.com/fabiocaccamo/django-treenode/blob/main/CHANGELOG.md)
- [Commits](https://github.com/fabiocaccamo/django-treenode/compare/0.24.0...0.25.0)

Updates `drf-spectacular-sidecar` from 2026.7.1 to 2026.8.1
- [Commits](https://github.com/tfranzel/drf-spectacular-sidecar/compare/2026.7.1...2026.8.1)

Updates `imap-tools` from 1.14.0 to 1.15.0
- [Release notes](https://github.com/ikvk/imap_tools/releases)
- [Changelog](https://github.com/ikvk/imap_tools/blob/master/docs/release_notes.rst)
- [Commits](https://github.com/ikvk/imap_tools/compare/v1.14.0...v1.15.0)

Updates `ocrmypdf` from 17.10.0 to 17.11.0
- [Release notes](https://github.com/ocrmypdf/OCRmyPDF/releases)
- [Commits](https://github.com/ocrmypdf/OCRmyPDF/compare/v17.10.0...v17.11.0)

Updates `prek` from 0.4.11 to 0.5.0
- [Release notes](https://github.com/j178/prek/releases)
- [Changelog](https://github.com/j178/prek/blob/master/CHANGELOG.md)
- [Commits](https://github.com/j178/prek/compare/v0.4.11...v0.5.0)

Updates `faker` from 40.36.0 to 40.37.0
- [Release notes](https://github.com/joke2k/faker/releases)
- [Changelog](https://github.com/joke2k/faker/blob/master/CHANGELOG.md)
- [Commits](https://github.com/joke2k/faker/compare/v40.36.0...v40.37.0)

Updates `pytest-django` from 4.12.0 to 4.14.0
- [Release notes](https://github.com/pytest-dev/pytest-django/releases)
- [Changelog](https://github.com/pytest-dev/pytest-django/blob/main/docs/changelog.rst)
- [Commits](https://github.com/pytest-dev/pytest-django/compare/v4.12.0...v4.14.0)

Updates `pytest-rerunfailures` from 16.4 to 16.6
- [Changelog](https://github.com/pytest-dev/pytest-rerunfailures/blob/master/CHANGES.rst)
- [Commits](https://github.com/pytest-dev/pytest-rerunfailures/compare/16.4...16.6)

Updates `time-machine` from 3.2.0 to 3.5.0
- [Changelog](https://github.com/adamchainz/time-machine/blob/main/docs/changelog.rst)
- [Commits](https://github.com/adamchainz/time-machine/compare/3.2.0...3.5.0)

Updates `types-pygments` from 2.20.0.20260408 to 2.21.0.20260819
- [Commits](https://github.com/python/typeshed/commits)

---
updated-dependencies:
- dependency-name: django-guardian
  dependency-version: 3.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: django-treenode
  dependency-version: 0.25.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: drf-spectacular-sidecar
  dependency-version: 2026.8.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: faker
  dependency-version: 40.37.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: imap-tools
  dependency-version: 1.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: ocrmypdf
  dependency-version: 17.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: prek
  dependency-version: 0.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: pytest-django
  dependency-version: 4.14.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: pytest-rerunfailures
  dependency-version: '16.6'
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: time-machine
  dependency-version: 3.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
- dependency-name: types-pygments
  dependency-version: 2.21.0.20260819
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: utilities-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* Handles pytest-django

* Formatting

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Trenton Holmes <797416+stumpylog@users.noreply.github.com>
2026-09-07 20:13:15 +00:00
GitHub Actions a415d1bf74 Auto translate strings 2026-09-07 15:50:11 +00:00
shamoon 4b1434f876 Fix: catch some frontend failed object retrievals (#14023) 2026-09-07 08:48:34 -07:00
shamoon 43fd109bc6 Refactor issue comment for clarity and conciseness 2026-09-07 01:27:44 -07:00
shamoon ec70e4f423 Fix: more v3 icons cleanup (#14017) 2026-09-06 16:12:45 -07:00
GitHub Actions 340118ad51 Auto translate strings 2026-09-06 22:53:12 +00:00
shamoon 3899e0f0d6 Fix: correct add version actor parity (#14016) 2026-09-06 15:51:53 -07:00
shamoon d648526858 Fix: fix v3 favicon file (#14014) 2026-09-06 15:43:37 -07:00
99 changed files with 2621 additions and 967 deletions
+2 -4
View File
@@ -72,11 +72,9 @@ jobs:
'You are welcome to open a new issue that describes the problem you observed in your own words.' '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. ' + : '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' + '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 ` + `If the problem is still there, please [open a new issue](${newIssue}) using the form. No other action is needed here.\n\n` +
'to get it looked at, and 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 ' + '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 ` + `contributions are a violation of our [Code of Conduct](${codeOfConduct}).`;
`behavior you observed only, without code analysis or suggested fixes. See our [contributing guidelines](${contributing}).`;
await github.rest.issues.createComment({ ...common, body }); await github.rest.issues.createComment({ ...common, body });
await github.rest.issues.addLabels({ ...common, labels: ['ai'] }); await github.rest.issues.addLabels({ ...common, labels: ['ai'] });
+23 -2
View File
@@ -25,6 +25,10 @@ jobs:
pr-bot: pr-bot:
name: Automated PR Bot name: Automated PR Bot
runs-on: ubuntu-latest 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: permissions:
contents: read contents: read
pull-requests: write pull-requests: write
@@ -99,8 +103,25 @@ jobs:
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with: with:
script: | script: |
const pr = context.payload.pull_request; const user = context.payload.pull_request.user.login;
const user = pr.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({ const { data: members } = await github.rest.orgs.listMembers({
org: 'paperless-ngx', org: 'paperless-ngx',
+3 -1
View File
@@ -138,7 +138,9 @@ for suggested generation and embedding models.
With AI enabled, Paperless-ngx can suggest a title, tags, correspondent, document type, 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** 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 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 language can be steered with
[`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE`](configuration.md#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE) [`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE`](configuration.md#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE)
(otherwise it follows the user's UI language). (otherwise it follows the user's UI language).
+4 -1
View File
@@ -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. 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 ## 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. 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. never replace the document's existing tags.
The action works with every trigger **except Consumption Started**, because suggestions are made from 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 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. than as part of the workflow run itself. The document is updated once the suggestions come back.
+7 -7
View File
@@ -32,21 +32,21 @@ dependencies = [
"django-cors-headers~=4.9.0", "django-cors-headers~=4.9.0",
"django-extensions~=4.1", "django-extensions~=4.1",
"django-filter~=25.1", "django-filter~=25.1",
"django-guardian~=3.3.3", "django-guardian>=3.3.3,<3.5",
"django-multiselectfield~=1.0.1", "django-multiselectfield~=1.0.1",
"django-rich~=2.2.0", "django-rich~=2.2.0",
"django-soft-delete~=1.0.18", "django-soft-delete~=1.0.18",
"django-treenode>=0.24", "django-treenode>=0.24",
"djangorestframework~=3.16", "djangorestframework~=3.16",
"drf-spectacular~=0.30", "drf-spectacular~=0.30",
"drf-spectacular-sidecar~=2026.7.1", "drf-spectacular-sidecar>=2026.7.1,<2026.9",
"drf-writable-nested~=0.7.1", "drf-writable-nested~=0.7.1",
"filelock~=3.32.0", "filelock~=3.32.0",
"flower>=2.0.1,<2.2", "flower>=2.0.1,<2.2",
"gotenberg-client[httpx]~=1.0", "gotenberg-client[httpx]~=1.0",
"httpx-oauth~=0.17", "httpx-oauth~=0.17",
"ijson>=3.5.1", "ijson>=3.5.1",
"imap-tools~=1.14.0", "imap-tools>=1.14,<1.16",
"jinja2~=3.1.6", "jinja2~=3.1.6",
"langdetect~=1.0.9", "langdetect~=1.0.9",
"llama-index-core>=0.14.23", "llama-index-core>=0.14.23",
@@ -56,7 +56,7 @@ dependencies = [
"llama-index-llms-ollama>=0.9.1", "llama-index-llms-ollama>=0.9.1",
"llama-index-llms-openai-like>=0.7.1", "llama-index-llms-openai-like>=0.7.1",
"nltk~=3.10.0", "nltk~=3.10.0",
"ocrmypdf>=17.7,<17.11", "ocrmypdf>=17.7,<17.12",
"openai>=2.48", "openai>=2.48",
"pathvalidate~=3.3.1", "pathvalidate~=3.3.1",
"pdf2image~=1.17.0", "pdf2image~=1.17.0",
@@ -103,17 +103,17 @@ docs = [
"zensical>=0.0.51", "zensical>=0.0.51",
] ]
lint = [ lint = [
"prek~=0.4.11", "prek>=0.4.11,<0.6",
"ruff~=0.16.1", "ruff~=0.16.1",
] ]
testing = [ testing = [
"daphne", "daphne",
"factory-boy~=3.3.1", "factory-boy~=3.3.1",
"faker~=40.36.0", "faker>=40.36,<40.38",
"imagehash", "imagehash",
"pytest~=9.1.1", "pytest~=9.1.1",
"pytest-cov~=7.1.0", "pytest-cov~=7.1.0",
"pytest-django~=4.12.0", "pytest-django>=4.12,<4.15",
"pytest-env~=1.7.0", "pytest-env~=1.7.0",
"pytest-httpx", "pytest-httpx",
"pytest-mock~=3.15.1", "pytest-mock~=3.15.1",
+3 -1
View File
@@ -71,8 +71,10 @@
"tsConfig": "tsconfig.app.json", "tsConfig": "tsconfig.app.json",
"localize": true, "localize": true,
"assets": [ "assets": [
"src/favicon.ico",
"src/apple-touch-icon.png", "src/apple-touch-icon.png",
"src/icon-192.png",
"src/icon-512.png",
"src/icon-512-maskable.png",
"src/assets", "src/assets",
"src/manifest.webmanifest", "src/manifest.webmanifest",
{ {
+324 -206
View File
File diff suppressed because it is too large Load Diff
@@ -23,17 +23,30 @@
<div class="col"> <div class="col">
<div class="card bg-light"> <div class="card bg-light">
<div class="card-body"> <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"> <h6 class="mb-0">
{{option.title}} {{option.title}}
</h6> </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"> <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> <i-bs name="info-circle"></i-bs>
</a> </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)) { @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)"> @if (isExternallyConfigured(option.config_key)) {
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container> <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)">
</button> <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>
<div class="mb-n3"> <div class="mb-n3">
@@ -163,6 +163,19 @@ describe('ConfigComponent', () => {
expect(component.configForm.get('barcodes_enabled').value).toBeNull() 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', () => { it('should group options into sections within a category, or not', () => {
const sections = component.getCategorySections(ConfigCategory.OCR) const sections = component.getCategorySections(ConfigCategory.OCR)
expect(sections).toEqual([null, ConfigSection.RemoteOCR]) expect(sections).toEqual([null, ConfigSection.RemoteOCR])
@@ -69,6 +69,7 @@ export class ConfigComponent
public configForm = new FormGroup({}) public configForm = new FormGroup({})
public errors = {} public errors = {}
public externallyConfiguredVariables = new Set<string>()
get optionCategories(): string[] { get optionCategories(): string[] {
return Object.values(ConfigCategory) return Object.values(ConfigCategory)
@@ -152,6 +153,9 @@ export class ConfigComponent
} }
private initialize(config: PaperlessConfig) { private initialize(config: PaperlessConfig) {
this.externallyConfiguredVariables = new Set(
config.externally_configured_variables ?? []
)
if (!this.store) { if (!this.store) {
this.store = new BehaviorSubject(config) this.store = new BehaviorSubject(config)
@@ -162,7 +166,9 @@ export class ConfigComponent
this.configForm.patchValue(state, { emitEvent: false }) 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) this.configForm.patchValue(config)
@@ -227,6 +233,10 @@ export class ConfigComponent
return this.configForm.get(key).value != null return this.configForm.get(key).value != null
} }
public isExternallyConfigured(configKey: string): boolean {
return this.externallyConfiguredVariables.has(configKey)
}
public resetOption(key: string) { public resetOption(key: string) {
this.configForm.get(key).setValue(null) this.configForm.get(key).setValue(null)
} }
@@ -237,6 +237,12 @@
</div> </div>
</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="row">
<div class="col"> <div class="col">
<pngx-input-check i18n-title title="Show document thumbnail during loading" formControlName="documentEditingOverlayThumbnail"></pngx-input-check> <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(toastErrorSpy).toHaveBeenCalled()
expect(storeSpy).toHaveBeenCalled() expect(storeSpy).toHaveBeenCalled()
expect(appearanceSettingsSpy).not.toHaveBeenCalled() expect(appearanceSettingsSpy).not.toHaveBeenCalled()
expect(setSpy).toHaveBeenCalledTimes(32) expect(setSpy).toHaveBeenCalledTimes(33)
// succeed // succeed
storeSpy.mockReturnValueOnce(of(true)) storeSpy.mockReturnValueOnce(of(true))
@@ -168,6 +168,7 @@ export class SettingsComponent
pdfEditorDefaultEditMode: new FormControl(null), pdfEditorDefaultEditMode: new FormControl(null),
documentEditingRemoveInboxTags: new FormControl(null), documentEditingRemoveInboxTags: new FormControl(null),
documentEditingOverlayThumbnail: new FormControl(null), documentEditingOverlayThumbnail: new FormControl(null),
documentEditingAutoSuggest: new FormControl(null),
documentDetailsHiddenFields: new FormControl([]), documentDetailsHiddenFields: new FormControl([]),
searchDbOnly: new FormControl(null), searchDbOnly: new FormControl(null),
searchLink: new FormControl(null), searchLink: new FormControl(null),
@@ -368,6 +369,9 @@ export class SettingsComponent
documentEditingOverlayThumbnail: this.settings.get( documentEditingOverlayThumbnail: this.settings.get(
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL
), ),
documentEditingAutoSuggest: this.settings.get(
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST
),
documentDetailsHiddenFields: this.settings.get( documentDetailsHiddenFields: this.settings.get(
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS
), ),
@@ -565,6 +569,10 @@ export class SettingsComponent
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL, SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL,
this.settingsForm.value.documentEditingOverlayThumbnail this.settingsForm.value.documentEditingOverlayThumbnail
) )
this.settings.set(
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST,
this.settingsForm.value.documentEditingAutoSuggest
)
this.settings.set( this.settings.set(
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS, SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
this.settingsForm.value.documentDetailsHiddenFields this.settingsForm.value.documentDetailsHiddenFields
@@ -4,7 +4,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select' import { NgSelectModule } from '@ng-select/ng-select'
import { of } from 'rxjs' import { of, throwError } from 'rxjs'
import { import {
MailAction, MailAction,
MailMetadataCorrespondentOption, 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 { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { MailAccountService } from 'src/app/services/rest/mail-account.service' import { MailAccountService } from 'src/app/services/rest/mail-account.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { CheckComponent } from '../../input/check/check.component' import { CheckComponent } from '../../input/check/check.component'
import { NumberComponent } from '../../input/number/number.component' import { NumberComponent } from '../../input/number/number.component'
import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component' import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component'
@@ -81,6 +82,41 @@ describe('MailRuleEditDialogComponent', () => {
fixture.detectChanges() 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', () => { it('should support create and edit modes', () => {
component.dialogMode.set(EditDialogMode.CREATE) component.dialogMode.set(EditDialogMode.CREATE)
const createTitleSpy = jest.spyOn(component, 'getCreateTitle') const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
@@ -6,7 +6,7 @@ import {
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
} from '@angular/forms' } 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 { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
import { Correspondent } from 'src/app/data/correspondent' import { Correspondent } from 'src/app/data/correspondent'
import { DocumentType } from 'src/app/data/document-type' 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 { MailRuleService } from 'src/app/services/rest/mail-rule.service'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { CheckComponent } from '../../input/check/check.component' import { CheckComponent } from '../../input/check/check.component'
import { NumberComponent } from '../../input/number/number.component' import { NumberComponent } from '../../input/number/number.component'
import { SelectComponent } from '../../input/select/select.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 accountService = inject(MailAccountService)
private readonly correspondentService = inject(CorrespondentService) private readonly correspondentService = inject(CorrespondentService)
private readonly documentTypeService = inject(DocumentTypeService) private readonly documentTypeService = inject(DocumentTypeService)
private readonly toastService = inject(ToastService)
readonly accounts = toSignal( 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[] } { initialValue: undefined as MailAccount[] }
) )
readonly correspondents = toSignal( 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[] } { initialValue: undefined as Correspondent[] }
) )
readonly documentTypes = toSignal( 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[] } { initialValue: undefined as DocumentType[] }
) )
@@ -81,6 +81,23 @@ describe('UserEditDialogComponent', () => {
fixture.detectChanges() 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', () => { it('should support create and edit modes', () => {
component.dialogMode.set(EditDialogMode.CREATE) component.dialogMode.set(EditDialogMode.CREATE)
const createTitleSpy = jest.spyOn(component, 'getCreateTitle') const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
@@ -6,7 +6,7 @@ import {
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
} from '@angular/forms' } 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 { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
import { Group } from 'src/app/data/group' import { Group } from 'src/app/data/group'
import { User } from 'src/app/data/user' import { User } from 'src/app/data/user'
@@ -42,7 +42,13 @@ export class UserEditDialogComponent
private readonly groupsService = inject(GroupService) private readonly groupsService = inject(GroupService)
readonly groups = toSignal( 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[] } { initialValue: undefined as Group[] }
) )
readonly passwordIsSet = signal(false) readonly passwordIsSet = signal(false)
@@ -11,7 +11,7 @@ import {
} from '@angular/forms' } from '@angular/forms'
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select' import { NgSelectModule } from '@ng-select/ng-select'
import { of } from 'rxjs' import { of, throwError } from 'rxjs'
import { CustomFieldQueriesModel } from 'src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component' 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 { CustomFieldDataType } from 'src/app/data/custom-field'
import { CustomFieldQueryLogicalOperator } from 'src/app/data/custom-field-query' 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 { MailRuleService } from 'src/app/services/rest/mail-rule.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element' import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element'
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component' import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
import { NumberComponent } from '../../input/number/number.component' import { NumberComponent } from '../../input/number/number.component'
@@ -206,6 +207,44 @@ describe('WorkflowEditDialogComponent', () => {
settingsService.set(SETTINGS_KEYS.AI_ENABLED, ai) 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', () => { it('should support create and edit modes, support adding triggers and actions on new workflow', () => {
component.dialogMode.set(EditDialogMode.CREATE) component.dialogMode.set(EditDialogMode.CREATE)
const createTitleSpy = jest.spyOn(component, 'getCreateTitle') const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
@@ -16,7 +16,7 @@ import {
} from '@angular/forms' } from '@angular/forms'
import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap' import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' 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 { Correspondent } from 'src/app/data/correspondent'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { DocumentType } from 'src/app/data/document-type' 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 { UserService } from 'src/app/services/rest/user.service'
import { WorkflowService } from 'src/app/services/rest/workflow.service' import { WorkflowService } from 'src/app/services/rest/workflow.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element' import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element'
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component' import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
import { import {
@@ -512,26 +513,43 @@ export class WorkflowEditDialogComponent
private readonly storagePathService = inject(StoragePathService) private readonly storagePathService = inject(StoragePathService)
private readonly mailRuleService = inject(MailRuleService) private readonly mailRuleService = inject(MailRuleService)
private readonly customFieldsService = inject(CustomFieldsService) private readonly customFieldsService = inject(CustomFieldsService)
private readonly toastService = inject(ToastService)
private relatedObjectLoadErrorShown = false
readonly templates = signal<Workflow[]>(undefined) readonly templates = signal<Workflow[]>(undefined)
readonly correspondents = toSignal( 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[] } { initialValue: undefined as Correspondent[] }
) )
readonly documentTypes = toSignal( 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[] } { initialValue: undefined as DocumentType[] }
) )
readonly storagePaths = toSignal( 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[] } { initialValue: undefined as StoragePath[] }
) )
readonly mailRules = toSignal( 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[] } { initialValue: undefined as MailRule[] }
) )
readonly customFields = toSignal( 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[] } { initialValue: undefined as CustomField[] }
) )
readonly dateCustomFields = computed(() => readonly dateCustomFields = computed(() =>
@@ -545,6 +563,17 @@ export class WorkflowEditDialogComponent
SETTINGS_KEYS.AI_ENABLED 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 expandedItem: number = null
private readonly triggerFilterOptionsMap = new WeakMap< private readonly triggerFilterOptionsMap = new WeakMap<
@@ -7,8 +7,9 @@ import {
ReactiveFormsModule, ReactiveFormsModule,
} from '@angular/forms' } from '@angular/forms'
import { NgSelectModule } from '@ng-select/ng-select' 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 { GroupService } from 'src/app/services/rest/group.service'
import { ToastService } from 'src/app/services/toast.service'
import { PermissionsGroupComponent } from './permissions-group.component' import { PermissionsGroupComponent } from './permissions-group.component'
describe('PermissionsGroupComponent', () => { describe('PermissionsGroupComponent', () => {
@@ -60,4 +61,19 @@ describe('PermissionsGroupComponent', () => {
expect(component.value).toEqual({ id: 2, name: 'Group 2' }) expect(component.value).toEqual({ id: 2, name: 'Group 2' })
expect(groupServiceSpy).toHaveBeenCalled() 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, ReactiveFormsModule,
} from '@angular/forms' } from '@angular/forms'
import { NgSelectComponent } from '@ng-select/ng-select' 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 { Group } from 'src/app/data/group'
import { GroupService } from 'src/app/services/rest/group.service' import { GroupService } from 'src/app/services/rest/group.service'
import { ToastService } from 'src/app/services/toast.service'
import { AbstractInputComponent } from '../../abstract-input' import { AbstractInputComponent } from '../../abstract-input'
@Component({ @Component({
@@ -26,8 +27,15 @@ import { AbstractInputComponent } from '../../abstract-input'
}) })
export class PermissionsGroupComponent extends AbstractInputComponent<Group> { export class PermissionsGroupComponent extends AbstractInputComponent<Group> {
private readonly groupService = inject(GroupService) private readonly groupService = inject(GroupService)
private readonly toastService = inject(ToastService)
readonly groups = toSignal( 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[] } { initialValue: undefined as Group[] }
) )
} }
@@ -7,8 +7,9 @@ import {
ReactiveFormsModule, ReactiveFormsModule,
} from '@angular/forms' } from '@angular/forms'
import { NgSelectModule } from '@ng-select/ng-select' 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 { UserService } from 'src/app/services/rest/user.service'
import { ToastService } from 'src/app/services/toast.service'
import { PermissionsUserComponent } from './permissions-user.component' import { PermissionsUserComponent } from './permissions-user.component'
describe('PermissionsUserComponent', () => { describe('PermissionsUserComponent', () => {
@@ -60,4 +61,19 @@ describe('PermissionsUserComponent', () => {
expect(component.value).toEqual({ id: 2, name: 'User 2' }) expect(component.value).toEqual({ id: 2, name: 'User 2' })
expect(userServiceSpy).toHaveBeenCalled() 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, ReactiveFormsModule,
} from '@angular/forms' } from '@angular/forms'
import { NgSelectComponent } from '@ng-select/ng-select' 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 { User } from 'src/app/data/user'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { ToastService } from 'src/app/services/toast.service'
import { AbstractInputComponent } from '../../abstract-input' import { AbstractInputComponent } from '../../abstract-input'
@Component({ @Component({
@@ -26,8 +27,15 @@ import { AbstractInputComponent } from '../../abstract-input'
}) })
export class PermissionsUserComponent extends AbstractInputComponent<User[]> { export class PermissionsUserComponent extends AbstractInputComponent<User[]> {
private readonly userService = inject(UserService) private readonly userService = inject(UserService)
private readonly toastService = inject(ToastService)
readonly users = toSignal( 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[] } { initialValue: undefined as User[] }
) )
} }
@@ -4,8 +4,9 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select' import { NgSelectModule } from '@ng-select/ng-select'
import { of } from 'rxjs' import { of, throwError } from 'rxjs'
import { UserService } from 'src/app/services/rest/user.service' 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 { PermissionsFormComponent } from '../input/permissions/permissions-form/permissions-form.component'
import { PermissionsGroupComponent } from '../input/permissions/permissions-group/permissions-group.component' import { PermissionsGroupComponent } from '../input/permissions/permissions-group/permissions-group.component'
import { PermissionsUserComponent } from '../input/permissions/permissions-user/permissions-user.component' import { PermissionsUserComponent } from '../input/permissions/permissions-user/permissions-user.component'
@@ -77,6 +78,23 @@ describe('PermissionsDialogComponent', () => {
fixture.detectChanges() 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', () => { it('should return permissions', () => {
expect(component.permissions).toEqual({ expect(component.permissions).toEqual({
owner: null, owner: null,
@@ -14,10 +14,11 @@ import {
ReactiveFormsModule, ReactiveFormsModule,
} from '@angular/forms' } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' 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 { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
import { User } from 'src/app/data/user' import { User } from 'src/app/data/user'
import { UserService } from 'src/app/services/rest/user.service' 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 { PermissionsFormComponent } from '../input/permissions/permissions-form/permissions-form.component'
import { SwitchComponent } from '../input/switch/switch.component' import { SwitchComponent } from '../input/switch/switch.component'
@@ -35,9 +36,16 @@ import { SwitchComponent } from '../input/switch/switch.component'
export class PermissionsDialogComponent { export class PermissionsDialogComponent {
activeModal = inject(NgbActiveModal) activeModal = inject(NgbActiveModal)
private userService = inject(UserService) private userService = inject(UserService)
private toastService = inject(ToastService)
readonly users = toSignal( 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[] } { initialValue: undefined as User[] }
) )
readonly title = signal($localize`Set permissions`) 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', () => { it('should reset the suggestions loading state if the document changes mid-request', () => {
const getSetting = settingsService.get.bind(settingsService) const getSetting = settingsService.get.bind(settingsService)
jest jest
@@ -237,6 +237,9 @@ export class DocumentDetailComponent
this.settings.getSignal<boolean>( this.settings.getSignal<boolean>(
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL 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< private readonly hiddenFieldsSetting = this.settings.getSignal<
DocumentDetailFieldID[] DocumentDetailFieldID[]
>(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS) >(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
@@ -357,6 +360,10 @@ export class DocumentDetailComponent
return this.aiEnabledSetting() return this.aiEnabledSetting()
} }
get autoSuggest(): boolean {
return this.autoSuggestSetting()
}
get archiveContentRenderType(): ContentRenderType { get archiveContentRenderType(): ContentRenderType {
const hasArchiveVersion = const hasArchiveVersion =
this.metadata()?.has_archive_version ?? this.metadata()?.has_archive_version ??
@@ -904,6 +911,7 @@ export class DocumentDetailComponent
this.updateFormForCustomFields() this.updateFormForCustomFields()
this.loadMetadataForSelectedVersion() this.loadMetadataForSelectedVersion()
if ( if (
this.autoSuggest &&
this.permissionsService.currentUserHasObjectPermissions( this.permissionsService.currentUserHasObjectPermissions(
PermissionAction.Change, PermissionAction.Change,
doc doc
@@ -10,7 +10,7 @@
} }
</div> </div>
</div> </div>
@if (textFilterTarget === 'asn') { @if (textFilterTarget === 'asn' || textFilterTarget === 'duplicates') {
<select class="form-select flex-grow-0 w-auto" [(ngModel)]="textFilterModifier" (change)="textFilterModifierChange()"> <select class="form-select flex-grow-0 w-auto" [(ngModel)]="textFilterModifier" (change)="textFilterModifierChange()">
@for (m of textFilterModifiers; track m) { @for (m of textFilterModifiers; track m) {
<option ngbDropdownItem [value]="m.id">{{m.label}}</option> <option ngbDropdownItem [value]="m.id">{{m.label}}</option>
@@ -23,7 +23,7 @@
</button> </button>
} }
<input #textFilterInput class="form-control form-control-sm" type="text" <input #textFilterInput class="form-control form-control-sm" type="text"
[disabled]="textFilterModifierIsNull" [disabled]="textFilterInputDisabled"
[(ngModel)]="textFilter" [(ngModel)]="textFilter"
(keydown)="textFilterKeydown($event)" (keydown)="textFilterKeydown($event)"
[ngbTypeahead]="searchAutoComplete" [ngbTypeahead]="searchAutoComplete"
@@ -53,6 +53,7 @@ import {
FILTER_HAS_CUSTOM_FIELDS_ALL, FILTER_HAS_CUSTOM_FIELDS_ALL,
FILTER_HAS_CUSTOM_FIELDS_ANY, FILTER_HAS_CUSTOM_FIELDS_ANY,
FILTER_HAS_DOCUMENT_TYPE_ANY, FILTER_HAS_DOCUMENT_TYPE_ANY,
FILTER_HAS_DUPLICATES,
FILTER_HAS_STORAGE_PATH_ANY, FILTER_HAS_STORAGE_PATH_ANY,
FILTER_HAS_TAGS_ALL, FILTER_HAS_TAGS_ALL,
FILTER_HAS_TAGS_ANY, FILTER_HAS_TAGS_ANY,
@@ -427,6 +428,38 @@ describe('FilterEditorComponent', () => {
expect(component.textFilterTarget).toEqual('mime-type') // TEXT_FILTER_TARGET_MIME_TYPE 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', () => { it('should ingest text filter rules for fulltext query', () => {
expect(component.textFilter).toEqual(null) expect(component.textFilter).toEqual(null)
component.filterRules = [ 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', () => { it('should convert user input to correct filter rules on full text query', () => {
component.textFilterInput.nativeElement.value = 'foo' component.textFilterInput.nativeElement.value = 'foo'
component.textFilterInput.nativeElement.dispatchEvent(new Event('input')) component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
@@ -2178,6 +2238,22 @@ describe('FilterEditorComponent', () => {
] ]
expect(component.generateFilterName()).toEqual('Without any tag') 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 = [ component.filterRules = [
{ {
rule_type: FILTER_CUSTOM_FIELDS_QUERY, rule_type: FILTER_CUSTOM_FIELDS_QUERY,
@@ -65,6 +65,7 @@ import {
FILTER_HAS_CUSTOM_FIELDS_ALL, FILTER_HAS_CUSTOM_FIELDS_ALL,
FILTER_HAS_CUSTOM_FIELDS_ANY, FILTER_HAS_CUSTOM_FIELDS_ANY,
FILTER_HAS_DOCUMENT_TYPE_ANY, FILTER_HAS_DOCUMENT_TYPE_ANY,
FILTER_HAS_DUPLICATES,
FILTER_HAS_STORAGE_PATH_ANY, FILTER_HAS_STORAGE_PATH_ANY,
FILTER_HAS_TAGS_ALL, FILTER_HAS_TAGS_ALL,
FILTER_HAS_TAGS_ANY, 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_FULLTEXT_MORELIKE = 'fulltext-morelike'
const TEXT_FILTER_TARGET_CUSTOM_FIELDS = 'custom-fields' const TEXT_FILTER_TARGET_CUSTOM_FIELDS = 'custom-fields'
const TEXT_FILTER_TARGET_MIME_TYPE = 'mime-type' const TEXT_FILTER_TARGET_MIME_TYPE = 'mime-type'
const TEXT_FILTER_TARGET_DUPLICATES = 'duplicates'
const TEXT_FILTER_MODIFIER_EQUALS = 'equals' const TEXT_FILTER_MODIFIER_EQUALS = 'equals'
const TEXT_FILTER_MODIFIER_NULL = 'is null' const TEXT_FILTER_MODIFIER_NULL = 'is null'
const TEXT_FILTER_MODIFIER_NOTNULL = 'not null' const TEXT_FILTER_MODIFIER_NOTNULL = 'not null'
const TEXT_FILTER_MODIFIER_GT = 'greater' const TEXT_FILTER_MODIFIER_GT = 'greater'
const TEXT_FILTER_MODIFIER_LT = 'less' 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_CREATED = /created:[\["]([^\]]+)[\]"]/g
const RELATIVE_DATE_QUERY_REGEXP_ADDED = /added:[\["]([^\]]+)[\]"]/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, id: TEXT_FILTER_TARGET_FULLTEXT_QUERY,
name: $localize`Advanced search`, name: $localize`Advanced search`,
}, },
{ id: TEXT_FILTER_TARGET_DUPLICATES, name: $localize`Duplicates` },
] ]
const DEPRECATED_CUSTOM_FIELDS_TEXT_FILTER_TARGET_OPTION = { 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({ @Component({
selector: 'pngx-filter-editor', selector: 'pngx-filter-editor',
templateUrl: './filter-editor.component.html', templateUrl: './filter-editor.component.html',
@@ -320,6 +336,12 @@ export class FilterEditorComponent
if (rule.value == 'false') { if (rule.value == 'false') {
return $localize`Without any tag` 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: case FILTER_CUSTOM_FIELDS_QUERY:
return $localize`Custom fields query` return $localize`Custom fields query`
@@ -390,7 +412,9 @@ export class FilterEditorComponent
public textFilterModifier: string public textFilterModifier: string
get textFilterModifiers() { 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 { 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) tagSelectionModel = new FilterableDropdownSelectionModel(true)
correspondentSelectionModel = new FilterableDropdownSelectionModel() correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel() documentTypeSelectionModel = new FilterableDropdownSelectionModel()
@@ -444,6 +475,7 @@ export class FilterEditorComponent
this.customFieldQueriesModel.clear(false) this.customFieldQueriesModel.clear(false)
this._textFilter = null this._textFilter = null
this._moreLikeId = null this._moreLikeId = null
this.textFilterTarget = TEXT_FILTER_TARGET_TITLE_CONTENT
this.dateAddedTo = null this.dateAddedTo = null
this.dateAddedFrom = null this.dateAddedFrom = null
this.dateCreatedTo = null this.dateCreatedTo = null
@@ -477,6 +509,13 @@ export class FilterEditorComponent
this.textFilterTarget = TEXT_FILTER_TARGET_MIME_TYPE this.textFilterTarget = TEXT_FILTER_TARGET_MIME_TYPE
this._textFilter = rule.value this._textFilter = rule.value
break 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: case FILTER_FULLTEXT_QUERY:
let allQueryArgs = rule.value.split(',') let allQueryArgs = rule.value.split(',')
let textQueryArgs = [] let textQueryArgs = []
@@ -800,6 +839,14 @@ export class FilterEditorComponent
value: this._textFilter.trim(), 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) { if (this._textFilter && this.textFilterTarget == TEXT_FILTER_TARGET_TITLE) {
filterRules.push({ filterRules.push({
rule_type: FILTER_SIMPLE_TITLE, rule_type: FILTER_SIMPLE_TITLE,
@@ -1163,7 +1210,7 @@ export class FilterEditorComponent
} }
get textFilter() { get textFilter() {
return this.textFilterModifierIsNull ? '' : this._textFilter return this.textFilterInputDisabled ? '' : this._textFilter
} }
set textFilter(value) { set textFilter(value) {
@@ -1363,12 +1410,24 @@ export class FilterEditorComponent
this._textFilter = '' this._textFilter = ''
} }
this.textFilterTarget = target 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.textFilterInput.nativeElement.focus()
this.updateRules() this.updateRules()
} }
textFilterModifierChange() { textFilterModifierChange() {
if ( if (
this.textFilterTarget == TEXT_FILTER_TARGET_DUPLICATES ||
this.textFilterModifierIsNull || this.textFilterModifierIsNull ||
([ ([
TEXT_FILTER_MODIFIER_EQUALS, TEXT_FILTER_MODIFIER_EQUALS,
+8
View File
@@ -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_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_TITLE = 48
export const FILTER_SIMPLE_TEXT = 49 export const FILTER_SIMPLE_TEXT = 49
export const FILTER_HAS_DUPLICATES = 50
export const FILTER_FULLTEXT_QUERY = 20 export const FILTER_FULLTEXT_QUERY = 20
export const FILTER_FULLTEXT_MORELIKE = 21 export const FILTER_FULLTEXT_MORELIKE = 21
@@ -382,6 +383,13 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
datatype: 'string', datatype: 'string',
multi: false, multi: false,
}, },
{
id: FILTER_HAS_DUPLICATES,
filtervar: 'has_duplicates',
datatype: 'boolean',
multi: false,
default: true,
},
] ]
export interface FilterRuleType { export interface FilterRuleType {
+1
View File
@@ -422,6 +422,7 @@ export const PaperlessConfigOptions: ConfigOption[] = [
] ]
export interface PaperlessConfig extends ObjectWithId { export interface PaperlessConfig extends ObjectWithId {
externally_configured_variables: string[]
output_type: OutputTypeConfig output_type: OutputTypeConfig
pages: number pages: number
language: string language: string
+7
View File
@@ -84,6 +84,8 @@ export const SETTINGS_KEYS = {
'general-settings:document-editing:remove-inbox-tags', 'general-settings:document-editing:remove-inbox-tags',
DOCUMENT_EDITING_OVERLAY_THUMBNAIL: DOCUMENT_EDITING_OVERLAY_THUMBNAIL:
'general-settings:document-editing:overlay-thumbnail', 'general-settings:document-editing:overlay-thumbnail',
DOCUMENT_EDITING_AUTO_SUGGEST:
'general-settings:document-editing:auto-suggest',
DOCUMENT_DETAILS_HIDDEN_FIELDS: DOCUMENT_DETAILS_HIDDEN_FIELDS:
'general-settings:document-details:hidden-fields', 'general-settings:document-details:hidden-fields',
SEARCH_DB_ONLY: 'general-settings:search:db-only', SEARCH_DB_ONLY: 'general-settings:search:db-only',
@@ -300,6 +302,11 @@ export const SETTINGS: UiSetting[] = [
type: 'boolean', type: 'boolean',
default: true, default: true,
}, },
{
key: SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST,
type: 'boolean',
default: true,
},
{ {
key: SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS, key: SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
type: 'array', type: 'array',
+23
View File
@@ -7,6 +7,7 @@ import {
FILTER_HAS_ANY_TAG, FILTER_HAS_ANY_TAG,
FILTER_HAS_CUSTOM_FIELDS_ALL, FILTER_HAS_CUSTOM_FIELDS_ALL,
FILTER_HAS_CUSTOM_FIELDS_ANY, FILTER_HAS_CUSTOM_FIELDS_ANY,
FILTER_HAS_DUPLICATES,
FILTER_HAS_TAGS_ALL, FILTER_HAS_TAGS_ALL,
FILTER_SIMPLE_TEXT, FILTER_SIMPLE_TEXT,
FILTER_SIMPLE_TITLE, FILTER_SIMPLE_TITLE,
@@ -132,6 +133,16 @@ describe('QueryParams Utils', () => {
is_tagged: 0, is_tagged: 0,
}) })
params = queryParamsFromFilterRules([
{
rule_type: FILTER_HAS_DUPLICATES,
value: 'false',
},
])
expect(params).toEqual({
has_duplicates: 0,
})
params = queryParamsFromFilterRules([ params = queryParamsFromFilterRules([
{ {
rule_type: FILTER_TITLE_CONTENT, 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( rules = filterRulesFromQueryParams(
convertToParamMap({ convertToParamMap({
correspondent__isnull: '1', correspondent__isnull: '1',
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

-3
View File
@@ -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

-4
View File
@@ -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

-3
View File
@@ -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

-3
View File
@@ -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

-4
View File
@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

-1
View File
@@ -8,7 +8,6 @@
<meta name="color-scheme" content="dark light"> <meta name="color-scheme" content="dark light">
<meta name="theme-color" content="#17541f" /> <meta name="theme-color" content="#17541f" />
<link rel="manifest" href="manifest.webmanifest"> <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"> <link rel="apple-touch-icon" href="apple-touch-icon.png">
</head> </head>
<body> <body>
+12 -4
View File
@@ -4,12 +4,20 @@
"display": "standalone", "display": "standalone",
"icons": [ "icons": [
{ {
"src": "favicon.ico", "src": "icon-192.png",
"sizes": "256x256" "sizes": "192x192",
"type": "image/png"
}, },
{ {
"src": "assets/logo-notext.svg", "src": "icon-512.png",
"sizes": "any" "sizes": "512x512",
"type": "image/png"
},
{
"src": "icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
} }
], ],
"name": "Paperless-ngx", "name": "Paperless-ngx",
+4 -1
View File
@@ -52,6 +52,7 @@ from documents.templating.workflows import parse_w_workflow_placeholders
from documents.utils import compute_checksum from documents.utils import compute_checksum
from documents.utils import copy_basic_file_stats from documents.utils import copy_basic_file_stats
from documents.utils import copy_file_with_basic_stats from documents.utils import copy_file_with_basic_stats
from documents.utils import normalize_unicode
from documents.utils import run_subprocess from documents.utils import run_subprocess
from paperless.config import OcrConfig from paperless.config import OcrConfig
from paperless.config import RemoteOCRConfig from paperless.config import RemoteOCRConfig
@@ -201,7 +202,9 @@ class ConsumerPluginMixin:
self.renew_logging_group() self.renew_logging_group()
self.filename = self.metadata.filename or self.input_doc.original_file.name self.filename = normalize_unicode(
self.metadata.filename or self.input_doc.original_file.name,
)
def _send_progress( def _send_progress(
self, self,
+47 -17
View File
@@ -12,7 +12,6 @@ from typing import TYPE_CHECKING
from typing import Any from typing import Any
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldError
from django.db.models import Case from django.db.models import Case
from django.db.models import CharField from django.db.models import CharField
from django.db.models import Count 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 Value
from django.db.models import When from django.db.models import When
from django.db.models.functions import Cast from django.db.models.functions import Cast
from django.db.models.functions import NullIf
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_filters import DateFilter from django_filters import DateFilter
from django_filters.rest_framework import BooleanFilter from django_filters.rest_framework import BooleanFilter
@@ -50,7 +50,9 @@ from documents.models import ShareLink
from documents.models import ShareLinkBundle from documents.models import ShareLinkBundle
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
from documents.permissions import permitted_document_ids
from documents.permissions import permitted_object_ids from documents.permissions import permitted_object_ids
from documents.versioning import annotate_effective_content
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
@@ -180,14 +182,9 @@ class TitleContentFilter(Filter):
logger.warning( logger.warning(
"Deprecated document filter parameter 'title_content' used; use `text` instead.", "Deprecated document filter parameter 'title_content' used; use `text` instead.",
) )
try: return annotate_effective_content(qs).filter(
return qs.filter( Q(title__icontains=value) | Q(effective_content__icontains=value),
Q(title__icontains=value) | Q(effective_content__icontains=value), )
)
except FieldError:
return qs.filter(
Q(title__icontains=value) | Q(content__icontains=value),
)
else: else:
return qs return qs
@@ -198,14 +195,9 @@ class EffectiveContentFilter(Filter):
value = value.strip() if isinstance(value, str) else value value = value.strip() if isinstance(value, str) else value
if not value: if not value:
return qs return qs
try: return annotate_effective_content(qs).filter(
return qs.filter( **{f"effective_content__{self.lookup_expr}": value},
**{f"effective_content__{self.lookup_expr}": value}, )
)
except FieldError:
return qs.filter(
**{f"content__{self.lookup_expr}": value},
)
@extend_schema_field(serializers.BooleanField) @extend_schema_field(serializers.BooleanField)
@@ -793,6 +785,12 @@ class CustomFieldQueryFilter(Filter):
class DocumentFilterSet(FilterSet): 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( is_tagged = BooleanFilter(
label="Is tagged", label="Is tagged",
field_name="tags", field_name="tags",
@@ -852,6 +850,38 @@ class DocumentFilterSet(FilterSet):
mime_type = MimeTypeFilter() 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 # Backwards compatibility
created__date__gt = DateFilter(field_name="created", lookup_expr="gt") created__date__gt = DateFilter(field_name="created", lookup_expr="gt")
created__date__gte = DateFilter(field_name="created", lookup_expr="gte") created__date__gte = DateFilter(field_name="created", lookup_expr="gte")
+10 -6
View File
@@ -21,6 +21,7 @@ from documents.models import Workflow
from documents.models import WorkflowTrigger from documents.models import WorkflowTrigger
from documents.permissions import permitted_object_ids from documents.permissions import permitted_object_ids
from documents.regex import safe_regex_search from documents.regex import safe_regex_search
from documents.utils import normalize_unicode
if TYPE_CHECKING: if TYPE_CHECKING:
from django.db.models import QuerySet from django.db.models import QuerySet
@@ -311,11 +312,12 @@ def consumable_document_matches_workflow(
trigger_matched = False trigger_matched = False
# Document filename vs trigger filename # Document filename vs trigger filename
document_filename = normalize_unicode(document.original_file.name)
if ( if (
trigger.filter_filename is not None trigger.filter_filename is not None
and len(trigger.filter_filename) > 0 and len(trigger.filter_filename) > 0
and not fnmatch( and not fnmatch(
document.original_file.name.lower(), document_filename.lower(),
trigger.filter_filename.lower(), trigger.filter_filename.lower(),
) )
): ):
@@ -328,10 +330,12 @@ def consumable_document_matches_workflow(
# Document path vs trigger path # Document path vs trigger path
# Use the original_path if set, else us the original_file # Use the original_path if set, else us the original_file
match_against = ( match_against = normalize_unicode(
document.original_path str(
if document.original_path is not None document.original_path
else document.original_file if document.original_path is not None
else document.original_file,
),
) )
if ( if (
@@ -536,7 +540,7 @@ def existing_document_matches_workflow(
and len(trigger.filter_filename) > 0 and len(trigger.filter_filename) > 0
and document.original_filename is not None and document.original_filename is not None
and not fnmatch( and not fnmatch(
document.original_filename.lower(), normalize_unicode(document.original_filename).lower(),
trigger.filter_filename.lower(), trigger.filter_filename.lower(),
) )
): ):
@@ -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",
),
),
]
+4 -1
View File
@@ -27,6 +27,7 @@ from django_softdelete.models import SoftDeleteModel
from documents.data_models import DocumentSource from documents.data_models import DocumentSource
from documents.parsers import get_default_file_extension from documents.parsers import get_default_file_extension
from documents.utils import normalize_unicode
class ModelWithOwner(models.Model): class ModelWithOwner(models.Model):
@@ -227,6 +228,7 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
editable=False, editable=False,
blank=True, blank=True,
null=True, null=True,
db_index=True,
help_text=_("The checksum of the archived document."), help_text=_("The checksum of the archived document."),
) )
@@ -466,7 +468,7 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
context_document = ( context_document = (
self.root_document if self.root_document_id is not None else self self.root_document if self.root_document_id is not None else self
) )
result = str(context_document) result = normalize_unicode(str(context_document))
if counter: if counter:
result += f"_{counter:02}" result += f"_{counter:02}"
@@ -706,6 +708,7 @@ class SavedViewFilterRule(models.Model):
(47, _("mime type is")), (47, _("mime type is")),
(48, _("simple title search")), (48, _("simple title search")),
(49, _("simple text search")), (49, _("simple text search")),
(50, _("has duplicates")),
] ]
saved_view = models.ForeignKey( saved_view = models.ForeignKey(
+40
View File
@@ -284,6 +284,46 @@ class WriteBatch:
tantivy.Query.term_query(self._backend._schema, "id", doc_id), 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: class TantivyBackend:
""" """
+11
View File
@@ -87,6 +87,7 @@ from documents.regex import validate_regex_pattern
from documents.templating.filepath import validate_filepath_template_and_render from documents.templating.filepath import validate_filepath_template_and_render
from documents.templating.utils import convert_format_str_to_template_format from documents.templating.utils import convert_format_str_to_template_format
from documents.templating.workflows import validate_workflow_template from documents.templating.workflows import validate_workflow_template
from documents.utils import normalize_unicode
from documents.validators import uri_validator from documents.validators import uri_validator
from documents.validators import url_validator from documents.validators import url_validator
from documents.versioning import sort_versions_newest_first from documents.versioning import sort_versions_newest_first
@@ -674,6 +675,9 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
ordering = ordering or (Lower("name"),) ordering = ordering or (Lower("name"),)
children = children.order_by(*ordering) children = children.order_by(*ordering)
if not children:
return []
serializer = TagSerializer( serializer = TagSerializer(
children, children,
many=True, many=True,
@@ -3117,6 +3121,13 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer[WorkflowTrigger]):
): ):
attrs["filter_path"] = None attrs["filter_path"] = None
# Normalize once at write time, since these are matched against many
# documents but edited rarely
if attrs.get("filter_filename") is not None:
attrs["filter_filename"] = normalize_unicode(attrs["filter_filename"])
if attrs.get("filter_path") is not None:
attrs["filter_path"] = normalize_unicode(attrs["filter_path"])
if ( if (
"filter_custom_field_query" in attrs "filter_custom_field_query" in attrs
and attrs["filter_custom_field_query"] is not None and attrs["filter_custom_field_query"] is not None
+5 -3
View File
@@ -312,7 +312,10 @@ def bulk_update_documents(document_ids) -> None:
from documents.search import get_backend from documents.search import get_backend
document_ids = list(document_ids) 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( documents = annotate_effective_content(
Document.objects.filter(id__in=document_ids), 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) post_save.send(Document, instance=doc, created=False)
with get_backend().batch_update() as batch: with get_backend().batch_update() as batch:
for doc in documents: batch.add_or_update_ids(document_ids)
batch.add_or_update(doc)
ai_config = AIConfig() ai_config = AIConfig()
if ai_config.llm_index_enabled: if ai_config.llm_index_enabled:
+11 -13
View File
@@ -1,7 +1,6 @@
import logging import logging
import os import os
import re import re
import unicodedata
from collections.abc import Iterable from collections.abc import Iterable
from pathlib import PurePath from pathlib import PurePath
@@ -26,6 +25,7 @@ from documents.templating.environment import _template_environment
from documents.templating.filters import format_datetime from documents.templating.filters import format_datetime
from documents.templating.filters import get_cf_value from documents.templating.filters import get_cf_value
from documents.templating.filters import localize_date from documents.templating.filters import localize_date
from documents.utils import normalize_unicode
logger = logging.getLogger("paperless.templating") logger = logging.getLogger("paperless.templating")
@@ -42,7 +42,7 @@ class FilePathTemplate(Template):
3. Removing extra spaces before and after forward slashes 3. Removing extra spaces before and after forward slashes
4. Preserving spaces in other parts of the path 4. Preserving spaces in other parts of the path
""" """
value = unicodedata.normalize("NFC", value) value = normalize_unicode(value)
value = value.replace("\n", "").replace("\r", "") value = value.replace("\n", "").replace("\r", "")
value = re.sub(r"\s*/\s*", "/", value) value = re.sub(r"\s*/\s*", "/", value)
@@ -184,17 +184,17 @@ def get_basic_metadata_context(
""" """
return { return {
"title": pathvalidate.sanitize_filename( "title": pathvalidate.sanitize_filename(
unicodedata.normalize("NFC", document.title), normalize_unicode(document.title),
replacement_text="-", replacement_text="-",
), ),
"correspondent": pathvalidate.sanitize_filename( "correspondent": pathvalidate.sanitize_filename(
unicodedata.normalize("NFC", document.correspondent.name), normalize_unicode(document.correspondent.name),
replacement_text="-", replacement_text="-",
) )
if document.correspondent if document.correspondent
else no_value_default, else no_value_default,
"document_type": pathvalidate.sanitize_filename( "document_type": pathvalidate.sanitize_filename(
unicodedata.normalize("NFC", document.document_type.name), normalize_unicode(document.document_type.name),
replacement_text="-", replacement_text="-",
) )
if document.document_type if document.document_type
@@ -205,8 +205,7 @@ def get_basic_metadata_context(
"owner_username": document.owner.username "owner_username": document.owner.username
if document.owner if document.owner
else no_value_default, else no_value_default,
"original_name": unicodedata.normalize( "original_name": normalize_unicode(
"NFC",
PurePath(document.original_filename).with_suffix("").name, PurePath(document.original_filename).with_suffix("").name,
) )
if document.original_filename if document.original_filename
@@ -275,12 +274,12 @@ def get_tags_context(tags: Iterable[Tag]) -> dict[str, str | list[str]]:
return { return {
"tag_list": pathvalidate.sanitize_filename( "tag_list": pathvalidate.sanitize_filename(
",".join( ",".join(
sorted(unicodedata.normalize("NFC", tag.name) for tag in tags), sorted(normalize_unicode(tag.name) for tag in tags),
), ),
replacement_text="-", replacement_text="-",
), ),
# Assumed to be ordered, but a template could loop through to find what they want # Assumed to be ordered, but a template could loop through to find what they want
"tag_name_list": [unicodedata.normalize("NFC", x.name) for x in tags], "tag_name_list": [normalize_unicode(x.name) for x in tags],
} }
@@ -307,7 +306,7 @@ def get_custom_fields_context(
CustomField.FieldDataType.LONG_TEXT, CustomField.FieldDataType.LONG_TEXT,
}: }:
value = pathvalidate.sanitize_filename( value = pathvalidate.sanitize_filename(
unicodedata.normalize("NFC", field_instance.value), normalize_unicode(field_instance.value),
replacement_text="-", replacement_text="-",
) )
elif ( elif (
@@ -316,8 +315,7 @@ def get_custom_fields_context(
): ):
options = field_instance.field.extra_data["select_options"] options = field_instance.field.extra_data["select_options"]
value = pathvalidate.sanitize_filename( value = pathvalidate.sanitize_filename(
unicodedata.normalize( normalize_unicode(
"NFC",
next( next(
option["label"] option["label"]
for option in options for option in options
@@ -330,7 +328,7 @@ def get_custom_fields_context(
value = field_instance.value value = field_instance.value
field_data["custom_fields"][ field_data["custom_fields"][
pathvalidate.sanitize_filename( pathvalidate.sanitize_filename(
unicodedata.normalize("NFC", field_instance.field.name), normalize_unicode(field_instance.field.name),
replacement_text="-", replacement_text="-",
) )
] = { ] = {
+3 -3
View File
@@ -10,7 +10,7 @@ import pytest
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from guardian.shortcuts import clear_ct_cache 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 rest_framework.test import APIClient
from documents.tests.factories import DocumentFactory from documents.tests.factories import DocumentFactory
@@ -100,7 +100,7 @@ def sample_doc(
@pytest.fixture() @pytest.fixture()
def _search_index( def _search_index(
tmp_path: Path, tmp_path: Path,
settings: SettingsWrapper, settings: Settings,
) -> Generator[None, None, None]: ) -> Generator[None, None, None]:
"""Create a temp index directory and point INDEX_DIR at it. """Create a temp index directory and point INDEX_DIR at it.
@@ -118,7 +118,7 @@ def _search_index(
@pytest.fixture() @pytest.fixture()
def settings_timezone(settings: SettingsWrapper) -> zoneinfo.ZoneInfo: def settings_timezone(settings: Settings) -> zoneinfo.ZoneInfo:
return zoneinfo.ZoneInfo(settings.TIME_ZONE) return zoneinfo.ZoneInfo(settings.TIME_ZONE)
+1 -1
View File
@@ -70,7 +70,7 @@ def clear_lru_cache() -> Generator[None, None, None]:
@pytest.fixture @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. Override Django settings for the duration of date parser tests.
""" """
+3 -3
View File
@@ -6,7 +6,7 @@ from pathlib import Path
import pytest import pytest
import pytest_mock 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 DirectoryExportSink
from documents.export.sinks import ExportSink from documents.export.sinks import ExportSink
@@ -242,7 +242,7 @@ class TestZipExportSink:
self, self,
tmp_path: Path, tmp_path: Path,
source_file: Path, source_file: Path,
settings: SettingsWrapper, settings: Settings,
) -> None: ) -> None:
scratch_dir = tmp_path / "scratch" scratch_dir = tmp_path / "scratch"
settings.SCRATCH_DIR = scratch_dir settings.SCRATCH_DIR = scratch_dir
@@ -261,7 +261,7 @@ class TestZipExportSink:
def test_abort_after_manifest_written_cleans_up_pending_tmp( def test_abort_after_manifest_written_cleans_up_pending_tmp(
self, self,
tmp_path: Path, tmp_path: Path,
settings: SettingsWrapper, settings: Settings,
) -> None: ) -> None:
scratch_dir = tmp_path / "scratch" scratch_dir = tmp_path / "scratch"
settings.SCRATCH_DIR = scratch_dir settings.SCRATCH_DIR = scratch_dir
+2 -2
View File
@@ -15,11 +15,11 @@ if TYPE_CHECKING:
from collections.abc import Generator from collections.abc import Generator
from pathlib import Path from pathlib import Path
from pytest_django.fixtures import SettingsWrapper from pytest_django.fixtures import Settings
@pytest.fixture @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 = tmp_path / "index"
path.mkdir() path.mkdir()
settings.INDEX_DIR = path settings.INDEX_DIR = path
+187
View File
@@ -4,6 +4,8 @@ from pathlib import Path
import pytest import pytest
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.contrib.auth.models import User 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 guardian.shortcuts import assign_perm
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
@@ -102,6 +104,191 @@ class TestWriteBatch:
assert len(backend.search_ids("indexable", user=None)) == 1 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: class TestSearch:
"""Test search query parsing and matching via search_ids.""" """Test search query parsing and matching via search_ids."""
+7 -6
View File
@@ -11,7 +11,8 @@ from documents.search._schema import needs_rebuild
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
from pytest_django.fixtures import SettingsWrapper from pytest_django.fixtures import Settings
pytestmark = pytest.mark.search pytestmark = pytest.mark.search
@@ -25,7 +26,7 @@ class TestNeedsRebuild:
def test_returns_false_when_version_and_language_match( def test_returns_false_when_version_and_language_match(
self, self,
index_dir: Path, index_dir: Path,
settings: SettingsWrapper, settings: Settings,
) -> None: ) -> None:
settings.SEARCH_LANGUAGE = "en" settings.SEARCH_LANGUAGE = "en"
(index_dir / ".index_settings.json").write_text( (index_dir / ".index_settings.json").write_text(
@@ -36,7 +37,7 @@ class TestNeedsRebuild:
def test_returns_true_on_schema_version_mismatch( def test_returns_true_on_schema_version_mismatch(
self, self,
index_dir: Path, index_dir: Path,
settings: SettingsWrapper, settings: Settings,
) -> None: ) -> None:
settings.SEARCH_LANGUAGE = None settings.SEARCH_LANGUAGE = None
(index_dir / ".index_settings.json").write_text( (index_dir / ".index_settings.json").write_text(
@@ -47,7 +48,7 @@ class TestNeedsRebuild:
def test_returns_true_when_version_is_not_an_integer( def test_returns_true_when_version_is_not_an_integer(
self, self,
index_dir: Path, index_dir: Path,
settings: SettingsWrapper, settings: Settings,
) -> None: ) -> None:
settings.SEARCH_LANGUAGE = None settings.SEARCH_LANGUAGE = None
(index_dir / ".index_settings.json").write_text( (index_dir / ".index_settings.json").write_text(
@@ -58,7 +59,7 @@ class TestNeedsRebuild:
def test_returns_true_when_language_key_missing( def test_returns_true_when_language_key_missing(
self, self,
index_dir: Path, index_dir: Path,
settings: SettingsWrapper, settings: Settings,
) -> None: ) -> None:
settings.SEARCH_LANGUAGE = "en" settings.SEARCH_LANGUAGE = "en"
(index_dir / ".index_settings.json").write_text( (index_dir / ".index_settings.json").write_text(
@@ -69,7 +70,7 @@ class TestNeedsRebuild:
def test_returns_true_when_language_differs( def test_returns_true_when_language_differs(
self, self,
index_dir: Path, index_dir: Path,
settings: SettingsWrapper, settings: Settings,
) -> None: ) -> None:
settings.SEARCH_LANGUAGE = "de" settings.SEARCH_LANGUAGE = "de"
(index_dir / ".index_settings.json").write_text( (index_dir / ".index_settings.json").write_text(
+49 -2
View File
@@ -35,7 +35,8 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
THEN: THEN:
- Existing config - 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) self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -45,6 +46,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
response.data[0], response.data[0],
{ {
"id": 1, "id": 1,
"externally_configured_variables": [],
"output_type": None, "output_type": None,
"pages": None, "pages": None,
"language": None, "language": None,
@@ -76,7 +78,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
"remote_ocr_api_key": None, "remote_ocr_api_key": None,
"remote_ocr_endpoint": None, "remote_ocr_endpoint": None,
"remote_ocr_mode": None, "remote_ocr_mode": None,
"ai_enabled": False, "ai_enabled": None,
"llm_embedding_backend": None, "llm_embedding_backend": None,
"llm_embedding_model": None, "llm_embedding_model": None,
"llm_embedding_endpoint": 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: def test_api_get_ui_settings_with_config(self) -> None:
""" """
GIVEN: GIVEN:
@@ -949,6 +976,26 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
) )
mock_update.assert_called_once() 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: def test_update_llm_embedding_chunk_size_triggers_rebuild(self) -> None:
config = ApplicationConfiguration.objects.first() config = ApplicationConfiguration.objects.first()
assert config is not None assert config is not None
+38
View File
@@ -717,6 +717,44 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(args[0], [self.doc2.id]) self.assertEqual(args[0], [self.doc2.id])
self.assertEqual(kwargs["storage_path"], self.sp1.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.search.get_backend")
@mock.patch("documents.serialisers.bulk_edit.set_storage_path") @mock.patch("documents.serialisers.bulk_edit.set_storage_path")
def test_api_bulk_edit_with_all_true_resolves_documents_from_search_filters( def test_api_bulk_edit_with_all_true_resolves_documents_from_search_filters(
@@ -2,14 +2,12 @@ from __future__ import annotations
import datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from unittest import TestCase
from unittest import mock from unittest import mock
from auditlog.models import LogEntry # type: ignore[import-untyped] from auditlog.models import LogEntry # type: ignore[import-untyped]
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldError
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase as DjangoTestCase from django.test import TestCase as DjangoTestCase
from django.utils import timezone from django.utils import timezone
@@ -22,6 +20,7 @@ from documents.filters import TitleContentFilter
from documents.models import Document from documents.models import Document
from documents.tests.utils import DirectoriesMixin from documents.tests.utils import DirectoriesMixin
from documents.tests.utils import read_streaming_response from documents.tests.utils import read_streaming_response
from documents.versioning import annotate_effective_content
from documents.views import DocumentSelectionMixin from documents.views import DocumentSelectionMixin
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -598,6 +597,7 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
self.assertEqual(input_doc.root_document_id, root.id) self.assertEqual(input_doc.root_document_id, root.id)
self.assertEqual(input_doc.source, DocumentSource.ApiUpload) self.assertEqual(input_doc.source, DocumentSource.ApiUpload)
self.assertEqual(overrides.version_label, "New Version") self.assertEqual(overrides.version_label, "New Version")
self.assertEqual(overrides.owner_id, self.user.id)
self.assertEqual(overrides.actor_id, self.user.id) self.assertEqual(overrides.actor_id, self.user.id)
def test_update_version_with_version_pk_normalizes_to_root(self) -> None: def test_update_version_with_version_pk_normalizes_to_root(self) -> None:
@@ -891,32 +891,104 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
) )
class TestVersionAwareFilters(TestCase): class TestVersionAwareFilters(DjangoTestCase):
def test_title_content_filter_falls_back_to_content(self) -> None: """
queryset = mock.Mock() The filters annotate effective_content themselves rather than relying on
fallback_queryset = mock.Mock() the caller's queryset carrying it, so they stay version-aware on a plain
queryset.filter.side_effect = [FieldError("missing field"), fallback_queryset] 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) def test_title_content_filter_matches_latest_version_content(self) -> None:
self.assertEqual(queryset.filter.call_count, 2) result = TitleContentFilter().filter(
Document.objects.filter(root_document__isnull=True),
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,
" latest ", " latest ",
) )
self.assertIs(result, fallback_queryset) self.assertCountEqual(
first_kwargs = queryset.filter.call_args_list[0].kwargs [doc.id for doc in result],
second_kwargs = queryset.filter.call_args_list[1].kwargs [self.root.id, self.unversioned.id],
self.assertEqual(first_kwargs, {"effective_content__icontains": "latest"}) )
self.assertEqual(second_kwargs, {"content__icontains": "latest"})
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: def test_effective_content_filter_returns_input_for_empty_values(self) -> None:
queryset = mock.Mock() queryset = mock.Mock()
+122
View File
@@ -981,6 +981,128 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
self.assertEqual(len(results), 1) self.assertEqual(len(results), 1)
self.assertEqual(results[0]["id"], doc.id) 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: def test_custom_fields_icontains_filter_no_duplicates(self) -> None:
""" """
GIVEN: GIVEN:
+23
View File
@@ -1947,6 +1947,29 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
self.assertEqual(len(response.data["documents"]), 1) self.assertEqual(len(response.data["documents"]), 1)
self.assertEqual(response.data["documents"][0]["id"], title_match.id) 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: def test_global_search_filters_owned_mail_objects(self) -> None:
user1 = User.objects.create_user("mail-search-user") user1 = User.objects.create_user("mail-search-user")
user2 = User.objects.create_user("other-mail-search-user") user2 = User.objects.create_user("other-mail-search-user")
@@ -0,0 +1,69 @@
import unicodedata
from typing import TYPE_CHECKING
from unittest import mock
import celery.result
import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from documents.models import Document
if TYPE_CHECKING:
from documents.data_models import ConsumableDocument
@pytest.fixture()
def consume_file_mock():
with mock.patch("documents.tasks.consume_file.apply_async") as m:
m.return_value = celery.result.AsyncResult(id="test-task-id")
yield m
@pytest.fixture()
def directories(tmp_path, settings, _media_settings):
scratch = tmp_path / "scratch"
scratch.mkdir()
settings.SCRATCH_DIR = scratch
return scratch
@pytest.mark.django_db
class TestUpdateVersionNFCNormalization:
def test_nfd_filename_normalized_to_nfc(
self,
admin_client,
consume_file_mock: mock.MagicMock,
directories,
):
"""Uploaded new-version file with NFD filename must have its temp name stored as NFC."""
document = Document.objects.create(
title="Test",
content="content",
checksum="checksum",
mime_type="application/pdf",
)
nfd = unicodedata.normalize("NFD", "Rechnung März.pdf")
nfc = unicodedata.normalize("NFC", "Rechnung März.pdf")
assert nfd != nfc
uploaded = SimpleUploadedFile(
nfd,
b"%PDF-1.4 test",
content_type="application/pdf",
)
response = admin_client.post(
f"/api/documents/{document.pk}/update_version/",
{"document": uploaded},
)
assert response.status_code == 200
task_kwargs = consume_file_mock.call_args.kwargs["kwargs"]
input_doc: ConsumableDocument = task_kwargs["input_doc"]
assert input_doc.original_file.name == nfc, (
f"Expected NFC filename {nfc!r}, got {input_doc.original_file.name!r}"
)
assert unicodedata.is_normalized("NFC", input_doc.original_file.name)
+2 -2
View File
@@ -1,7 +1,7 @@
import pytest import pytest
from django.core.checks import Error from django.core.checks import Error
from django.core.checks import Warning from django.core.checks import Warning
from pytest_django.fixtures import SettingsWrapper from pytest_django.fixtures import Settings
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from documents.checks import filename_format_check from documents.checks import filename_format_check
@@ -47,7 +47,7 @@ class TestFilenameFormatCheck:
) )
def test_warns_on_old_style_format( def test_warns_on_old_style_format(
self, self,
settings: SettingsWrapper, settings: Settings,
filename_format: str, filename_format: str,
expected_hint: str, expected_hint: str,
) -> None: ) -> None:
@@ -0,0 +1,48 @@
import unicodedata
from datetime import date
import pytest
from documents.models import Correspondent
from documents.models import Document
@pytest.mark.django_db
class TestGetPublicFilenameNfc:
def test_normalizes_nfd_title_to_nfc(self) -> None:
nfd_title = unicodedata.normalize("NFD", "Gehaltserhöhung")
assert not unicodedata.is_normalized("NFC", nfd_title)
doc = Document(
mime_type="application/pdf",
title=nfd_title,
created=date(2025, 10, 17),
)
result = doc.get_public_filename()
assert unicodedata.is_normalized("NFC", result)
assert (
result
== "2025-10-17 "
+ unicodedata.normalize(
"NFC",
nfd_title,
)
+ ".pdf"
)
def test_normalizes_nfd_correspondent_name_to_nfc(self) -> None:
nfd_name = unicodedata.normalize("NFD", "Müller GmbH")
correspondent = Correspondent.objects.create(name=nfd_name)
doc = Document.objects.create(
mime_type="application/pdf",
title="Rechnung",
created=date(2025, 10, 17),
correspondent=correspondent,
)
result = doc.get_public_filename()
assert unicodedata.is_normalized("NFC", result)
@@ -43,7 +43,7 @@ if TYPE_CHECKING:
from collections.abc import Generator from collections.abc import Generator
from unittest.mock import MagicMock from unittest.mock import MagicMock
from pytest_django.fixtures import SettingsWrapper from pytest_django.fixtures import Settings
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
@@ -605,7 +605,7 @@ class TestCommandValidation:
def test_raises_for_missing_consumption_dir( def test_raises_for_missing_consumption_dir(
self, self,
settings: SettingsWrapper, settings: Settings,
) -> None: ) -> None:
"""Test command raises error when directory is not provided.""" """Test command raises error when directory is not provided."""
settings.CONSUMPTION_DIR = None settings.CONSUMPTION_DIR = None
@@ -639,7 +639,7 @@ class TestCommandOneshot:
scratch_dir: Path, scratch_dir: Path,
sample_pdf: Path, sample_pdf: Path,
mock_consume_file_delay: MagicMock, mock_consume_file_delay: MagicMock,
settings: SettingsWrapper, settings: Settings,
) -> None: ) -> None:
"""Test oneshot mode processes existing files.""" """Test oneshot mode processes existing files."""
target = consumption_dir / "document.pdf" target = consumption_dir / "document.pdf"
@@ -659,7 +659,7 @@ class TestCommandOneshot:
scratch_dir: Path, scratch_dir: Path,
sample_pdf: Path, sample_pdf: Path,
mock_consume_file_delay: MagicMock, mock_consume_file_delay: MagicMock,
settings: SettingsWrapper, settings: Settings,
) -> None: ) -> None:
"""Test oneshot mode processes files recursively.""" """Test oneshot mode processes files recursively."""
subdir = consumption_dir / "subdir" subdir = consumption_dir / "subdir"
@@ -681,7 +681,7 @@ class TestCommandOneshot:
consumption_dir: Path, consumption_dir: Path,
scratch_dir: Path, scratch_dir: Path,
mock_consume_file_delay: MagicMock, mock_consume_file_delay: MagicMock,
settings: SettingsWrapper, settings: Settings,
) -> None: ) -> None:
"""Test oneshot mode ignores unsupported file extensions.""" """Test oneshot mode ignores unsupported file extensions."""
target = consumption_dir / "document.xyz" target = consumption_dir / "document.xyz"
@@ -1256,7 +1256,7 @@ class TestProcessExistingFilesQueued:
consumption_dir: Path, consumption_dir: Path,
sample_pdf: Path, sample_pdf: Path,
mock_consume_file_delay: MagicMock, mock_consume_file_delay: MagicMock,
settings: SettingsWrapper, settings: Settings,
) -> None: ) -> None:
"""The set returned seeds the rescan's queued set, avoiding re-queue.""" """The set returned seeds the rescan's queued set, avoiding re-queue."""
target = consumption_dir / "document.pdf" target = consumption_dir / "document.pdf"
+80
View File
@@ -0,0 +1,80 @@
import unicodedata
import pytest
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentSource
from documents.matching import consumable_document_matches_workflow
from documents.matching import existing_document_matches_workflow
from documents.models import Document
from documents.models import Workflow
from documents.models import WorkflowTrigger
@pytest.mark.django_db
class TestMatchingNfcNormalization:
def test_consumable_document_filename_nfd_matches_nfc_pattern(
self,
tmp_path,
) -> None:
"""
GIVEN:
- A file on disk whose name is NFD-normalized
- A workflow trigger filename filter typed as NFC
WHEN:
- The consumable document is checked against the trigger
THEN:
- It matches, because both sides are normalized before comparing
"""
nfd_name = unicodedata.normalize("NFD", "Gehaltserhöhung.pdf")
nfc_pattern = unicodedata.normalize("NFC", "*Gehaltserhöhung*")
assert nfd_name != unicodedata.normalize("NFC", nfd_name)
file_path = tmp_path / nfd_name
file_path.write_bytes(b"%PDF-1.4 test")
document = ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=file_path,
)
trigger = WorkflowTrigger(
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
filter_filename=nfc_pattern,
sources=[],
)
matched, reason = consumable_document_matches_workflow(document, trigger)
assert matched, reason
def test_existing_document_filename_nfd_matches_nfc_pattern(self) -> None:
"""
GIVEN:
- A Document whose original_filename is NFD-normalized (e.g. from
before normalization was applied at consumption time)
- A workflow trigger filename filter typed as NFC
WHEN:
- The document is checked against the trigger
THEN:
- It matches, because both sides are normalized before comparing
"""
nfd_name = unicodedata.normalize("NFD", "Gehaltserhöhung.pdf")
nfc_pattern = unicodedata.normalize("NFC", "*Gehaltserhöhung*")
document = Document.objects.create(
title="Test",
content="content",
checksum="checksum",
mime_type="application/pdf",
original_filename=nfd_name,
)
workflow = Workflow.objects.create(name="Test workflow", order=0)
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
filter_filename=nfc_pattern,
)
workflow.triggers.add(trigger)
matched, reason = existing_document_matches_workflow(document, trigger)
assert matched, reason
@@ -0,0 +1,6 @@
from documents.utils import normalize_unicode
class TestNormalizeUnicode:
def test_none_passes_through(self) -> None:
assert normalize_unicode(None) is None
+2 -2
View File
@@ -1,7 +1,7 @@
from collections.abc import Generator from collections.abc import Generator
import pytest 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_default_file_extension
from documents.parsers import get_supported_file_extensions from documents.parsers import get_supported_file_extensions
@@ -14,7 +14,7 @@ from paperless.parsers.tika import TikaDocumentParser
@pytest.fixture() @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 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) test, then reset on exit so other tests see the default (Tika-disabled)
+35 -2
View File
@@ -23,6 +23,7 @@ from guardian.shortcuts import get_users_with_perms
from httpx import ConnectError from httpx import ConnectError
from httpx import HTTPError from httpx import HTTPError
from httpx import HTTPStatusError from httpx import HTTPStatusError
from pytest_django.fixtures import Settings
from pytest_httpx import HTTPXMock from pytest_httpx import HTTPXMock
from rest_framework.test import APIClient from rest_framework.test import APIClient
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
@@ -38,7 +39,6 @@ from paperless_ai.exceptions import LLMTimeoutError
if TYPE_CHECKING: if TYPE_CHECKING:
from django.db.models import QuerySet from django.db.models import QuerySet
from pytest_django.fixtures import SettingsWrapper
from documents import tasks from documents import tasks
from documents.data_models import ConsumableDocument from documents.data_models import ConsumableDocument
@@ -5356,7 +5356,7 @@ class TestDateWorkflowLocalization(
def test_document_consumption_workflow_localization( def test_document_consumption_workflow_localization(
self, self,
tmp_path: Path, tmp_path: Path,
settings: SettingsWrapper, settings: Settings,
title_template: str, title_template: str,
expected_title: str, expected_title: str,
) -> None: ) -> None:
@@ -5711,6 +5711,39 @@ class TestApplyAISuggestionsWorkflowAction(
self.assertEqual(changed, []) self.assertEqual(changed, [])
self.assertIn("AI is not enabled", "".join(cm.output)) 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: def test_invalid_configuration_leaves_document_untouched(self) -> None:
""" """
GIVEN: GIVEN:
+20
View File
@@ -1,6 +1,7 @@
import hashlib import hashlib
import logging import logging
import shutil import shutil
import unicodedata
from collections.abc import Callable from collections.abc import Callable
from collections.abc import Iterable from collections.abc import Iterable
from collections.abc import Iterator from collections.abc import Iterator
@@ -31,6 +32,25 @@ def identity(iterable: Iterable[_T]) -> Iterable[_T]:
return iterable return iterable
def normalize_unicode(value: str | None) -> str | None:
"""
Normalize a string to Unicode NFC form, or return None unchanged.
This is the single normalization pass for any user- or filesystem-supplied
text that ends up in a filename, path, or is compared/matched against one
(titles, correspondent/tag/type names, uploaded filenames, workflow and
mail rule filename/path filters). Composed (NFC) and decomposed (NFD)
forms of the same visible text are different byte sequences, which breaks
exact comparisons and filesystem lookups even though the text looks
identical. Always normalize through this function rather than calling
unicodedata.normalize() directly, so every call site agrees on the same
form.
"""
if value is None:
return None
return unicodedata.normalize("NFC", value)
class QuerySetStream(Generic[_M]): class QuerySetStream(Generic[_M]):
"""Stream a QuerySet via .iterator(chunk_size=...) instead of """Stream a QuerySet via .iterator(chunk_size=...) instead of
materializing it (plus any prefetch caches) all at once, while still materializing it (plus any prefetch caches) all at once, while still
+6 -3
View File
@@ -27,10 +27,13 @@ def versions_newest_first(documents: QuerySet[Document]) -> QuerySet[Document]:
def annotate_effective_content(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 Annotates documents with the content of their newest version unless the
to their own, so get_effective_content() can answer from the row rather queryset already carries the annotation, falling back to their own, so
than querying for the versions of each document 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( return documents.annotate(
effective_content=Coalesce( effective_content=Coalesce(
Subquery( Subquery(
+14 -5
View File
@@ -231,7 +231,9 @@ from documents.tasks import sanity_check
from documents.tasks import train_classifier from documents.tasks import train_classifier
from documents.tasks import update_document_parent_tags from documents.tasks import update_document_parent_tags
from documents.utils import get_boolean from documents.utils import get_boolean
from documents.utils import normalize_unicode
from documents.versioning import VersionResolutionError 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_latest_version_for_root
from documents.versioning import get_request_version_param from documents.versioning import get_request_version_param
from documents.versioning import get_root_document from documents.versioning import get_root_document
@@ -2067,6 +2069,7 @@ class DocumentViewSet(
try: try:
doc_name, doc_data = serializer.validated_data.get("document") doc_name, doc_data = serializer.validated_data.get("document")
doc_name = normalize_unicode(doc_name)
version_label = serializer.validated_data.get("version_label") version_label = serializer.validated_data.get("version_label")
t = int(mktime(datetime.now().timetuple())) t = int(mktime(datetime.now().timetuple()))
@@ -2091,6 +2094,7 @@ class DocumentViewSet(
if version_label: if version_label:
overrides.version_label = version_label.strip() overrides.version_label = version_label.strip()
if request.user is not None: if request.user is not None:
overrides.owner_id = request.user.id
overrides.actor_id = request.user.id overrides.actor_id = request.user.id
async_task = consume_file.apply_async( async_task = consume_file.apply_async(
@@ -2815,6 +2819,7 @@ class DocumentSelectionMixin:
filtered_documents = DocumentFilterSet( filtered_documents = DocumentFilterSet(
data=orm_filters, data=orm_filters,
queryset=permitted_documents, queryset=permitted_documents,
user=user,
).qs.distinct() ).qs.distinct()
# tantivy-filtered docs (if search params provided) # tantivy-filtered docs (if search params provided)
search_filtered_ids = self._get_search_document_ids( search_filtered_ids = self._get_search_document_ids(
@@ -3331,7 +3336,7 @@ class PostDocumentView(GenericAPIView[Any]):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
doc_name, doc_data = serializer.validated_data.get("document") doc_name, doc_data = serializer.validated_data.get("document")
doc_name = normalize("NFC", doc_name) doc_name = normalize_unicode(doc_name)
correspondent_id = serializer.validated_data.get("correspondent") correspondent_id = serializer.validated_data.get("correspondent")
document_type_id = serializer.validated_data.get("document_type") document_type_id = serializer.validated_data.get("document_type")
storage_path_id = serializer.validated_data.get("storage_path") storage_path_id = serializer.validated_data.get("storage_path")
@@ -3630,8 +3635,13 @@ class GlobalSearchView(PassUserMixin):
OBJECT_LIMIT = 3 OBJECT_LIMIT = 3
docs = [] docs = []
if request.user.has_perm("documents.view_document"): if request.user.has_perm("documents.view_document"):
all_docs = Document.objects.filter( # Never more than OBJECT_LIMIT rows come back here, so annotating
id__in=permitted_document_ids(request.user), # 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: if db_only:
docs = all_docs.filter(title__icontains=query)[:OBJECT_LIMIT] docs = all_docs.filter(title__icontains=query)[:OBJECT_LIMIT]
@@ -5467,8 +5477,7 @@ class TrashView(ListModelMixin, PassUserMixin):
from documents.search import get_backend from documents.search import get_backend
with get_backend().batch_update() as batch: with get_backend().batch_update() as batch:
for doc in restored: batch.add_or_update_ids([doc.pk for doc in restored])
batch.add_or_update(doc)
elif action == "empty": elif action == "empty":
if doc_ids is None: if doc_ids is None:
doc_ids = [doc.id for doc in docs] doc_ids = [doc.id for doc in docs]
+10
View File
@@ -138,6 +138,16 @@ def apply_ai_suggestions_to_document(
) )
return [] 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 # Workflows run without a user, so we use the document owner
owner = document.owner owner = document.owner
File diff suppressed because it is too large Load Diff
+21 -7
View File
@@ -133,21 +133,27 @@ class BarcodeConfig(BaseConfig):
app_config = self._get_config_instance() app_config = self._get_config_instance()
self.barcodes_enabled = ( 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 = ( self.barcode_enable_tiff_support = (
app_config.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 = ( self.barcode_string = (
app_config.barcode_string or settings.CONSUMER_BARCODE_STRING app_config.barcode_string or settings.CONSUMER_BARCODE_STRING
) )
self.barcode_retain_split_pages = ( self.barcode_retain_split_pages = (
app_config.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 = ( 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 = ( self.barcode_asn_prefix = (
app_config.barcode_asn_prefix or settings.CONSUMER_ASN_BARCODE_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 app_config.barcode_max_pages or settings.CONSUMER_BARCODE_MAX_PAGES
) )
self.barcode_enable_tag = ( 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 = ( self.barcode_tag_mapping = (
app_config.barcode_tag_mapping or settings.CONSUMER_TAG_BARCODE_MAPPING app_config.barcode_tag_mapping or settings.CONSUMER_TAG_BARCODE_MAPPING
) )
self.barcode_tag_split = ( 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: def __post_init__(self) -> None:
app_config = self._get_config_instance() 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 = ( self.llm_embedding_backend = (
app_config.llm_embedding_backend or settings.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),
]
-1
View File
@@ -348,7 +348,6 @@ class ApplicationConfiguration(AbstractSingletonModel):
ai_enabled = models.BooleanField( ai_enabled = models.BooleanField(
verbose_name=_("Enables AI features"), verbose_name=_("Enables AI features"),
null=True, null=True,
default=False,
) )
llm_embedding_backend = models.CharField( llm_embedding_backend = models.CharField(
+8
View File
@@ -1,4 +1,5 @@
import logging import logging
import os
from io import BytesIO from io import BytesIO
import magic import magic
@@ -212,6 +213,7 @@ class ProfileSerializer(PasswordValidationMixin, serializers.ModelSerializer[Use
class ApplicationConfigurationSerializer( class ApplicationConfigurationSerializer(
serializers.ModelSerializer[ApplicationConfiguration], serializers.ModelSerializer[ApplicationConfiguration],
): ):
externally_configured_variables = serializers.SerializerMethodField()
user_args = serializers.JSONField(binary=True, allow_null=True) user_args = serializers.JSONField(binary=True, allow_null=True)
barcode_tag_mapping = serializers.JSONField(binary=True, allow_null=True) barcode_tag_mapping = serializers.JSONField(binary=True, allow_null=True)
llm_api_key = ObfuscatedPasswordField( llm_api_key = ObfuscatedPasswordField(
@@ -227,6 +229,12 @@ class ApplicationConfigurationSerializer(
OBFUSCATED_FIELDS = ("llm_api_key", "remote_ocr_api_key") 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): def run_validation(self, data):
# Empty strings treated as None to avoid unexpected behavior # Empty strings treated as None to avoid unexpected behavior
if "user_args" in data and data["user_args"] == "": if "user_args" in data and data["user_args"] == "":
+3
View File
@@ -701,6 +701,9 @@ CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
CELERY_BROKER_TRANSPORT_OPTIONS = { CELERY_BROKER_TRANSPORT_OPTIONS = {
"global_keyprefix": _REDIS_KEY_PREFIX, "global_keyprefix": _REDIS_KEY_PREFIX,
} }
CELERY_RESULT_BACKEND_TRANSPORT_OPTIONS = {
"global_keyprefix": _REDIS_KEY_PREFIX,
}
CELERY_TASK_TRACK_STARTED = True CELERY_TASK_TRACK_STARTED = True
CELERY_TASK_TIME_LIMIT: Final[int] = get_int_from_env("PAPERLESS_WORKER_TIMEOUT", 1800) CELERY_TASK_TIME_LIMIT: Final[int] = get_int_from_env("PAPERLESS_WORKER_TIMEOUT", 1800)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 16 KiB

+7 -7
View File
@@ -24,7 +24,7 @@ if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock from unittest.mock import MagicMock
from pytest_django.fixtures import SettingsWrapper from pytest_django.fixtures import Settings
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
#: Type for the ``make_tesseract_parser`` fixture factory. #: Type for the ``make_tesseract_parser`` fixture factory.
@@ -131,9 +131,9 @@ def empty_remote_ocr_app_config(mocker: MockerFixture) -> MagicMock:
@pytest.fixture() @pytest.fixture()
def azure_settings( def azure_settings(
settings: SettingsWrapper, settings: Settings,
empty_remote_ocr_app_config: MagicMock, empty_remote_ocr_app_config: MagicMock,
) -> SettingsWrapper: ) -> Settings:
"""Configure Django settings for a valid Azure AI OCR engine. """Configure Django settings for a valid Azure AI OCR engine.
Sets ``REMOTE_OCR_ENGINE``, ``REMOTE_OCR_API_KEY``, and Sets ``REMOTE_OCR_ENGINE``, ``REMOTE_OCR_API_KEY``, and
@@ -142,7 +142,7 @@ def azure_settings(
Returns Returns
------- -------
SettingsWrapper Settings
The modified settings object (for chaining further overrides). The modified settings object (for chaining further overrides).
""" """
settings.REMOTE_OCR_ENGINE = "azureai" settings.REMOTE_OCR_ENGINE = "azureai"
@@ -153,14 +153,14 @@ def azure_settings(
@pytest.fixture() @pytest.fixture()
def no_engine_settings( def no_engine_settings(
settings: SettingsWrapper, settings: Settings,
empty_remote_ocr_app_config: MagicMock, empty_remote_ocr_app_config: MagicMock,
) -> SettingsWrapper: ) -> Settings:
"""Configure Django settings with no remote engine configured. """Configure Django settings with no remote engine configured.
Returns Returns
------- -------
SettingsWrapper Settings
The modified settings object. The modified settings object.
""" """
settings.REMOTE_OCR_ENGINE = None settings.REMOTE_OCR_ENGINE = None
@@ -7,7 +7,7 @@ import httpx
import pytest import pytest
from django.test.html import parse_html from django.test.html import parse_html
from django.utils import timezone from django.utils import timezone
from pytest_django.fixtures import SettingsWrapper from pytest_django.fixtures import Settings
from pytest_httpx import HTTPXMock from pytest_httpx import HTTPXMock
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
@@ -428,7 +428,7 @@ class TestTikaHtmlParse:
def test_tika_parse_unreachable( def test_tika_parse_unreachable(
self, self,
settings: SettingsWrapper, settings: Settings,
mail_parser: MailDocumentParser, mail_parser: MailDocumentParser,
) -> None: ) -> None:
""" """
@@ -30,7 +30,7 @@ if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path from pathlib import Path
from pytest_django.fixtures import SettingsWrapper from pytest_django.fixtures import Settings
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
@@ -67,7 +67,7 @@ def make_azure_mock() -> Callable[[str], Mock]:
@pytest.fixture() @pytest.fixture()
def azure_client( def azure_client(
azure_settings: SettingsWrapper, azure_settings: Settings,
make_azure_mock: Callable[[str], Mock], make_azure_mock: Callable[[str], Mock],
mocker: MockerFixture, mocker: MockerFixture,
) -> Mock: ) -> Mock:
@@ -83,7 +83,7 @@ def azure_client(
@pytest.fixture() @pytest.fixture()
def failing_azure_client( def failing_azure_client(
azure_settings: SettingsWrapper, azure_settings: Settings,
mocker: MockerFixture, mocker: MockerFixture,
) -> Mock: ) -> Mock:
"""Patch the Azure DI client to raise RuntimeError on every call. """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( def test_score_returns_none_when_api_key_missing(
self, self,
no_engine_settings: SettingsWrapper, no_engine_settings: Settings,
) -> None: ) -> None:
no_engine_settings.REMOTE_OCR_ENGINE = "azureai" no_engine_settings.REMOTE_OCR_ENGINE = "azureai"
no_engine_settings.REMOTE_OCR_ENDPOINT = ( no_engine_settings.REMOTE_OCR_ENDPOINT = (
@@ -210,7 +210,7 @@ class TestRemoteParserScore:
def test_score_returns_none_when_endpoint_missing( def test_score_returns_none_when_endpoint_missing(
self, self,
no_engine_settings: SettingsWrapper, no_engine_settings: Settings,
) -> None: ) -> None:
no_engine_settings.REMOTE_OCR_ENGINE = "azureai" no_engine_settings.REMOTE_OCR_ENGINE = "azureai"
no_engine_settings.REMOTE_OCR_API_KEY = "key" no_engine_settings.REMOTE_OCR_API_KEY = "key"
@@ -231,7 +231,7 @@ class TestRemoteParserScore:
@pytest.mark.django_db @pytest.mark.django_db
def test_score_uses_app_config_when_env_unset( def test_score_uses_app_config_when_env_unset(
self, self,
settings: SettingsWrapper, settings: Settings,
) -> None: ) -> None:
"""The app config alone is enough to activate the parser.""" """The app config alone is enough to activate the parser."""
settings.REMOTE_OCR_ENGINE = None settings.REMOTE_OCR_ENGINE = None
@@ -5,7 +5,7 @@ from pathlib import Path
import pytest import pytest
from httpx import codes from httpx import codes
from pytest_django.fixtures import SettingsWrapper from pytest_django.fixtures import Settings
from pytest_httpx import HTTPXMock from pytest_httpx import HTTPXMock
from documents.parsers import ParseError from documents.parsers import ParseError
@@ -27,7 +27,7 @@ class TestTikaParserRegistryInterface:
def test_score_returns_none_when_tika_disabled( def test_score_returns_none_when_tika_disabled(
self, self,
settings: SettingsWrapper, settings: Settings,
) -> None: ) -> None:
settings.TIKA_ENABLED = False settings.TIKA_ENABLED = False
result = TikaDocumentParser.score( result = TikaDocumentParser.score(
@@ -38,7 +38,7 @@ class TestTikaParserRegistryInterface:
def test_score_returns_int_when_tika_enabled( def test_score_returns_int_when_tika_enabled(
self, self,
settings: SettingsWrapper, settings: Settings,
) -> None: ) -> None:
settings.TIKA_ENABLED = True settings.TIKA_ENABLED = True
result = TikaDocumentParser.score( result = TikaDocumentParser.score(
@@ -49,7 +49,7 @@ class TestTikaParserRegistryInterface:
def test_score_returns_none_for_unsupported_mime( def test_score_returns_none_for_unsupported_mime(
self, self,
settings: SettingsWrapper, settings: Settings,
) -> None: ) -> None:
settings.TIKA_ENABLED = True settings.TIKA_ENABLED = True
result = TikaDocumentParser.score("application/pdf", "doc.pdf") result = TikaDocumentParser.score("application/pdf", "doc.pdf")
@@ -90,7 +90,7 @@ class TestTikaParser:
def test_parse( def test_parse(
self, self,
httpx_mock: HTTPXMock, httpx_mock: HTTPXMock,
settings: SettingsWrapper, settings: Settings,
tika_parser: TikaDocumentParser, tika_parser: TikaDocumentParser,
sample_odt_file: Path, sample_odt_file: Path,
) -> None: ) -> None:
@@ -179,7 +179,7 @@ class TestTikaParser:
setting_value: str, setting_value: str,
expected_form_value: str, expected_form_value: str,
httpx_mock: HTTPXMock, httpx_mock: HTTPXMock,
settings: SettingsWrapper, settings: Settings,
sample_odt_file: Path, sample_odt_file: Path,
) -> None: ) -> None:
""" """
+8 -8
View File
@@ -10,7 +10,7 @@ from django.contrib.auth.models import User
from django.forms import ValidationError from django.forms import ValidationError
from django.http import HttpRequest from django.http import HttpRequest
from django.urls import reverse from django.urls import reverse
from pytest_django.fixtures import SettingsWrapper from pytest_django.fixtures import Settings
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
@@ -19,7 +19,7 @@ from paperless.adapter import DrfTokenStrategy
@pytest.mark.django_db @pytest.mark.django_db
class TestCustomAccountAdapter: 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() adapter = get_adapter()
# With no accounts, signups should be allowed # With no accounts, signups should be allowed
@@ -33,7 +33,7 @@ class TestCustomAccountAdapter:
settings.ACCOUNT_ALLOW_SIGNUPS = False settings.ACCOUNT_ALLOW_SIGNUPS = False
assert not adapter.is_open_for_signup(None) 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 = HttpRequest()
request.get_host = lambda: "example.com" request.get_host = lambda: "example.com"
with context.request_context(request): with context.request_context(request):
@@ -55,7 +55,7 @@ class TestCustomAccountAdapter:
def test_pre_authenticate( def test_pre_authenticate(
self, self,
settings: SettingsWrapper, settings: Settings,
mocker: MockerFixture, mocker: MockerFixture,
) -> None: ) -> None:
mocker.patch("allauth.core.internal.ratelimit.consume", return_value=True) mocker.patch("allauth.core.internal.ratelimit.consume", return_value=True)
@@ -70,7 +70,7 @@ class TestCustomAccountAdapter:
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
adapter.pre_authenticate(request) 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 = HttpRequest()
request.get_host = lambda: "foo.org" request.get_host = lambda: "foo.org"
with context.request_context(request): with context.request_context(request):
@@ -87,7 +87,7 @@ class TestCustomAccountAdapter:
def test_save_user_adds_groups( def test_save_user_adds_groups(
self, self,
settings: SettingsWrapper, settings: Settings,
mocker: MockerFixture, mocker: MockerFixture,
) -> None: ) -> None:
settings.ACCOUNT_DEFAULT_GROUPS = ["group1", "group2"] settings.ACCOUNT_DEFAULT_GROUPS = ["group1", "group2"]
@@ -130,7 +130,7 @@ class TestCustomAccountAdapter:
class TestCustomSocialAccountAdapter: class TestCustomSocialAccountAdapter:
@pytest.mark.django_db @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() adapter = get_social_adapter()
settings.SOCIALACCOUNT_ALLOW_SIGNUPS = True settings.SOCIALACCOUNT_ALLOW_SIGNUPS = True
@@ -146,7 +146,7 @@ class TestCustomSocialAccountAdapter:
@pytest.mark.django_db @pytest.mark.django_db
def test_save_user_adds_groups( def test_save_user_adds_groups(
self, self,
settings: SettingsWrapper, settings: Settings,
mocker: MockerFixture, mocker: MockerFixture,
) -> None: ) -> None:
settings.SOCIAL_ACCOUNT_DEFAULT_GROUPS = ["group1", "group2"] settings.SOCIAL_ACCOUNT_DEFAULT_GROUPS = ["group1", "group2"]
+14 -14
View File
@@ -8,7 +8,7 @@ import pytest
from django.core.checks import ERROR from django.core.checks import ERROR
from django.core.checks import Error from django.core.checks import Error
from django.core.checks import Warning from django.core.checks import Warning
from pytest_django.fixtures import SettingsWrapper from pytest_django.fixtures import Settings
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from paperless.checks import audit_log_check from paperless.checks import audit_log_check
@@ -31,7 +31,7 @@ class PaperlessTestDirs:
# TODO: consolidate with documents/tests/conftest.py PaperlessDirs/paperless_dirs # TODO: consolidate with documents/tests/conftest.py PaperlessDirs/paperless_dirs
# once the paperless and documents test suites are ready to share fixtures. # once the paperless and documents test suites are ready to share fixtures.
@pytest.fixture() @pytest.fixture()
def directories(tmp_path: Path, settings: SettingsWrapper) -> PaperlessTestDirs: def directories(tmp_path: Path, settings: Settings) -> PaperlessTestDirs:
data_dir = tmp_path / "data" data_dir = tmp_path / "data"
media_dir = tmp_path / "media" media_dir = tmp_path / "media"
consumption_dir = tmp_path / "consumption" consumption_dir = tmp_path / "consumption"
@@ -54,7 +54,7 @@ class TestChecks:
def test_binaries(self) -> None: def test_binaries(self) -> None:
assert binaries_check(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" settings.CONVERT_BINARY = "uuuhh"
assert len(binaries_check(None)) == 1 assert len(binaries_check(None)) == 1
@@ -62,7 +62,7 @@ class TestChecks:
def test_paths_check(self) -> None: def test_paths_check(self) -> None:
assert paths_check(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.MEDIA_ROOT = Path("uuh")
settings.DATA_DIR = Path("whatever") settings.DATA_DIR = Path("whatever")
settings.CONSUMPTION_DIR = Path("idontcare") settings.CONSUMPTION_DIR = Path("idontcare")
@@ -89,11 +89,11 @@ class TestChecks:
for msg in msgs: for msg in msgs:
assert msg.msg.endswith("is not writeable") 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 settings.DEBUG = False
assert debug_mode_check(None) == [] assert debug_mode_check(None) == []
def test_debug_enabled(self, settings: SettingsWrapper) -> None: def test_debug_enabled(self, settings: Settings) -> None:
settings.DEBUG = True settings.DEBUG = True
assert len(debug_mode_check(None)) == 1 assert len(debug_mode_check(None)) == 1
@@ -150,7 +150,7 @@ class TestOcrSettingsChecks:
) )
def test_invalid_setting_produces_one_error( def test_invalid_setting_produces_one_error(
self, self,
settings: SettingsWrapper, settings: Settings,
setting: str, setting: str,
value: str, value: str,
expected_msg: str, expected_msg: str,
@@ -173,7 +173,7 @@ class TestOcrSettingsChecks:
class TestTimezoneSettingsChecks: class TestTimezoneSettingsChecks:
def test_invalid_timezone(self, settings: SettingsWrapper) -> None: def test_invalid_timezone(self, settings: Settings) -> None:
""" """
GIVEN: GIVEN:
- Default settings - Default settings
@@ -192,7 +192,7 @@ class TestTimezoneSettingsChecks:
class TestEmailCertSettingsChecks: class TestEmailCertSettingsChecks:
def test_not_valid_file(self, settings: SettingsWrapper) -> None: def test_not_valid_file(self, settings: Settings) -> None:
""" """
GIVEN: GIVEN:
- Default settings - Default settings
@@ -215,7 +215,7 @@ class TestEmailCertSettingsChecks:
class TestAuditLogChecks: class TestAuditLogChecks:
def test_was_enabled_once( def test_was_enabled_once(
self, self,
settings: SettingsWrapper, settings: Settings,
mocker: MockerFixture, mocker: MockerFixture,
) -> None: ) -> None:
""" """
@@ -634,7 +634,7 @@ class TestTesseractChecks:
def test_default_language(self) -> None: def test_default_language(self) -> None:
check_default_language_available(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 = "" settings.OCR_LANGUAGE = ""
@@ -649,7 +649,7 @@ class TestTesseractChecks:
def test_invalid_language( def test_invalid_language(
self, self,
settings: SettingsWrapper, settings: Settings,
mocker: MockerFixture, mocker: MockerFixture,
) -> None: ) -> None:
@@ -668,7 +668,7 @@ class TestTesseractChecks:
def test_multi_part_language( def test_multi_part_language(
self, self,
settings: SettingsWrapper, settings: Settings,
mocker: MockerFixture, mocker: MockerFixture,
) -> None: ) -> None:
""" """
@@ -692,7 +692,7 @@ class TestTesseractChecks:
def test_multi_part_language_bad_format( def test_multi_part_language_bad_format(
self, self,
settings: SettingsWrapper, settings: Settings,
mocker: MockerFixture, mocker: MockerFixture,
) -> None: ) -> 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)
+3 -3
View File
@@ -1,13 +1,13 @@
from pathlib import Path from pathlib import Path
from django.test import Client from django.test import Client
from pytest_django.fixtures import SettingsWrapper from pytest_django.fixtures import Settings
def test_favicon_view( def test_favicon_view(
client: Client, client: Client,
tmp_path: Path, tmp_path: Path,
settings: SettingsWrapper, settings: Settings,
) -> None: ) -> None:
favicon_path = tmp_path / "paperless" / "img" / "favicon.ico" favicon_path = tmp_path / "paperless" / "img" / "favicon.ico"
favicon_path.parent.mkdir(parents=True) favicon_path.parent.mkdir(parents=True)
@@ -24,7 +24,7 @@ def test_favicon_view(
def test_favicon_view_missing_file( def test_favicon_view_missing_file(
client: Client, client: Client,
tmp_path: Path, tmp_path: Path,
settings: SettingsWrapper, settings: Settings,
) -> None: ) -> None:
settings.STATIC_ROOT = tmp_path settings.STATIC_ROOT = tmp_path
response = client.get("/favicon.ico") response = client.get("/favicon.ico")
+6 -1
View File
@@ -443,8 +443,13 @@ class ApplicationConfigurationViewSet(ModelViewSet[ApplicationConfiguration]):
new_llm_embedding_backend = ( new_llm_embedding_backend = (
new_instance.llm_embedding_backend or settings.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_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_llm_embedding_chunk_size = (
new_instance.llm_embedding_chunk_size or settings.LLM_EMBEDDING_CHUNK_SIZE new_instance.llm_embedding_chunk_size or settings.LLM_EMBEDDING_CHUNK_SIZE
+2 -2
View File
@@ -3,11 +3,11 @@ from pathlib import Path
import pytest import pytest
import pytest_mock import pytest_mock
from llama_index.core.base.embeddings.base import BaseEmbedding from llama_index.core.base.embeddings.base import BaseEmbedding
from pytest_django.fixtures import SettingsWrapper from pytest_django.fixtures import Settings
@pytest.fixture @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_DIR = tmp_path
settings.LLM_INDEX_LOCK = tmp_path / "index.lock" settings.LLM_INDEX_LOCK = tmp_path / "index.lock"
settings.LLM_INDEX_RWLOCK = tmp_path / "llmindex.rwlock.db" settings.LLM_INDEX_RWLOCK = tmp_path / "llmindex.rwlock.db"
+2 -2
View File
@@ -8,7 +8,7 @@ import pytest
from django.conf import settings from django.conf import settings
from filelock import ReadWriteLock from filelock import ReadWriteLock
from llama_index.core.schema import TextNode 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 import indexing
from paperless_ai.vector_store import PaperlessSqliteVecVectorStore from paperless_ai.vector_store import PaperlessSqliteVecVectorStore
@@ -69,7 +69,7 @@ class TestCompactionLock:
def test_compaction_skips_when_a_reader_holds_the_lock( def test_compaction_skips_when_a_reader_holds_the_lock(
self, self,
temp_llm_index_dir: Path, temp_llm_index_dir: Path,
settings: SettingsWrapper, settings: Settings,
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
) -> None: ) -> None:
_seed_bloated_index(temp_llm_index_dir) _seed_bloated_index(temp_llm_index_dir)
+9 -7
View File
@@ -6,7 +6,6 @@ import socket
import ssl import ssl
import tempfile import tempfile
import traceback import traceback
import unicodedata
from datetime import date from datetime import date
from datetime import timedelta from datetime import timedelta
from fnmatch import fnmatch from fnmatch import fnmatch
@@ -45,6 +44,7 @@ from documents.models import Correspondent
from documents.models import PaperlessTask from documents.models import PaperlessTask
from documents.parsers import is_mime_type_supported from documents.parsers import is_mime_type_supported
from documents.tasks import consume_file from documents.tasks import consume_file
from documents.utils import normalize_unicode
from paperless.network import is_public_ip from paperless.network import is_public_ip
from paperless.network import resolve_hostname_ips from paperless.network import resolve_hostname_ips
from paperless_mail.models import MailAccount from paperless_mail.models import MailAccount
@@ -617,10 +617,10 @@ class MailAccountHandler(LoggingMixin):
rule: MailRule, rule: MailRule,
) -> str | None: ) -> str | None:
if rule.assign_title_from == MailRule.TitleSource.FROM_SUBJECT: if rule.assign_title_from == MailRule.TitleSource.FROM_SUBJECT:
return unicodedata.normalize("NFC", message.subject) return normalize_unicode(message.subject)
elif rule.assign_title_from == MailRule.TitleSource.FROM_FILENAME: elif rule.assign_title_from == MailRule.TitleSource.FROM_FILENAME:
return unicodedata.normalize("NFC", Path(att.filename).stem) return normalize_unicode(Path(att.filename).stem)
elif rule.assign_title_from == MailRule.TitleSource.NONE: elif rule.assign_title_from == MailRule.TitleSource.NONE:
return None return None
@@ -1004,6 +1004,8 @@ class MailAccountHandler(LoggingMixin):
consume_tasks = [] consume_tasks = []
for att in message.attachments: for att in message.attachments:
attachment_filename = normalize_unicode(att.filename)
if ( if (
att.content_disposition != "attachment" att.content_disposition != "attachment"
and rule.attachment_type and rule.attachment_type
@@ -1018,7 +1020,7 @@ class MailAccountHandler(LoggingMixin):
if not self.filename_inclusion_matches( if not self.filename_inclusion_matches(
rule.filter_attachment_filename_include, rule.filter_attachment_filename_include,
att.filename, attachment_filename,
): ):
# Force the filename and pattern to the lowercase # Force the filename and pattern to the lowercase
# as this is system dependent otherwise # as this is system dependent otherwise
@@ -1030,7 +1032,7 @@ class MailAccountHandler(LoggingMixin):
continue continue
elif self.filename_exclusion_matches( elif self.filename_exclusion_matches(
rule.filter_attachment_filename_exclude, rule.filter_attachment_filename_exclude,
att.filename, attachment_filename,
): ):
self.log.debug( self.log.debug(
f"Rule {rule}: " f"Rule {rule}: "
@@ -1064,7 +1066,7 @@ class MailAccountHandler(LoggingMixin):
) )
attachment_name = pathvalidate.sanitize_filename( attachment_name = pathvalidate.sanitize_filename(
unicodedata.normalize("NFC", att.filename), attachment_filename,
) )
if attachment_name: if attachment_name:
temp_filename = temp_dir / attachment_name temp_filename = temp_dir / attachment_name
@@ -1175,7 +1177,7 @@ class MailAccountHandler(LoggingMixin):
doc_overrides = DocumentMetadataOverrides( doc_overrides = DocumentMetadataOverrides(
title=message.subject, title=message.subject,
filename=pathvalidate.sanitize_filename( filename=pathvalidate.sanitize_filename(
unicodedata.normalize("NFC", f"{message.subject}.eml"), normalize_unicode(f"{message.subject}.eml"),
), ),
correspondent_id=correspondent.id if correspondent else None, correspondent_id=correspondent.id if correspondent else None,
document_type_id=doc_type.id if doc_type else None, document_type_id=doc_type.id if doc_type else None,
+7
View File
@@ -8,6 +8,7 @@ from documents.serialisers import CorrespondentField
from documents.serialisers import DocumentTypeField from documents.serialisers import DocumentTypeField
from documents.serialisers import OwnedObjectSerializer from documents.serialisers import OwnedObjectSerializer
from documents.serialisers import TagsField from documents.serialisers import TagsField
from documents.utils import normalize_unicode
from paperless_mail.models import MailAccount from paperless_mail.models import MailAccount
from paperless_mail.models import MailRule from paperless_mail.models import MailRule
from paperless_mail.models import ProcessedMail from paperless_mail.models import ProcessedMail
@@ -161,6 +162,12 @@ class MailRuleSerializer(OwnedObjectSerializer):
raise serializers.ValidationError("Maximum mail age is unreasonably large.") raise serializers.ValidationError("Maximum mail age is unreasonably large.")
return value return value
def validate_filter_attachment_filename_include(self, value):
return normalize_unicode(value)
def validate_filter_attachment_filename_exclude(self, value):
return normalize_unicode(value)
class ProcessedMailSerializer(OwnedObjectSerializer): class ProcessedMailSerializer(OwnedObjectSerializer):
class Meta: class Meta:
Generated
+96 -114
View File
@@ -1012,15 +1012,15 @@ wheels = [
[[package]] [[package]]
name = "django-guardian" name = "django-guardian"
version = "3.3.3" version = "3.4.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "django" }, { name = "django" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/f4/27/2924c6ebb788d5b76ca9f28ba4da82a2d828e56bb0f9b973dd523e797da4/django_guardian-3.3.3.tar.gz", hash = "sha256:fa9be851e5e7df4fccd0b0d7a171042cdbfaf0412a9aa54c56361d642eaccff1", size = 111083, upload-time = "2026-07-22T23:35:21.784Z" } sdist = { url = "https://files.pythonhosted.org/packages/89/c7/0a2cea15a432bd97fffa50b0a01be6018e30b9cdb9775ce33c41f6994fb5/django_guardian-3.4.0.tar.gz", hash = "sha256:f7bd2d673c018e28970c6f98a5bdeeaaf294b9ca7726586695cff01f15db7ef0", size = 111700, upload-time = "2026-08-28T10:04:07.701Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/40/e4/68a8224d9746f51e39e5f2442c240424c1545f63a60e28dc63735104a2cd/django_guardian-3.3.3-py3-none-any.whl", hash = "sha256:490c04c81564a15470a95cd39a5844c5898adefa4334eed6a70aa7bb41a0b7e3", size = 147566, upload-time = "2026-07-22T23:35:20.158Z" }, { url = "https://files.pythonhosted.org/packages/32/6d/518729624af9e29a7ea7f4b429ddca56fd6af3462c835b0e42b494680474/django_guardian-3.4.0-py3-none-any.whl", hash = "sha256:73380fcf6815b1de15dea9cbfc931d20afdae30852228c9be2f286e0e84950c8", size = 148072, upload-time = "2026-08-28T10:04:06.056Z" },
] ]
[[package]] [[package]]
@@ -1095,11 +1095,11 @@ wheels = [
[[package]] [[package]]
name = "django-treenode" name = "django-treenode"
version = "0.24.0" version = "0.25.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/71/8b/ff8bca02000c1d211e8eb29111fe45fd725992fc53511384b57824225333/django_treenode-0.24.0.tar.gz", hash = "sha256:7ad2198620c285ebaa9a75d0bdcb9164c5e88c084f4c3e8f765ed0fa44cbd0d6", size = 28447, upload-time = "2026-04-15T19:52:28.502Z" } sdist = { url = "https://files.pythonhosted.org/packages/cb/2b/09f04b44b3583a911ae4f5d0e3360a23d529704c9e9115fce2520b7d4420/django_treenode-0.25.0.tar.gz", hash = "sha256:d14d0eb3571c227ce11147210d62a8f87b568bbb30fa76a5be9b9e6d514157c3", size = 29951, upload-time = "2026-08-26T00:20:13.489Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/5a/c0a6c96d33bc86827ef13397fb4d4d83a49d067961a4225702148b20ced9/django_treenode-0.24.0-py3-none-any.whl", hash = "sha256:ed9c14fb3eac31e8279f11debc3d024e82e570be6624b3d99dbd94459b6278b5", size = 22089, upload-time = "2026-04-15T19:52:27.208Z" }, { url = "https://files.pythonhosted.org/packages/e1/ac/2140e8c20e2a47457ec23302273527d3430bf5c09edb8940d74c0725c138/django_treenode-0.25.0-py3-none-any.whl", hash = "sha256:f47caab5440420870b6287602b3ee401ca354416d44b24ab97579be5ea658927", size = 22343, upload-time = "2026-08-26T00:20:12.078Z" },
] ]
[[package]] [[package]]
@@ -1153,14 +1153,14 @@ wheels = [
[[package]] [[package]]
name = "drf-spectacular-sidecar" name = "drf-spectacular-sidecar"
version = "2026.7.1" version = "2026.8.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "django" }, { name = "django" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/7a/51/9e038d14bf51a0bd051e8bcb690287349c908fa7ba69021d9f3e5d5ac51f/drf_spectacular_sidecar-2026.7.1.tar.gz", hash = "sha256:40113c4066c7bc3ef15a7ce1c40cda227a907a9986748024a813a9e0595eba25", size = 2593211, upload-time = "2026-07-01T13:39:06.084Z" } sdist = { url = "https://files.pythonhosted.org/packages/28/82/eb18e2108b8ceaa50a01d2cdb35ab5ce198c18f062448b2d29d6e89aabeb/drf_spectacular_sidecar-2026.8.1.tar.gz", hash = "sha256:9d9ee678e5ec70cdda2c7ed95cafcdb6141664dc3e11def8a8599c8ad36cf6f5", size = 2603635, upload-time = "2026-08-01T12:09:03.088Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/76/d08f5c79f7643dbff4512605c28b75481966ed6c8cc9b397c9dd2ee91cd1/drf_spectacular_sidecar-2026.7.1-py3-none-any.whl", hash = "sha256:bc6d50c9b64660e45e09296d39553b3e759eedd825fc41d631ee5b3f88e0c5de", size = 2617384, upload-time = "2026-07-01T13:39:03.787Z" }, { url = "https://files.pythonhosted.org/packages/3e/5b/5ea443b8c24b4a4815a6467a158c4511dfe8bed83f8861a27bdb29c4fc47/drf_spectacular_sidecar-2026.8.1-py3-none-any.whl", hash = "sha256:6b8784d3e8c6bd9aa740b14e874640697ab1bc67f5232d74fdd9ef84eaea6bc3", size = 2626283, upload-time = "2026-08-01T12:09:01.705Z" },
] ]
[[package]] [[package]]
@@ -1194,11 +1194,11 @@ wheels = [
[[package]] [[package]]
name = "faker" name = "faker"
version = "40.36.0" version = "40.37.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/98/d2/026af1e002bbc6df534d1f8262b18ec79a974f928e9290bfbfdfe7c7b2af/faker-40.36.0.tar.gz", hash = "sha256:754048c76c03afa7de83eee8f4bcee3cf668cbb7d995f54a4e9678db7f110308", size = 2025903, upload-time = "2026-07-24T21:11:33.088Z" } sdist = { url = "https://files.pythonhosted.org/packages/f6/fb/35acc76128d5ee8983d940da871cc8eba876e7b0e9e6bd402ecd7104dcdc/faker-40.37.0.tar.gz", hash = "sha256:a92dff7f310e61fb544c61720e15edb2e7448bc33d15a321a99e9ab7b94abf54", size = 2025920, upload-time = "2026-08-21T16:33:16.261Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/50/9a/b947ed175ce9a0dcb070ccf3607f0ce8720cfb5ed1a36166a150b2acd5af/faker-40.36.0-py3-none-any.whl", hash = "sha256:82b9497d9cfe017048075bcf969298a74b1b6e39f5e4dad1211085d1133f7b62", size = 2062829, upload-time = "2026-07-24T21:11:31.37Z" }, { url = "https://files.pythonhosted.org/packages/3e/22/bf589b6b2c7527047f55450358fbe5961aeaadf168d6b51a1a1c67da497f/faker-40.37.0-py3-none-any.whl", hash = "sha256:ddbafa55c94d5b69c08ced3a7f202614204a02e07ba6548c729b8d18acc0b490", size = 2062853, upload-time = "2026-08-21T16:33:14.516Z" },
] ]
[[package]] [[package]]
@@ -1850,11 +1850,11 @@ wheels = [
[[package]] [[package]]
name = "imap-tools" name = "imap-tools"
version = "1.14.0" version = "1.15.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/41/66/d52b6c7740aa81678404a9039668ab2b4887f46055290f232229a2d5b612/imap_tools-1.14.0.tar.gz", hash = "sha256:d730a35763c09ca02be45b93c8d5d638d9f098876c95841c7ce4d337ede403da", size = 48268, upload-time = "2026-07-24T06:23:43.804Z" } sdist = { url = "https://files.pythonhosted.org/packages/59/4e/acf9a7d59932548bc749fe0f5ae852e6da00b640303ed72786f697944b69/imap_tools-1.15.0.tar.gz", hash = "sha256:a90f948549ab000d96caf17c90e63b114f8788ba4a0d3f9274a96c0e108c3805", size = 48497, upload-time = "2026-08-06T10:25:26.567Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/02/eb/8567f7542aaee43575002915a959e8891d2d9f3642d1bc5d32410d871bb5/imap_tools-1.14.0-py3-none-any.whl", hash = "sha256:eab3506e701eba44269bc04760b8b29a96c93b173553057180fe40a084d65426", size = 36410, upload-time = "2026-07-24T06:23:41.945Z" }, { url = "https://files.pythonhosted.org/packages/50/eb/a4ad6ec962af2806c3484adb1869c24a04d8ef8c2034f2dd2b0f48459b0a/imap_tools-1.15.0-py3-none-any.whl", hash = "sha256:7bb0003c8bf124b80968c7bef786e4577d8365e9afb6551dc15b13161b688bdc", size = 36512, upload-time = "2026-08-06T10:25:24.377Z" },
] ]
[[package]] [[package]]
@@ -2802,7 +2802,7 @@ wheels = [
[[package]] [[package]]
name = "ocrmypdf" name = "ocrmypdf"
version = "17.10.0" version = "17.11.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "fpdf2" }, { name = "fpdf2" },
@@ -2819,9 +2819,9 @@ dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "uharfbuzz" }, { name = "uharfbuzz" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/20/2e/96c9912ad50fe3e186f0d3580d06d27ac2300fcc178d7457794f4bcaf3b8/ocrmypdf-17.10.0.tar.gz", hash = "sha256:3e80a22e7ca9a746034e990414c9f18791f168800f3ede92101504f45be6129c", size = 7499394, upload-time = "2026-08-05T00:25:50.595Z" } sdist = { url = "https://files.pythonhosted.org/packages/4c/9a/f7d7c943e07b0a28cb0958229ac480f3c51b4169ad9df030477f103b298a/ocrmypdf-17.11.0.tar.gz", hash = "sha256:8e41cbba23bba9ce20bc557576211e1829d1beaef00183bacd227b4b9482af87", size = 7544564, upload-time = "2026-08-28T22:15:37.774Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/39/6b/29a4c5f4e67d16bff32f706bf338d443761fe4933b9be4067e958db04aaa/ocrmypdf-17.10.0-py3-none-any.whl", hash = "sha256:34ba1b595ecacc94b6dc3c9d4fa51953de63082cd16cf8595251bd72120b930a", size = 523170, upload-time = "2026-08-05T00:25:48.734Z" }, { url = "https://files.pythonhosted.org/packages/14/85/5a996c67cf44ecd3dfb6154202c7fecefb71b5ed52ec00b6a4de46a77e15/ocrmypdf-17.11.0-py3-none-any.whl", hash = "sha256:56cc82e49e22ba0fe77585d7fe788ab951b58ff1e7f75025eafa917e37efb2a9", size = 534921, upload-time = "2026-08-28T22:15:35.815Z" },
] ]
[[package]] [[package]]
@@ -3036,14 +3036,14 @@ requires-dist = [
{ name = "django-cors-headers", specifier = "~=4.9.0" }, { name = "django-cors-headers", specifier = "~=4.9.0" },
{ name = "django-extensions", specifier = "~=4.1" }, { name = "django-extensions", specifier = "~=4.1" },
{ name = "django-filter", specifier = "~=25.1" }, { name = "django-filter", specifier = "~=25.1" },
{ name = "django-guardian", specifier = "~=3.3.3" }, { name = "django-guardian", specifier = ">=3.3.3,<3.5.0" },
{ name = "django-multiselectfield", specifier = "~=1.0.1" }, { name = "django-multiselectfield", specifier = "~=1.0.1" },
{ name = "django-rich", specifier = "~=2.2.0" }, { name = "django-rich", specifier = "~=2.2.0" },
{ name = "django-soft-delete", specifier = "~=1.0.18" }, { name = "django-soft-delete", specifier = "~=1.0.18" },
{ name = "django-treenode", specifier = ">=0.24" }, { name = "django-treenode", specifier = ">=0.24" },
{ name = "djangorestframework", specifier = "~=3.16" }, { name = "djangorestframework", specifier = "~=3.16" },
{ name = "drf-spectacular", specifier = "~=0.30" }, { name = "drf-spectacular", specifier = "~=0.30" },
{ name = "drf-spectacular-sidecar", specifier = "~=2026.7.1" }, { name = "drf-spectacular-sidecar", specifier = ">=2026.7.1,<2026.9.0" },
{ name = "drf-writable-nested", specifier = "~=0.7.1" }, { name = "drf-writable-nested", specifier = "~=0.7.1" },
{ name = "filelock", specifier = "~=3.32.0" }, { name = "filelock", specifier = "~=3.32.0" },
{ name = "flower", specifier = ">=2.0.1,<2.2" }, { name = "flower", specifier = ">=2.0.1,<2.2" },
@@ -3051,7 +3051,7 @@ requires-dist = [
{ name = "granian", extras = ["uvloop"], marker = "extra == 'webserver'", specifier = ">=2.7,<2.9" }, { name = "granian", extras = ["uvloop"], marker = "extra == 'webserver'", specifier = ">=2.7,<2.9" },
{ name = "httpx-oauth", specifier = "~=0.17" }, { name = "httpx-oauth", specifier = "~=0.17" },
{ name = "ijson", specifier = ">=3.5.1" }, { name = "ijson", specifier = ">=3.5.1" },
{ name = "imap-tools", specifier = "~=1.14.0" }, { name = "imap-tools", specifier = ">=1.14,<1.16" },
{ name = "jinja2", specifier = "~=3.1.6" }, { name = "jinja2", specifier = "~=3.1.6" },
{ name = "langdetect", specifier = "~=1.0.9" }, { name = "langdetect", specifier = "~=1.0.9" },
{ name = "llama-index-core", specifier = ">=0.14.23" }, { name = "llama-index-core", specifier = ">=0.14.23" },
@@ -3062,7 +3062,7 @@ requires-dist = [
{ name = "llama-index-llms-openai-like", specifier = ">=0.7.1" }, { name = "llama-index-llms-openai-like", specifier = ">=0.7.1" },
{ name = "mysqlclient", marker = "extra == 'mariadb'", specifier = "~=2.2.7" }, { name = "mysqlclient", marker = "extra == 'mariadb'", specifier = "~=2.2.7" },
{ name = "nltk", specifier = "~=3.10.0" }, { name = "nltk", specifier = "~=3.10.0" },
{ name = "ocrmypdf", specifier = ">=17.7,<17.11" }, { name = "ocrmypdf", specifier = ">=17.7,<17.12" },
{ name = "openai", specifier = ">=2.48" }, { name = "openai", specifier = ">=2.48" },
{ name = "pathvalidate", specifier = "~=3.3.1" }, { name = "pathvalidate", specifier = "~=3.3.1" },
{ name = "pdf2image", specifier = "~=1.17.0" }, { name = "pdf2image", specifier = "~=1.17.0" },
@@ -3098,12 +3098,12 @@ provides-extras = ["mariadb", "postgres", "webserver"]
dev = [ dev = [
{ name = "daphne" }, { name = "daphne" },
{ name = "factory-boy", specifier = "~=3.3.1" }, { name = "factory-boy", specifier = "~=3.3.1" },
{ name = "faker", specifier = "~=40.36.0" }, { name = "faker", specifier = ">=40.36,<40.38" },
{ name = "imagehash" }, { name = "imagehash" },
{ name = "prek", specifier = "~=0.4.11" }, { name = "prek", specifier = ">=0.4.11,<0.6.0" },
{ name = "pytest", specifier = "~=9.1.1" }, { name = "pytest", specifier = "~=9.1.1" },
{ name = "pytest-cov", specifier = "~=7.1.0" }, { name = "pytest-cov", specifier = "~=7.1.0" },
{ name = "pytest-django", specifier = "~=4.12.0" }, { name = "pytest-django", specifier = ">=4.12,<4.15" },
{ name = "pytest-env", specifier = "~=1.7.0" }, { name = "pytest-env", specifier = "~=1.7.0" },
{ name = "pytest-httpx" }, { name = "pytest-httpx" },
{ name = "pytest-mock", specifier = "~=3.15.1" }, { name = "pytest-mock", specifier = "~=3.15.1" },
@@ -3116,17 +3116,17 @@ dev = [
] ]
docs = [{ name = "zensical", specifier = ">=0.0.51" }] docs = [{ name = "zensical", specifier = ">=0.0.51" }]
lint = [ lint = [
{ name = "prek", specifier = "~=0.4.11" }, { name = "prek", specifier = ">=0.4.11,<0.6.0" },
{ name = "ruff", specifier = "~=0.16.1" }, { name = "ruff", specifier = "~=0.16.1" },
] ]
testing = [ testing = [
{ name = "daphne" }, { name = "daphne" },
{ name = "factory-boy", specifier = "~=3.3.1" }, { name = "factory-boy", specifier = "~=3.3.1" },
{ name = "faker", specifier = "~=40.36.0" }, { name = "faker", specifier = ">=40.36,<40.38" },
{ name = "imagehash" }, { name = "imagehash" },
{ name = "pytest", specifier = "~=9.1.1" }, { name = "pytest", specifier = "~=9.1.1" },
{ name = "pytest-cov", specifier = "~=7.1.0" }, { name = "pytest-cov", specifier = "~=7.1.0" },
{ name = "pytest-django", specifier = "~=4.12.0" }, { name = "pytest-django", specifier = ">=4.12,<4.15" },
{ name = "pytest-env", specifier = "~=1.7.0" }, { name = "pytest-env", specifier = "~=1.7.0" },
{ name = "pytest-httpx" }, { name = "pytest-httpx" },
{ name = "pytest-mock", specifier = "~=3.15.1" }, { name = "pytest-mock", specifier = "~=3.15.1" },
@@ -3376,23 +3376,23 @@ wheels = [
[[package]] [[package]]
name = "prek" name = "prek"
version = "0.4.11" version = "0.5.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c4/1a/73b6dae5ce7e997cb35a69bfe1d25a798e85fa3d2eabf95324563f461b30/prek-0.4.11.tar.gz", hash = "sha256:4a14cb9bbae850605ae3904fbdbb12f0e00c12455efaa2266da8fb8e5c0350d7", size = 516254, upload-time = "2026-07-24T17:05:35.107Z" } sdist = { url = "https://files.pythonhosted.org/packages/ed/c8/ad3efcaf7007e4796f9a7482b4a3d84402c1535109695cba76fcb87cb03d/prek-0.5.0.tar.gz", hash = "sha256:8df015db60c1e9a30b4a266e65fa14c4d41d2b2b3b90879d76a5d8970dcce64d", size = 544033, upload-time = "2026-08-27T03:50:33.079Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/8e/d7/a00b2de492a80e99b1698e72c1d196ac3ed544dc7ee0ada261ac066e78e0/prek-0.4.11-py3-none-linux_armv6l.whl", hash = "sha256:3830cb7cc47e837888b8b464ecb21355a69235cfacef0fd89101e17f09345d63", size = 5770511, upload-time = "2026-07-24T17:05:12.829Z" }, { url = "https://files.pythonhosted.org/packages/89/83/a5b36db04d5ed8c461b9fec216193a52eee5be37170a9387022f832312b6/prek-0.5.0-py3-none-linux_armv6l.whl", hash = "sha256:df794a883e347ef78cb74522a143dbd8cc2a66e765ef328f48b615f37098015e", size = 5593469, upload-time = "2026-08-27T03:50:00.402Z" },
{ url = "https://files.pythonhosted.org/packages/c1/1e/f97c74defcd5d5645888cb99fcdd9b8b48cc7247cf31e64414d106d56d66/prek-0.4.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:45facadf9c2332b28e6ab2744312ae0275a93ab5a437da8bcc53a8c7260cb4b0", size = 6118049, upload-time = "2026-07-24T17:05:14.451Z" }, { url = "https://files.pythonhosted.org/packages/ca/b5/cd6cab203693a1c45d5a3bae3b8eb56174d595da8af6d6e1595c795f909a/prek-0.5.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:19d6fd892e112cf7dc300735a7ac8e596813ff1c11649ba2d2b9dd6fdfd6c96e", size = 5980270, upload-time = "2026-08-27T03:50:02.675Z" },
{ url = "https://files.pythonhosted.org/packages/d9/92/8367d26421ee6fe6019a63928fb0ed31179cd0d6199879c524f89ef4c95c/prek-0.4.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2f8a194b1d00d24dff8baff691c99e2668339da2da3482b4ee99c1a0f2409378", size = 5601478, upload-time = "2026-07-24T17:05:15.81Z" }, { url = "https://files.pythonhosted.org/packages/9d/b6/bbf5134c7b1360aad3f62c0f0391a7c6403b3500d16df25848089ad957c3/prek-0.5.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:313e2535483c37bba884ab034f478c174de7d3b9627081f2eedd16ac2c03bcc3", size = 5521835, upload-time = "2026-08-27T03:50:04.609Z" },
{ url = "https://files.pythonhosted.org/packages/e1/6f/7617c9b87afaadede4167720aae7ef12d7e51167db903bc8fbac0cadef7b/prek-0.4.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:21d93e6d76bf3d7a9bb70c6ac86ef372adaee50068c45749e6b9e1c2ac4ec939", size = 5932071, upload-time = "2026-07-24T17:05:17.217Z" }, { url = "https://files.pythonhosted.org/packages/78/72/7c79e9de5603fdc54d2101f0982f1af5e5ffa1a45c0bcbdbe5f985ed6b90/prek-0.5.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:1e9b34caeb465f1fa7cbb5d872d4611560eaff877162c4ad80a608838b0819a3", size = 5822039, upload-time = "2026-08-27T03:50:07.281Z" },
{ url = "https://files.pythonhosted.org/packages/ef/41/6796a4011b04212333259064aa885a71d03d9581ba9bff52db3ce58d1f06/prek-0.4.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7fb07cde2d2156efa6980122b3f13dc88f20c84b933c6205675d0f1e7be2cde8", size = 5677617, upload-time = "2026-07-24T17:05:18.658Z" }, { url = "https://files.pythonhosted.org/packages/dc/d2/68c21ec26f773b2370b132d69fe325589db08865fbe8b3df7d8ea8d8fc49/prek-0.5.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:561c2d028c7679fe8969937aa9ab74d96d6f7c748b8abf98edf6dc031dfbbdc9", size = 5510802, upload-time = "2026-08-27T03:50:09.2Z" },
{ url = "https://files.pythonhosted.org/packages/03/5f/3e339901f8460b6073313619b4fd9bf4135e48430695c53640b83ace88ea/prek-0.4.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6b7f5e446d2aca739bd18b380578e9c78bb4c086299abc3e167d51c47840c56f", size = 6106370, upload-time = "2026-07-24T17:05:20.219Z" }, { url = "https://files.pythonhosted.org/packages/7a/9e/ce6db8d998cb3e48bf747b7390e1ec87bb817ec500cfa5791af3a0d2eded/prek-0.5.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:310579ceb4acbc5646df9d663ec38a57b47258a903780089cfacfbf0dc9d49f1", size = 5969141, upload-time = "2026-08-27T03:50:11.211Z" },
{ url = "https://files.pythonhosted.org/packages/8e/4e/94e24b5c1910ec15692ecaf33d0d8ef0d02a02a8b5e40d3c976396880cba/prek-0.4.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7059d640e595d098600e2d97af961f46292b4112670edbe632de84f6828389e", size = 6884342, upload-time = "2026-07-24T17:05:21.587Z" }, { url = "https://files.pythonhosted.org/packages/5a/10/35e4c8fc4534d3b98c1bef2bfd559ba2aeedc9e540fcd4091fd4d91543b6/prek-0.5.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:da38e9c1d227773728c05d2f644704f3d3ace67f6ea58e41d406ebb596c3a135", size = 6731634, upload-time = "2026-08-27T03:50:13.244Z" },
{ url = "https://files.pythonhosted.org/packages/a2/7c/fc0daa033dcafe74990c00af2da1e16922790b9c1b8da7e8eebf1123838b/prek-0.4.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85a4df33998fcac878bce3b2c624e1caeb9609f3d562c640702c1234ed815daf", size = 6331365, upload-time = "2026-07-24T17:05:22.87Z" }, { url = "https://files.pythonhosted.org/packages/a2/fc/433b55d10e928de8ab2a9b83dfdbe8cba936fafc6e0a5c65b85f4007a8ea/prek-0.5.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b845f25f481a7df56f39dddcba35576a10d78aa71e09e4f88bcccce0729ebfb2", size = 6203980, upload-time = "2026-08-27T03:50:15.158Z" },
{ url = "https://files.pythonhosted.org/packages/d7/36/49f152b8f539930e9685cff0509695ce4054abcecc22f4c16c4e1e5c23d0/prek-0.4.11-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:866991f387527c5f880ce2cc3ddea9b33cc5b986881f8c7f92524cf0969c1350", size = 5939075, upload-time = "2026-07-24T17:05:24.249Z" }, { url = "https://files.pythonhosted.org/packages/7b/b8/b5cdd27d6da0d754abcf9cc06a092f476bd9242f0e8e19e3dfd1a2d1b315/prek-0.5.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:93b4086a1b69c24a967b5c80608cfc4a111cc2b0f69bc1d1f278eb4c23ad430c", size = 5827384, upload-time = "2026-08-27T03:50:16.898Z" },
{ url = "https://files.pythonhosted.org/packages/4f/fe/7b097af9161edae7ddedcc9f5cda4a5f31346a492ce3f92583d96c46f628/prek-0.4.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:247e5d8740e137ebdf24fa96f01a95b2d2f1892e956a1ab00c7b1474a59ebcc0", size = 5799029, upload-time = "2026-07-24T17:05:25.652Z" }, { url = "https://files.pythonhosted.org/packages/18/e5/1d8fd614dfe04aa9378b33fb23f7285da582ca090c321973da8466a754d5/prek-0.5.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3299eca7269582665255f4c8f3ab38bc64e4d16ab2e9ce5e3d20cf65bc9f40ee", size = 5589195, upload-time = "2026-08-27T03:50:18.857Z" },
{ url = "https://files.pythonhosted.org/packages/49/63/dc955ff99e1002d3cd375b21467c87046bc41deddfce86d898240757b151/prek-0.4.11-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:603ba9f2fd9d666dddb3ae190a25a5c55091b843cde90ed52e0a1116f50d4062", size = 5651211, upload-time = "2026-07-24T17:05:27.027Z" }, { url = "https://files.pythonhosted.org/packages/6c/89/e758cefe423cd1422e882325baef65af2bf2b6a903c32d657323a993d394/prek-0.5.0-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:005c20b3df988f240bfa50518777d5babeab50cf774a8917dba1705567e1f561", size = 5491009, upload-time = "2026-08-27T03:50:20.836Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b8/bf2139ec25eefb5afef43afac7620c5181f2c2661f220598beacb1771207/prek-0.4.11-py3-none-musllinux_1_1_i686.whl", hash = "sha256:f2682ece3c5fc7201106c4fdd84b0587ccea4b8a2ecefa3e94d3074d2841f1df", size = 5954784, upload-time = "2026-07-24T17:05:28.391Z" }, { url = "https://files.pythonhosted.org/packages/ea/90/6b30d898a2610113378b78b82f1d700bcdb79993ba92b85f492a02859f53/prek-0.5.0-py3-none-musllinux_1_1_i686.whl", hash = "sha256:a1a46d8de94d7c7c58b027a3622763861f1c76b01bafe9dc6d8b408713ad3946", size = 5826007, upload-time = "2026-08-27T03:50:23.1Z" },
{ url = "https://files.pythonhosted.org/packages/fc/29/3fe5990aee1bd7c4d50a03358ad867c5e912d9510aafb83a359c7347e5fa/prek-0.4.11-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:22721d30394192931fecc80d6fc6dd47e8ddf8db8b9693805aba8ec0f50087ec", size = 6448916, upload-time = "2026-07-24T17:05:29.837Z" }, { url = "https://files.pythonhosted.org/packages/48/23/3fd00ff6d844b1756b95fc913730c6ec2f772f36558693ff3c4cfb2d3071/prek-0.5.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:e2aad66e0111cab24a03af2f67fca737c3d8f7b1188cf9b8994bbbf7b52f53b8", size = 6319092, upload-time = "2026-08-27T03:50:25.41Z" },
] ]
[[package]] [[package]]
@@ -3854,14 +3854,14 @@ wheels = [
[[package]] [[package]]
name = "pytest-django" name = "pytest-django"
version = "4.12.0" version = "4.14.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pytest" }, { name = "pytest" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/13/2b/db9a193df89e5660137f5428063bcc2ced7ad790003b26974adf5c5ceb3b/pytest_django-4.12.0.tar.gz", hash = "sha256:df94ec819a83c8979c8f6de13d9cdfbe76e8c21d39473cfe2b40c9fc9be3c758", size = 91156, upload-time = "2026-02-14T18:40:49.235Z" } sdist = { url = "https://files.pythonhosted.org/packages/44/f6/3851312120c2bf2f19cafff931e75059aad1ba670703cd751e2fde9bc942/pytest_django-4.14.0.tar.gz", hash = "sha256:26787dd3f422cfbab8f55b80a776e2edea7a11092cb74e960bef1312515708ef", size = 94700, upload-time = "2026-08-10T14:13:08.319Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/83/a5/41d091f697c09609e7ef1d5d61925494e0454ebf51de7de05f0f0a728f1d/pytest_django-4.12.0-py3-none-any.whl", hash = "sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85", size = 26123, upload-time = "2026-02-14T18:40:47.381Z" }, { url = "https://files.pythonhosted.org/packages/9c/03/850bffad2b581c440ca51c039d74504d5a422c94bda0bdb8a8ba5068d48b/pytest_django-4.14.0-py3-none-any.whl", hash = "sha256:c533b08d89cc675efcd5398eea270b34547e35f9a3608e2c9748dd88428ea187", size = 27067, upload-time = "2026-08-10T14:13:06.998Z" },
] ]
[[package]] [[package]]
@@ -3904,15 +3904,15 @@ wheels = [
[[package]] [[package]]
name = "pytest-rerunfailures" name = "pytest-rerunfailures"
version = "16.4" version = "16.6"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "packaging" }, { name = "packaging" },
{ name = "pytest" }, { name = "pytest" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/47/60/a90ca1cc6cffcb97b4260ed0ad2b7934b999d7c48abe4ea0840344862a3b/pytest_rerunfailures-16.4.tar.gz", hash = "sha256:8222d17c37eb7b9e4d6fc96a3c724ff4e1a5c97a5cc7cbb2c19e9282cfd21a11", size = 36635, upload-time = "2026-07-01T06:30:56.813Z" } sdist = { url = "https://files.pythonhosted.org/packages/ed/63/0114e45d4b2fcd5f6297dac655c067b47de28be4d33e088f200f0f2c4c28/pytest_rerunfailures-16.6.tar.gz", hash = "sha256:29dbfee46f542073c888e0ed4e81c51e15b9096f49a299eb1a759629c601684a", size = 42806, upload-time = "2026-08-17T07:11:00.447Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/93/3cdcc4033444e822e01b573414b03fd37fd5533070c750477b8f5fa5224b/pytest_rerunfailures-16.4-py3-none-any.whl", hash = "sha256:f69b5beb39622c90d1e44bd945d826eff6db545dcf0b68f52b7e4ad15eaf6d6c", size = 16955, upload-time = "2026-07-01T06:30:55.333Z" }, { url = "https://files.pythonhosted.org/packages/5d/5e/1e994889673d7a0da11651f17ef789b6c83bfe349f29f871873dd3802445/pytest_rerunfailures-16.6-py3-none-any.whl", hash = "sha256:6af2d1ebd6e5cb79666ac408942cd6a0672a49fefd8523664770718560795e13", size = 19137, upload-time = "2026-08-17T07:10:59.121Z" },
] ]
[[package]] [[package]]
@@ -4861,58 +4861,52 @@ wheels = [
[[package]] [[package]]
name = "time-machine" name = "time-machine"
version = "3.2.0" version = "3.5.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/02/fc/37b02f6094dbb1f851145330460532176ed2f1dc70511a35828166c41e52/time_machine-3.2.0.tar.gz", hash = "sha256:a4ddd1cea17b8950e462d1805a42b20c81eb9aafc8f66b392dd5ce997e037d79", size = 14804, upload-time = "2025-12-17T23:33:02.599Z" } sdist = { url = "https://files.pythonhosted.org/packages/e3/1a/d9c82e780ac6c41102d50b30c3fb3c25a3b8dd46f807147462d1f4429171/time_machine-3.5.0.tar.gz", hash = "sha256:bc193985b43f15394cfded976efaf9068c3078a2135f42f81c874ba684720eae", size = 25692, upload-time = "2026-08-25T00:18:32.594Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/e1/03aae5fbaa53859f665094af696338fc7cae733d926a024af69982712350/time_machine-3.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c188a9dda9fcf975022f1b325b466651b96a4dfc223c523ed7ed8d979f9bf3e8", size = 19143, upload-time = "2025-12-17T23:31:44.258Z" }, { url = "https://files.pythonhosted.org/packages/00/c4/e19d1646c1b879a90b8291279a927c7e9c961371128b1f0528c30421da76/time_machine-3.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:43448a1f2e9916e1a1f7cef0260decaad9071e9afe8ed8a4493e54e358c72802", size = 25285, upload-time = "2026-08-25T00:17:33.517Z" },
{ url = "https://files.pythonhosted.org/packages/75/8f/98cb17bebb52b22ff4ec26984dd44280f9c71353c3bae0640a470e6683e5/time_machine-3.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17245f1cc2dd13f9d63a174be59bb2684a9e5e0a112ab707e37be92068cd655f", size = 15273, upload-time = "2025-12-17T23:31:45.246Z" }, { url = "https://files.pythonhosted.org/packages/62/ac/7fa71d6f9f77b65f77e6906ee6fa4fa4cf4ab7ff2995be8fd9491533f5a5/time_machine-3.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5999f1854a4bcc021fca7c16bfbb7bcf8bd912691aad22af02a8745becc65fd", size = 25695, upload-time = "2026-08-25T00:17:34.43Z" },
{ url = "https://files.pythonhosted.org/packages/dd/2f/ca11e4a7897234bb9331fcc5f4ed4714481ba4012370cc79a0ae8c42ea0a/time_machine-3.2.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9bd1de1996e76efd36ae15970206c5089fb3728356794455bd5cd8d392b5537", size = 31049, upload-time = "2025-12-17T23:31:46.613Z" }, { url = "https://files.pythonhosted.org/packages/45/96/e915a95dfb3bd5314c6878c7d3a73b6a41a573133b7556daa09f1778c494/time_machine-3.5.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ba76cc7d5ad70babda28e064d47b8887adc47825acf5eba164527a937a104b29", size = 53446, upload-time = "2026-08-25T00:17:35.381Z" },
{ url = "https://files.pythonhosted.org/packages/cf/ad/d17d83a59943094e6b6c6a3743caaf6811b12203c3e07a30cc7bcc2ab7ee/time_machine-3.2.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98493cd50e8b7f941eab69b9e18e697ad69db1a0ec1959f78f3d7b0387107e5c", size = 32632, upload-time = "2025-12-17T23:31:47.72Z" }, { url = "https://files.pythonhosted.org/packages/da/b6/4566a277ce38ffd9a0b9aa7ba69cc489272a43f5e37e2f8bd10e6db7763d/time_machine-3.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5382acf9caa9c9bd1e1ef86f987cd7f91f5b70907baf16943d41b326ac0891c", size = 54681, upload-time = "2026-08-25T00:17:36.476Z" },
{ url = "https://files.pythonhosted.org/packages/71/50/d60576d047a0dfb5638cdfb335e9c3deb6e8528544fa0b3966a8480f72b7/time_machine-3.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:31f2a33d595d9f91eb9bc7f157f0dc5721f5789f4c4a9e8b852cdedb2a7d9b16", size = 34289, upload-time = "2025-12-17T23:31:48.913Z" }, { url = "https://files.pythonhosted.org/packages/12/24/8ac65ef5e961e1033a6498cc50e4cc51be6ece08e9fb7946857512e2998e/time_machine-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2cfa05cfaaf9c394bd976c3a437d358e668236794fc4295e4f7ffb91bc5999c0", size = 53489, upload-time = "2026-08-25T00:17:37.439Z" },
{ url = "https://files.pythonhosted.org/packages/fa/fe/4afa602dbdebddde6d0ea4a7fe849e49b9bb85dc3fb415725a87ccb4b471/time_machine-3.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9f78ac4213c10fbc44283edd1a29cfb7d3382484f4361783ddc057292aaa1889", size = 33175, upload-time = "2025-12-17T23:31:50.611Z" }, { url = "https://files.pythonhosted.org/packages/7f/f6/dd28591ebdca5ebad418c2c47c473ecac271eae53f05afc612660f4fa35d/time_machine-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d200c02eb95d7d766f2942dfc5e70a888b0beea3e92ed363bc255f01d2b6b6b9", size = 52823, upload-time = "2026-08-25T00:17:38.597Z" },
{ url = "https://files.pythonhosted.org/packages/0d/87/c152e23977c1d7d7c94eb3ed3ea45cc55971796205125c6fdff40db2c60f/time_machine-3.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c1326b09e947b360926d529a96d1d9e126ce120359b63b506ecdc6ee20755c23", size = 31170, upload-time = "2025-12-17T23:31:51.645Z" }, { url = "https://files.pythonhosted.org/packages/ba/fe/7719438b331df5a4b44740da046ad4919f2d19cf6ec8872425823368b404/time_machine-3.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:60baf021c396bc07b3403f96bbc06a2ed977dd42e1d942fc29edd6575ff6c601", size = 25708, upload-time = "2026-08-25T00:17:41.725Z" },
{ url = "https://files.pythonhosted.org/packages/80/af/54acf51d0f3ade3b51eab73df6192937c9a938753ef5456dff65eb8630be/time_machine-3.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9f2949f03d15264cc15c38918a2cda8966001f0f4ebe190cbfd9c56d91aed8ac", size = 32292, upload-time = "2025-12-17T23:31:52.803Z" }, { url = "https://files.pythonhosted.org/packages/68/e6/8acacd366d94a19f02011ce2af4821315c59af8b868220543096c1a70375/time_machine-3.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cdfd042a4b71fd2687eb16070db3d24e652c147d132a66e1756ac932f2f2bfdb", size = 25888, upload-time = "2026-08-25T00:17:42.783Z" },
{ url = "https://files.pythonhosted.org/packages/71/8b/080c8eedcd67921a52ba5bd0e075362062509ab63c86fc1a0442fad241a6/time_machine-3.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc4bee5b0214d7dc4ebc91f4a4c600f1a598e9b5606ac751f42cb6f6740b1dbb", size = 19255, upload-time = "2025-12-17T23:31:58.057Z" }, { url = "https://files.pythonhosted.org/packages/62/20/1cbce3bc36438730c945d6a3990f9de0ab07af1ad1cf9b6a399d0887e8ed/time_machine-3.5.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2b0ec43f3f8eb0edb92637684cdc4309fa9ea09f8f78e5daec20c7d5c4bc0b2e", size = 57769, upload-time = "2026-08-25T00:17:43.712Z" },
{ url = "https://files.pythonhosted.org/packages/66/17/0e5291e9eb705bf8a5a1305f826e979af307bbeb79def4ddbf4b3f9a81e0/time_machine-3.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ca036304b4460ae2fdc1b52dd8b1fa7cf1464daa427fc49567413c09aa839c1", size = 15360, upload-time = "2025-12-17T23:31:59.048Z" }, { url = "https://files.pythonhosted.org/packages/17/7a/a3dd6e0b8543cb559705af629fb73bbef753a8db31f7abe891d9c95ed7db/time_machine-3.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1962b3f01d979e3fe7d8ce1883bb841ef7f39be227776755e2f1f38d6d50b1dc", size = 58524, upload-time = "2026-08-25T00:17:44.724Z" },
{ url = "https://files.pythonhosted.org/packages/8b/e8/9ab87b71d2e2b62463b9b058b7ae7ac09fb57f8fcd88729dec169d304340/time_machine-3.2.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5442735b41d7a2abc2f04579b4ca6047ed4698a8338a4fec92c7c9423e7938cb", size = 33029, upload-time = "2025-12-17T23:32:00.413Z" }, { url = "https://files.pythonhosted.org/packages/e0/5d/0e6dc3fc6c055d35e140a862e1e214833edd1ae565ea63e1249e7ed841b0/time_machine-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7523bc19dfd77243ff20361685dd8b1fe319389f7bce758cadc72fd51e4f9471", size = 57199, upload-time = "2026-08-25T00:17:45.802Z" },
{ url = "https://files.pythonhosted.org/packages/4b/26/b5ca19da6f25ea905b3e10a0ea95d697c1aeba0404803a43c68f1af253e6/time_machine-3.2.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:97da3e971e505cb637079fb07ab0bcd36e33279f8ecac888ff131f45ef1e4d8d", size = 34579, upload-time = "2025-12-17T23:32:01.431Z" }, { url = "https://files.pythonhosted.org/packages/09/ac/79a13f1b16a40597e52214160ee79fbef2db00f39c8fb16dd3f9842d2ebf/time_machine-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8dd66efe1fe4740d1e789a3dc4121b593e0eaa1c3b9b75d6047033f9cc2263bc", size = 56917, upload-time = "2026-08-25T00:17:46.863Z" },
{ url = "https://files.pythonhosted.org/packages/79/ca/6ac7ad5f10ea18cc1d9de49716ba38c32132c7b64532430d92ef240c116b/time_machine-3.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3cdda6dee4966e38aeb487309bb414c6cb23a81fc500291c77a8fcd3098832e7", size = 35961, upload-time = "2025-12-17T23:32:02.521Z" }, { url = "https://files.pythonhosted.org/packages/8a/dc/61669d9a3c95291de10486d36b24ca8af50dce17e8d624f9887208f484d1/time_machine-3.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f01f0e0fa67ea501c4ea3bf5983234612fb5036ac32ddab092b09ead37715c7c", size = 25707, upload-time = "2026-08-25T00:17:49.791Z" },
{ url = "https://files.pythonhosted.org/packages/33/67/390dd958bed395ab32d79a9fe61fe111825c0dd4ded54dbba7e867f171e6/time_machine-3.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:33d9efd302a6998bcc8baa4d84f259f8a4081105bd3d7f7af7f1d0abd3b1c8aa", size = 34668, upload-time = "2025-12-17T23:32:03.585Z" }, { url = "https://files.pythonhosted.org/packages/2c/a4/3f5a4286768dff89e2de8c9ebcb8765962f2ce00196865954f48132435ea/time_machine-3.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:479abe218c4bc03e1acd1914f095a2351ca658db17e69e56b194f5fe2337b216", size = 25878, upload-time = "2026-08-25T00:17:50.881Z" },
{ url = "https://files.pythonhosted.org/packages/da/57/c88fff034a4e9538b3ae7c68c9cfb283670b14d17522c5a8bc17d29f9a4b/time_machine-3.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3a0b0a33971f14145853c9bd95a6ab0353cf7e0019fa2a7aa1ae9fddfe8eab50", size = 32891, upload-time = "2025-12-17T23:32:04.656Z" }, { url = "https://files.pythonhosted.org/packages/b3/ee/1cf35afb752519cd38a1aaf74617cde56c8417a6d235d26c9a650c74138f/time_machine-3.5.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a625e2fd1e3b0dcc1479f559866a068a8c48c3ebe7a2c2093daef8e924ff1a7e", size = 57713, upload-time = "2026-08-25T00:17:52.036Z" },
{ url = "https://files.pythonhosted.org/packages/2d/70/ebbb76022dba0fec8f9156540fc647e4beae1680c787c01b1b6200e56d70/time_machine-3.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2d0be9e5f22c38082d247a2cdcd8a936504e9db60b7b3606855fb39f299e9548", size = 34080, upload-time = "2025-12-17T23:32:06.146Z" }, { url = "https://files.pythonhosted.org/packages/67/57/d9e3a30cad31fbbe17b81971984ae83121cf968e7583e36b8cf92f31e534/time_machine-3.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c5624a6c3256182aaf1d15defcff782d6b0a12c8b2554191981bb59fe00954a", size = 58443, upload-time = "2026-08-25T00:17:53.074Z" },
{ url = "https://files.pythonhosted.org/packages/ee/cd/43ad5efc88298af3c59b66769cea7f055567a85071579ed40536188530c1/time_machine-3.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c421a8eb85a4418a7675a41bf8660224318c46cc62e4751c8f1ceca752059090", size = 19318, upload-time = "2025-12-17T23:32:10.518Z" }, { url = "https://files.pythonhosted.org/packages/63/37/82035b221f3bd657f8ca1eb3e28a5a7865338d35331ccc06a0569b87c39a/time_machine-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:27a2a399d500f03fafe185b4313d372098901bb66b839abf9fb1b981ffb85f44", size = 57122, upload-time = "2026-08-25T00:17:54.116Z" },
{ url = "https://files.pythonhosted.org/packages/b0/f6/084010ef7f4a3f38b5a4900923d7c85b29e797655c4f6ee4ce54d903cca8/time_machine-3.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f4e758f7727d0058c4950c66b58200c187072122d6f7a98b610530a4233ea7b", size = 15390, upload-time = "2025-12-17T23:32:11.625Z" }, { url = "https://files.pythonhosted.org/packages/93/ae/196922dd5af6b0b4ef650c02343f1276e4be5d98b2f83dfada5f79047cab/time_machine-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5e0c69a871a6a03daf491421590671652fd299803c218a37af9551c15dc3e68", size = 56842, upload-time = "2026-08-25T00:17:55.219Z" },
{ url = "https://files.pythonhosted.org/packages/25/aa/1cabb74134f492270dc6860cb7865859bf40ecf828be65972827646e91ad/time_machine-3.2.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:154bd3f75c81f70218b2585cc12b60762fb2665c507eec5ec5037d8756d9b4e0", size = 33115, upload-time = "2025-12-17T23:32:13.219Z" }, { url = "https://files.pythonhosted.org/packages/97/e4/f100f18682c0a4c2cdff7d90dbad96fc91d6629a2459502c2781e451c6f6/time_machine-3.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:408f1cd20c5e5546ad71c25398b4b8ca2cc698e9efa52cbf1a21160ca5607505", size = 25765, upload-time = "2026-08-25T00:17:58.187Z" },
{ url = "https://files.pythonhosted.org/packages/5e/03/78c5d7dfa366924eb4dbfcc3fc917c39a4280ca234b12819cc1f16c03d88/time_machine-3.2.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50cfe5ebea422c896ad8d278af9648412b7533b8ea6adeeee698a3fd9b1d3b7", size = 34705, upload-time = "2025-12-17T23:32:14.29Z" }, { url = "https://files.pythonhosted.org/packages/1e/0a/8a0c8cf3414b11f3c195df9c020bf393fab2e26f9a9973f16ae94e73d509/time_machine-3.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:92726ff3240d30d67b054f307dbe9257026dd092ce742d2a59a1c74317d194f4", size = 25889, upload-time = "2026-08-25T00:17:59.142Z" },
{ url = "https://files.pythonhosted.org/packages/86/93/d5e877c24541f674c6869ff6e9c56833369796010190252e92c9d7ae5f0f/time_machine-3.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:636576501724bd6a9124e69d86e5aef263479e89ef739c5db361469f0463a0a1", size = 36104, upload-time = "2025-12-17T23:32:15.354Z" }, { url = "https://files.pythonhosted.org/packages/43/cd/84c4c7f51d0881182e348ea0e30a329bc284bb24d680d29e0123bbd35a16/time_machine-3.5.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5dfab29d9d54bb073d5a7c4ed155d56964f6afb7726ead00003aee9f77d5e561", size = 57752, upload-time = "2026-08-25T00:18:00.111Z" },
{ url = "https://files.pythonhosted.org/packages/22/1c/d4bae72f388f67efc9609f89b012e434bb19d9549c7a7b47d6c7d9e5c55d/time_machine-3.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:40e6f40c57197fcf7ec32d2c563f4df0a82c42cdcc3cab27f688e98f6060df10", size = 34765, upload-time = "2025-12-17T23:32:16.434Z" }, { url = "https://files.pythonhosted.org/packages/09/b6/a72ab775f6656cc746b6f2cdbbf7740772f7805fc3d99acde6be3112cd63/time_machine-3.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ac0ef9d1dd64de23ba8b716c2321137c18d4b3d4229b33b494a889a47d82b35", size = 58547, upload-time = "2026-08-25T00:18:01.154Z" },
{ url = "https://files.pythonhosted.org/packages/1d/c3/ac378cf301d527d8dfad2f0db6bad0dfb1ab73212eaa56d6b96ee5d9d20b/time_machine-3.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a1bcf0b846bbfc19a79bc19e3fa04d8c7b1e8101c1b70340ffdb689cd801ea53", size = 33010, upload-time = "2025-12-17T23:32:17.532Z" }, { url = "https://files.pythonhosted.org/packages/0f/5c/bf25aab5688a29bfde2682d44d2a6ba6627d7ff9b26acb6893f1f71f4dfa/time_machine-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6ba9a85d7b2a25e758337e3aebbd489f1578baad6ef3ad7b50e2e6ef47c52d26", size = 57183, upload-time = "2026-08-25T00:18:02.169Z" },
{ url = "https://files.pythonhosted.org/packages/06/35/7ce897319accda7a6970b288a9a8c52d25227342a7508505a2b3d235b649/time_machine-3.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ae55a56c179f4fe7a62575ad5148b6ed82f6c7e5cf2f9a9ec65f2f5b067db5f5", size = 34185, upload-time = "2025-12-17T23:32:18.566Z" }, { url = "https://files.pythonhosted.org/packages/97/3c/e4ae4520b49d684bac5a0404d7ae072e46d71f0a859e7abe43141db4eb65/time_machine-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d50d894be50d74fbcee13e53e20ced6c35336519efb1d20fa96ec97dd25e8063", size = 56895, upload-time = "2026-08-25T00:18:03.288Z" },
{ url = "https://files.pythonhosted.org/packages/67/e7/487f0ba5fe6c58186a5e1af2a118dfa2c160fedb37ef53a7e972d410408e/time_machine-3.2.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:59d71545e62525a4b85b6de9ab5c02ee3c61110fd7f636139914a2335dcbfc9c", size = 20000, upload-time = "2025-12-17T23:32:23.058Z" }, { url = "https://files.pythonhosted.org/packages/72/60/344e0174a1d923c2b8611a03546c432e0c52d785a6fc780a32f18e619353/time_machine-3.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d7f4145552b9e9c32548a5f56cb0e57cf760fb189651812d42dcddd9ea38d7c5", size = 26468, upload-time = "2026-08-25T00:18:06.281Z" },
{ url = "https://files.pythonhosted.org/packages/e1/17/eb2c0054c8d44dd42df84ccd434539249a9c7d0b8eb53f799be2102500ab/time_machine-3.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:999672c621c35362bc28e03ca0c7df21500195540773c25993421fd8d6cc5003", size = 15657, upload-time = "2025-12-17T23:32:24.125Z" }, { url = "https://files.pythonhosted.org/packages/97/31/428d23f87a53a00d15cf0079756e74ca675bc43f318987b288e0edc0baa7/time_machine-3.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2f2234fd57263fd60cd47e87afff24b4b41ff64bcf62a88fbdff9df96884e10f", size = 26709, upload-time = "2026-08-25T00:18:07.41Z" },
{ url = "https://files.pythonhosted.org/packages/43/21/93443b5d1dd850f8bb9442e90d817a9033dcce6bfbdd3aabbb9786251c80/time_machine-3.2.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5faf7397f0580c7b9d67288522c8d7863e85f0cffadc0f1fccdb2c3dfce5783e", size = 39216, upload-time = "2025-12-17T23:32:25.542Z" }, { url = "https://files.pythonhosted.org/packages/0d/61/b88cc24cb437fca0971ca738841440148a841e37bf46c51f236de8ba4fb7/time_machine-3.5.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9c01bdf00862e5f13554b45e75f9103a0e0ec9cc43c80d3e6283b23dfccbe79", size = 69081, upload-time = "2026-08-25T00:18:08.552Z" },
{ url = "https://files.pythonhosted.org/packages/9f/9e/18544cf8acc72bb1dc03762231c82ecc259733f4bb6770a7bbe5cd138603/time_machine-3.2.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3dd886ec49f1fa5a00e844f5947e5c0f98ce574750c24b7424c6f77fc1c3e87", size = 40764, upload-time = "2025-12-17T23:32:26.643Z" }, { url = "https://files.pythonhosted.org/packages/64/7d/a4f6b7121f16cd0668f549c8896c2033361909431ddca5b504866809aa59/time_machine-3.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad1ca1aba7f0b52958646ef9f261123c04526218fcc85a041cc00caa7a0c510c", size = 71319, upload-time = "2026-08-25T00:18:09.799Z" },
{ url = "https://files.pythonhosted.org/packages/27/f7/9fe9ce2795636a3a7467307af6bdf38bb613ddb701a8a5cd50ec713beb5e/time_machine-3.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da0ecd96bc7bbe450acaaabe569d84e81688f1be8ad58d1470e42371d145fb53", size = 43526, upload-time = "2025-12-17T23:32:27.693Z" }, { url = "https://files.pythonhosted.org/packages/70/cc/96b68c16638936390f3afd939faee61d0e9a565813874e912b426c24d91c/time_machine-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:94c4baf87208eb5c1161dd6dbae6d1770c412c8a5a91c079f433912e050272d7", size = 69637, upload-time = "2026-08-25T00:18:10.836Z" },
{ url = "https://files.pythonhosted.org/packages/03/c1/a93e975ba9dec22e87ec92d18c28e67d36bd536f9119ffa439b2892b0c9c/time_machine-3.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:158220e946c1c4fb8265773a0282c88c35a7e3bb5d78e3561214e3b3231166f3", size = 41727, upload-time = "2025-12-17T23:32:28.985Z" }, { url = "https://files.pythonhosted.org/packages/97/b3/5da9984647c59d50128b0e7d5f03f73c273645eef9fed9dd42531da4183c/time_machine-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a610fa8487da78d4769dc77d04395cb7eaa8863a6907d58d0f142adba1c6b70f", size = 67643, upload-time = "2026-08-25T00:18:11.853Z" },
{ url = "https://files.pythonhosted.org/packages/5f/fb/e3633e5a6bbed1c76bb2e9810dabc2f8467532ffcd29b9aed404b473061a/time_machine-3.2.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8c1aee29bc54356f248d5d7dfdd131e12ca825e850a08c0ebdb022266d073013", size = 38952, upload-time = "2025-12-17T23:32:30.031Z" }, { url = "https://files.pythonhosted.org/packages/67/99/dfe5115f8e26838c8e7f3cbe89ef1a1af58df3fb44a6603dcaaca1cc686c/time_machine-3.5.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:ed8ad24ea16263f2aa5fd65e172489e31dd18c27da9f9c5aacf0f13a7b1f33ae", size = 25767, upload-time = "2026-08-25T00:18:15.429Z" },
{ url = "https://files.pythonhosted.org/packages/82/3d/02e9fb2526b3d6b1b45bc8e4d912d95d1cd699d1a3f6df985817d37a0600/time_machine-3.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c8ed2224f09d25b1c2fc98683613aca12f90f682a427eabb68fc824d27014e4a", size = 39829, upload-time = "2025-12-17T23:32:31.075Z" }, { url = "https://files.pythonhosted.org/packages/7e/79/efc28c06a78bdaffb165fd6e6389dc9afe6b5edfdec4f415e7fd32b252e3/time_machine-3.5.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:618a3690c5d30c0c815f589c3f2625825d62b4e94f67b390c6f434dd489eae6d", size = 25893, upload-time = "2026-08-25T00:18:16.514Z" },
{ url = "https://files.pythonhosted.org/packages/61/70/b4b980d126ed155c78d1879c50d60c8dcbd47bd11cb14ee7be50e0dfc07f/time_machine-3.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:1398980c017fe5744d66f419e0115ee48a53b00b146d738e1416c225eb610b82", size = 19303, upload-time = "2025-12-17T23:32:35.796Z" }, { url = "https://files.pythonhosted.org/packages/7e/ad/9c35bdde5bc64d0c73c2dee8edd89cfc799d1edf6498afa4acfdf171624c/time_machine-3.5.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:435549f1010a1344f032d842d51d2e1d62f7d24bf4108590590b5989ca336da6", size = 57938, upload-time = "2026-08-25T00:18:17.54Z" },
{ url = "https://files.pythonhosted.org/packages/73/73/eaa33603c69a68fe2b6f54f9dd75481693d62f1d29676531002be06e2d1c/time_machine-3.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4f8f4e35f4191ef70c2ab8ff490761ee9051b891afce2bf86dde3918eb7b537b", size = 15431, upload-time = "2025-12-17T23:32:37.244Z" }, { url = "https://files.pythonhosted.org/packages/93/99/0f6d87fdd9f63a5caf29b0e0b51b29160f148ce08663730e5436453aa309/time_machine-3.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f137ca62841c1b13d333dbda3e1c20d29c598aa1c0efb051a3301af3281902c", size = 58723, upload-time = "2026-08-25T00:18:18.577Z" },
{ url = "https://files.pythonhosted.org/packages/76/10/b81e138e86cc7bab40cdb59d294b341e172201f4a6c84bb0ec080407977a/time_machine-3.2.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6db498686ecf6163c5aa8cf0bcd57bbe0f4081184f247edf3ee49a2612b584f9", size = 33206, upload-time = "2025-12-17T23:32:38.713Z" }, { url = "https://files.pythonhosted.org/packages/5d/5b/e911dc4fb4313a0eb99f1ddc9f453adb7951a34d988abc5589426a91174e/time_machine-3.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e9488dc2a733c6b433f4a73ed09aa855512680713a8148580a9471aad1df100d", size = 57398, upload-time = "2026-08-25T00:18:19.661Z" },
{ url = "https://files.pythonhosted.org/packages/d3/72/4deab446b579e8bd5dca91de98595c5d6bd6a17ce162abf5c5f2ce40d3d8/time_machine-3.2.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:027c1807efb74d0cd58ad16524dec94212fbe900115d70b0123399883657ac0f", size = 34792, upload-time = "2025-12-17T23:32:40.223Z" }, { url = "https://files.pythonhosted.org/packages/0c/37/2c349d5f93c0d35133ff392c0922588b1db55a29aa48cedeb168f7384b4c/time_machine-3.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e9d89725cd7cc1ef6dc0560397fa0e820aecdb9ceb7741b0c60108ea3cc7fc1a", size = 57091, upload-time = "2026-08-25T00:18:20.813Z" },
{ url = "https://files.pythonhosted.org/packages/2c/39/439c6b587ddee76d533fe972289d0646e0a5520e14dc83d0a30aeb5565f7/time_machine-3.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92432610c05676edd5e6946a073c6f0c926923123ce7caee1018dc10782c713d", size = 36187, upload-time = "2025-12-17T23:32:41.705Z" }, { url = "https://files.pythonhosted.org/packages/3f/94/c319fdd99f9cf9f6a4ed3026bdaf0cc0617de0eca46aed7dc3b6abe2e087/time_machine-3.5.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:0e1a72dcf0acc644209ddf048502229c404ad4e44a4b41ce85045902dc2becbc", size = 26471, upload-time = "2026-08-25T00:18:24.09Z" },
{ url = "https://files.pythonhosted.org/packages/4b/db/2da4368db15180989bab83746a857bde05ad16e78f326801c142bb747a06/time_machine-3.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c25586b62480eb77ef3d953fba273209478e1ef49654592cd6a52a68dfe56a67", size = 34855, upload-time = "2025-12-17T23:32:42.817Z" }, { url = "https://files.pythonhosted.org/packages/30/14/12acfc2661560d0c5ec134331571b4835cf23a16f2848d2627191303913f/time_machine-3.5.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:98c28f9a2f7bd63ade6eeb4e0c2f1bee85862747f9ea5bf59d81584689db19d1", size = 26717, upload-time = "2026-08-25T00:18:25.101Z" },
{ url = "https://files.pythonhosted.org/packages/88/84/120a431fee50bc4c241425bee4d3a4910df4923b7ab5f7dff1bf0c772f08/time_machine-3.2.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6bf3a2fa738d15e0b95d14469a0b8ea42635467408d8b490e263d5d45c9a177f", size = 33222, upload-time = "2025-12-17T23:32:43.94Z" }, { url = "https://files.pythonhosted.org/packages/cd/95/23668168666e6b835caa979848b4addb2176b031ffbe1be3e42401ec699d/time_machine-3.5.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3dce82621d4f8782f2b8893b4152b58dc43254cee1ac69229c36621709b11036", size = 69104, upload-time = "2026-08-25T00:18:26.154Z" },
{ url = "https://files.pythonhosted.org/packages/f9/ea/89cfda82bb8c57ff91bb9a26751aa234d6d90e9b4d5ab0ad9dce0f9f0329/time_machine-3.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ce76b82276d7ad2a66cdc85dad4df19d1422b69183170a34e8fbc4c3f35502f7", size = 34270, upload-time = "2025-12-17T23:32:45.037Z" }, { url = "https://files.pythonhosted.org/packages/a7/ef/cec9cf58e5c6b91789b1d6c053fb230391896761fdfed4bd1f0690efe22a/time_machine-3.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bba5d2f7f34aa491521c1d1c13f66802aedc58b10826166753f4b824f8a9c8a3", size = 71339, upload-time = "2026-08-25T00:18:27.391Z" },
{ url = "https://files.pythonhosted.org/packages/86/a1/142de946dc4393f910bf4564b5c3ba819906e1f49b06c9cb557519c849e4/time_machine-3.2.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4e374779021446fc2b5c29d80457ec9a3b1a5df043dc2aae07d7c1415d52323c", size = 19991, upload-time = "2025-12-17T23:32:49.933Z" }, { url = "https://files.pythonhosted.org/packages/7c/d3/41391aa07e6f519b44a5df0c6cb4a0bba05ab22151a1dcbc7dc9c18a028b/time_machine-3.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b4810f9be2dd8c9484a57e949c3ad23cbb6edf4c0e75ca6a10f5935b7d7c2cd0", size = 69674, upload-time = "2026-08-25T00:18:28.51Z" },
{ url = "https://files.pythonhosted.org/packages/ee/62/7f17def6289901f94726921811a16b9adce46e666362c75d45730c60274f/time_machine-3.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:122310a6af9c36e9a636da32830e591e7923e8a07bdd0a43276c3a36c6821c90", size = 15707, upload-time = "2025-12-17T23:32:50.969Z" }, { url = "https://files.pythonhosted.org/packages/df/e3/f948ca31e142cebbe1efd81d361bcac7e9db4de441a6705fc0e109856f34/time_machine-3.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a48f9ff297700a9036d6fc2a597d97ea2a2bbbd735b45a22fee9ebd3b6b7a9f", size = 67650, upload-time = "2026-08-25T00:18:29.54Z" },
{ url = "https://files.pythonhosted.org/packages/5d/d3/3502fb9bd3acb159c18844b26c43220201a0d4a622c0c853785d07699a92/time_machine-3.2.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ba3eeb0f018cc362dd8128befa3426696a2e16dd223c3fb695fde184892d4d8c", size = 39207, upload-time = "2025-12-17T23:32:52.033Z" },
{ url = "https://files.pythonhosted.org/packages/5a/be/8b27f4aa296fda14a5a2ad7f588ddd450603c33415ab3f8e85b2f1a44678/time_machine-3.2.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:77d38ba664b381a7793f8786efc13b5004f0d5f672dae814430445b8202a67a6", size = 40764, upload-time = "2025-12-17T23:32:53.167Z" },
{ url = "https://files.pythonhosted.org/packages/42/cd/fe4c4e5c8ab6d48fab3624c32be9116fb120173a35fe67e482e5cf68b3d2/time_machine-3.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f09abeb8f03f044d72712207e0489a62098ad3ad16dac38927fcf80baca4d6a7", size = 43508, upload-time = "2025-12-17T23:32:54.597Z" },
{ url = "https://files.pythonhosted.org/packages/b4/28/5a3ba2fce85b97655a425d6bb20a441550acd2b304c96b2c19d3839f721a/time_machine-3.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6b28367ce4f73987a55e230e1d30a57a3af85da8eb1a140074eb6e8c7e6ef19f", size = 41712, upload-time = "2025-12-17T23:32:55.781Z" },
{ url = "https://files.pythonhosted.org/packages/81/58/e38084be7fdabb4835db68a3a47e58c34182d79fc35df1ecbe0db2c5359f/time_machine-3.2.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:903c7751c904581da9f7861c3015bed7cdc40047321291d3694a3cdc783bbca3", size = 38939, upload-time = "2025-12-17T23:32:56.867Z" },
{ url = "https://files.pythonhosted.org/packages/40/d0/ad3feb0a392ef4e0c08bc32024950373ddc0669002cbdcbb9f3bf0c2d114/time_machine-3.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:528217cad85ede5f85c8bc78b0341868d3c3cfefc6ecb5b622e1cacb6c73247b", size = 39837, upload-time = "2025-12-17T23:32:58.283Z" },
] ]
[[package]] [[package]]
@@ -5202,15 +5196,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cf/fb/3b1644bc509bbf4390f2df086d3cd38a5e43882f963268b637a193a23ee5/types_dateparser-1.4.2.20260813-py3-none-any.whl", hash = "sha256:2f5b3daabde0bdcabce3b186281f948434a24bdfdf72323b111c928f7c310798", size = 25567, upload-time = "2026-08-13T03:56:31.094Z" }, { url = "https://files.pythonhosted.org/packages/cf/fb/3b1644bc509bbf4390f2df086d3cd38a5e43882f963268b637a193a23ee5/types_dateparser-1.4.2.20260813-py3-none-any.whl", hash = "sha256:2f5b3daabde0bdcabce3b186281f948434a24bdfdf72323b111c928f7c310798", size = 25567, upload-time = "2026-08-13T03:56:31.094Z" },
] ]
[[package]]
name = "types-docutils"
version = "0.22.3.20251115"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/eb/d7/576ec24bf61a280f571e1f22284793adc321610b9bcfba1bf468cf7b334f/types_docutils-0.22.3.20251115.tar.gz", hash = "sha256:0f79ea6a7bd4d12d56c9f824a0090ffae0ea4204203eb0006392906850913e16", size = 56828, upload-time = "2025-11-15T02:59:57.371Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/01/61ac9eb38f1f978b47443dc6fd2e0a3b0f647c2da741ddad30771f1b2b6f/types_docutils-0.22.3.20251115-py3-none-any.whl", hash = "sha256:c6e53715b65395d00a75a3a8a74e352c669bc63959e65a207dffaa22f4a2ad6e", size = 91951, upload-time = "2025-11-15T02:59:56.413Z" },
]
[[package]] [[package]]
name = "types-html5lib" name = "types-html5lib"
version = "1.1.11.20251117" version = "1.1.11.20251117"
@@ -5234,14 +5219,11 @@ wheels = [
[[package]] [[package]]
name = "types-pygments" name = "types-pygments"
version = "2.20.0.20260408" version = "2.21.0.20260819"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ sdist = { url = "https://files.pythonhosted.org/packages/84/2b/b3e929c39fb056f0e44560acbe53a7c3f28f428af5362d043642d80b31ec/types_pygments-2.21.0.20260819.tar.gz", hash = "sha256:68e0cb27115b08b681c843e30a153b5d9850c048e1158623ada36ccd4cf7e89b", size = 21509, upload-time = "2026-08-19T02:47:15.827Z" }
{ name = "types-docutils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fe/89/4b443128fa540c54a8f7ecdeec225aab4818534167c4a2d133099dc00fa6/types_pygments-2.20.0.20260408.tar.gz", hash = "sha256:e8a56a3ab1aee7f4ed8f1876d2f62c96e0f41ede52405a7d30c888f3989d8f00", size = 21115, upload-time = "2026-04-08T04:34:24.29Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/d8/30924b38eef70caef6b05af5440c84d7673cea2a042e206f404c8100a88d/types_pygments-2.20.0.20260408-py3-none-any.whl", hash = "sha256:6d347d5967b5f0654b659a8b8461a870b207b7e60cd4d646bbc047f6a8db8e1e", size = 29055, upload-time = "2026-04-08T04:34:23.412Z" }, { url = "https://files.pythonhosted.org/packages/d9/a5/10a2de06b53a36f4334391fcf292b51877cbc71d3f6eac353e7b87a0a17e/types_pygments-2.21.0.20260819-py3-none-any.whl", hash = "sha256:85e216770d616db6a7e96fb70c2de9d9f283b746c37edb7768ce645bdbcaddb2", size = 29099, upload-time = "2026-08-19T02:47:14.81Z" },
] ]
[[package]] [[package]]