Compare commits

..
Author SHA1 Message Date
Crowdin Bot 847a6c6502 New Crowdin translations by GitHub Action 2026-10-16 17:13:46 +00:00
GitHub Actions 1f3d451a3a Auto translate strings 2026-10-16 17:12:50 +00:00
Trenton H 183cfd2da4 Fix: keep counter and extension when shortening long public filenames (#14472)
get_public_filename relied on sanitize_filename to truncate, which cuts
from the end and so dropped the counter and extension of long titles.
Multi-byte titles could also exceed the 255 byte filesystem limit.

Shorten the title by UTF-8 bytes before appending the counter and
extension. The exporter reserves room for its longer derived thumbnail
and archive suffixes.
2026-10-16 10:11:34 -07:00
shamoon 30a24ee02e Chore: disable deprecated ts-jest isolation override 2026-10-15 22:51:55 -07:00
github-actions[bot]andCrowdin Bot 1943c99b27 New Crowdin translations by GitHub Action (#14449)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-10-15 22:51:00 -07:00
shamoon 8b26ecafb6 Fix: prevent ownerless docs leaking others in to LLM prompts 2026-10-15 22:42:34 -07:00
Trenton H d4a57204eb Fix: copy only direct user grants in DocumentMetadataOverrides.from_document (#14452)
get_users_with_perms includes group members by default, so PDF actions such
as merge and split gave them direct grants on the new documents, which
outlived their group membership.
2026-10-15 15:11:28 -07:00
dependabot[bot] c50c6f5517 Chore(deps): Bump filelock from 3.32.6 to 4.0.12 (#14336)
Bumps [filelock](https://github.com/tox-dev/py-filelock) from 3.32.6 to 4.0.12.
- [Release notes](https://github.com/tox-dev/py-filelock/releases)
- [Changelog](https://github.com/tox-dev/filelock/blob/main/docs/changelog.rst)
- [Commits](https://github.com/tox-dev/py-filelock/compare/3.32.6...4.0.12)

---
updated-dependencies:
- dependency-name: filelock
  dependency-version: 4.0.3
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-14 16:11:12 -07:00
69 changed files with 20284 additions and 19993 deletions

No files matched your search

+1 -1
View File
@@ -43,7 +43,7 @@ dependencies = [
"drf-spectacular~=0.30",
"drf-spectacular-sidecar>=2026.7.1,<2026.10",
"drf-writable-nested~=0.7.1",
"filelock~=3.32.0",
"filelock>=3.32,<4.1",
"flower>=2.0.1,<2.3",
"gotenberg-client[httpx]~=1.0",
"httpx-oauth~=0.17",
+2
View File
@@ -15,6 +15,8 @@ module.exports = {
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
useESM: true,
// Disable the builder's deprecated override; tsconfig.spec.json enables isolation.
isolatedModules: false,
},
],
},
+46 -46
View File
@@ -17,7 +17,7 @@
<context context-type="linenumber">386,393</context>
</context-group>
<note priority="1" from="description">Currently selected slide number read by screen reader</note>
<target state="needs-translation"> Slide <x id="INTERPOLATION" equiv-text="xt(source?: Ngb"/> of <x id="INTERPOLATION_1" equiv-text="this._cycleToSe"/> </target>
<target state="translated"> スライド <x id="INTERPOLATION" equiv-text="xt(source?: Ngb"/> / <x id="INTERPOLATION_1" equiv-text="this._cycleToSe"/> </target>
</trans-unit>
<trans-unit id="ngb.carousel.previous" datatype="html">
<source>Previous</source>
@@ -479,7 +479,7 @@
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">201</context>
</context-group>
<target state="needs-translation">Attributes like tags, correspondents, document types, storage paths and custom fields can all be managed here. They can also be created from the document edit view.</target>
<target state="translated">タグ、取引先、ドキュメントの種類、保存先パス、カスタムフィールドなどの属性をここで管理できます。これらはドキュメントの編集画面から作成することもできます。</target>
</trans-unit>
<trans-unit id="7851939076947092983" datatype="html">
<source>Manage e-mail accounts and rules for automatically importing documents.</source>
@@ -507,7 +507,7 @@
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">225</context>
</context-group>
<target state="needs-translation">Tasks helps you track background work, what needs attention, and what recently completed.</target>
<target state="translated">タスクでは、バックグラウンド処理の進行状況、対応が必要な項目、最近完了した処理を確認できます。</target>
</trans-unit>
<trans-unit id="2755033621682206902" datatype="html">
<source>Check out the settings for various tweaks to the web app.</source>
@@ -571,7 +571,7 @@
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">35</context>
</context-group>
<target state="needs-translation">This value overrides <x id="INTERPOLATION" equiv-text="{{option.config_key}}"/>, which is set outside Paperless.</target>
<target state="translated">この値は、Paperless の外で設定されている <x id="INTERPOLATION" equiv-text="{{option.config_key}}"/>を上書きします。</target>
</trans-unit>
<trans-unit id="7221396516204435584" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{option.config_key}}"/> is set outside Paperless. Enter a value here to override it.</source>
@@ -579,7 +579,7 @@
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">37</context>
</context-group>
<target state="needs-translation"><x id="INTERPOLATION" equiv-text="{{option.config_key}}"/> is set outside Paperless. Enter a value here to override it.</target>
<target state="translated"><x id="INTERPOLATION" equiv-text="{{option.config_key}}"/> はPaperlessの外部で設定されています。ここに値を入力すると、その設定を上書きできます。</target>
</trans-unit>
<trans-unit id="8318849619178340389" datatype="html">
<source>Use the externally configured value</source>
@@ -587,7 +587,7 @@
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">42</context>
</context-group>
<target state="needs-translation">Use the externally configured value</target>
<target state="translated">外部で設定された値を使用</target>
</trans-unit>
<trans-unit id="6032629623003430385" datatype="html">
<source>Reset to external</source>
@@ -595,7 +595,7 @@
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">43</context>
</context-group>
<target state="needs-translation">Reset to external</target>
<target state="translated">外部設定に戻す</target>
</trans-unit>
<trans-unit id="7808756054397155068" datatype="html">
<source>Reset</source>
@@ -875,7 +875,7 @@
<context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context>
<context context-type="linenumber">18,19</context>
</context-group>
<target state="needs-translation">lines</target>
<target state="translated">行</target>
</trans-unit>
<trans-unit id="8838884664569764142" datatype="html">
<source>Auto refresh</source>
@@ -1003,7 +1003,7 @@
<context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context>
<context context-type="linenumber">63,64</context>
</context-group>
<target state="needs-translation">Jump to bottom</target>
<target state="translated">一番下へ移動</target>
</trans-unit>
<trans-unit id="1255048712725285892" datatype="html">
<source>Options to customize appearance, notifications and more. Settings apply to the &lt;strong&gt;current user only&lt;/strong&gt;.</source>
@@ -1135,7 +1135,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">115,116</context>
</context-group>
<target state="needs-translation">Sidebar items to show:</target>
<target state="translated">サイドバーに表示するアイテム:</target>
</trans-unit>
<trans-unit id="1356890996281769972" datatype="html">
<source>Dark mode</source>
@@ -1323,7 +1323,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">201</context>
</context-group>
<target state="needs-translation">Show document counts in sidebar saved views</target>
<target state="translated">サイドバーの保存済みビューにドキュメント数を表示</target>
</trans-unit>
<trans-unit id="8939587804990976924" datatype="html">
<source>Items per page</source>
@@ -1403,7 +1403,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">259,260</context>
</context-group>
<target state="needs-translation">Suggestions default to</target>
<target state="translated">提案機能のデフォルト設定</target>
</trans-unit>
<trans-unit id="6251586038459969294" datatype="html">
<source>Both</source>
@@ -1411,7 +1411,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">264</context>
</context-group>
<target state="needs-translation">Both</target>
<target state="translated">両方</target>
</trans-unit>
<trans-unit id="8967224283122923634" datatype="html">
<source>ML only</source>
@@ -1427,7 +1427,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">268</context>
</context-group>
<target state="needs-translation">AI only</target>
<target state="translated">AIのみ</target>
</trans-unit>
<trans-unit id="1043449647727456069" datatype="html">
<source>Automatically request suggestions for inbox documents</source>
@@ -1435,7 +1435,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">276</context>
</context-group>
<target state="needs-translation">Automatically request suggestions for inbox documents</target>
<target state="translated">受信トレイ内のドキュメントに対して自動的に提案をリクエストする</target>
</trans-unit>
<trans-unit id="460700039378563125" datatype="html">
<source>If un-checked, suggestions must be requested via the Suggest button.</source>
@@ -1443,7 +1443,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">276</context>
</context-group>
<target state="needs-translation">If un-checked, suggestions must be requested via the Suggest button.</target>
<target state="translated">チェックを外した場合、提案を取得するには「提案」ボタンを押す必要があります。</target>
</trans-unit>
<trans-unit id="8793267604636304297" datatype="html">
<source>Show document thumbnail during loading</source>
@@ -1467,7 +1467,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">300,301</context>
</context-group>
<target state="needs-translation">Uncheck fields to hide them on the document details page.</target>
<target state="translated">ドキュメントの詳細ページで非表示にするフィールドは、チェックを外してください。</target>
</trans-unit>
<trans-unit id="8508424367627989968" datatype="html">
<source>Bulk editing</source>
@@ -1507,7 +1507,7 @@
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1907</context>
</context-group>
<target state="needs-translation">PDF Editor</target>
<target state="translated">PDF エディター</target>
</trans-unit>
<trans-unit id="1577733187050997705" datatype="html">
<source>Default editing mode</source>
@@ -1515,7 +1515,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">317,318</context>
</context-group>
<target state="needs-translation">Default editing mode</target>
<target state="translated">デフォルトの編集モード</target>
</trans-unit>
<trans-unit id="7273640930165035289" datatype="html">
<source>Create new document(s)</source>
@@ -1527,7 +1527,7 @@
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
<context context-type="linenumber">82,83</context>
</context-group>
<target state="needs-translation">Create new document(s)</target>
<target state="translated">新しいドキュメントを作成</target>
</trans-unit>
<trans-unit id="7860582931776068318" datatype="html">
<source>Add document version</source>
@@ -1539,7 +1539,7 @@
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
<context context-type="linenumber">87,88</context>
</context-group>
<target state="needs-translation">Add document version</target>
<target state="translated">ドキュメントのバージョンを追加</target>
</trans-unit>
<trans-unit id="8104421162933956065" datatype="html">
<source>Notes</source>
@@ -1639,7 +1639,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">347</context>
</context-group>
<target state="needs-translation"> Settings apply to this user account for objects (Tags, Mail Rules, etc.) created via the web UI. These settings do not apply to documents. </target>
<target state="translated">これらの設定は、Web UIから作成されたオブジェクト(タグ、メールルールなど)に対して、このユーザーアカウントに適用されます。ドキュメントには適用されません。</target>
</trans-unit>
<trans-unit id="4292903881380648974" datatype="html">
<source>Default Owner</source>
@@ -2203,7 +2203,7 @@
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">331</context>
</context-group>
<target state="needs-translation">Tasks</target>
<target state="translated">タスク</target>
</trans-unit>
<trans-unit id="8492095365580052820" datatype="html">
<source>Tasks shows detailed information about document consumption and system tasks.</source>
@@ -2211,7 +2211,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">4,5</context>
</context-group>
<target state="needs-translation">Tasks shows detailed information about document consumption and system tasks.</target>
<target state="translated">タスクでは、ドキュメントの取り込み処理やシステムタスクに関する詳細情報を確認できます。</target>
</trans-unit>
<trans-unit id="103921551219467537" datatype="html">
<source>Clear selection</source>
@@ -2235,7 +2235,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">16</context>
</context-group>
<target state="needs-translation">Dismiss all</target>
<target state="translated">すべて閉じる</target>
</trans-unit>
<trans-unit id="1616102757855967475" datatype="html">
<source>All</source>
@@ -2295,7 +2295,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">219</context>
</context-group>
<target state="needs-translation">All types</target>
<target state="translated">すべての種類</target>
</trans-unit>
<trans-unit id="131016739441837046" datatype="html">
<source>All sources</source>
@@ -2307,7 +2307,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">231</context>
</context-group>
<target state="needs-translation">All sources</target>
<target state="translated">すべてのソース</target>
</trans-unit>
<trans-unit id="3643866539136239218" datatype="html">
<source>Clear search</source>
@@ -2327,7 +2327,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">92,93</context>
</context-group>
<target state="needs-translation">Search tasks</target>
<target state="translated">タスクを検索</target>
</trans-unit>
<trans-unit id="6849725902312323996" datatype="html">
<source>Reset filters</source>
@@ -2351,7 +2351,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">122</context>
</context-group>
<target state="needs-translation">{VAR_PLURAL, plural, =1 {1 task} other {<x id="INTERPOLATION"/> tasks}}</target>
<target state="translated">{VAR_PLURAL, plural, =1 {1件のタスク} other {<x id="INTERPOLATION"/>件のタスク}}</target>
</trans-unit>
<trans-unit id="8953033926734869941" datatype="html">
<source>Name</source>
@@ -2651,7 +2651,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">207</context>
</context-group>
<target state="needs-translation">View task details</target>
<target state="translated">タスクの詳細を表示</target>
</trans-unit>
<trans-unit id="1536087519743707362" datatype="html">
<source>Dismiss</source>
@@ -2691,7 +2691,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">232,233</context>
</context-group>
<target state="needs-translation">Result message</target>
<target state="translated">結果メッセージ</target>
</trans-unit>
<trans-unit id="6621329748219109148" datatype="html">
<source>Duplicate</source>
@@ -2699,7 +2699,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">239,240</context>
</context-group>
<target state="needs-translation">Duplicate</target>
<target state="translated">重複</target>
</trans-unit>
<trans-unit id="7593555694782789615" datatype="html">
<source>Open</source>
@@ -2739,7 +2739,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">257,258</context>
</context-group>
<target state="needs-translation">Input data</target>
<target state="translated">入力データ</target>
</trans-unit>
<trans-unit id="1585185618099050920" datatype="html">
<source>Result data</source>
@@ -2747,7 +2747,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">263,264</context>
</context-group>
<target state="needs-translation">Result data</target>
<target state="translated">結果データ</target>
</trans-unit>
<trans-unit id="7976920528153858271" datatype="html">
<source>No tasks match the current filters.</source>
@@ -2755,7 +2755,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">285,287</context>
</context-group>
<target state="needs-translation">No tasks match the current filters.</target>
<target state="translated">現在のフィルター条件に一致するタスクはありません。</target>
</trans-unit>
<trans-unit id="2525230676386818985" datatype="html">
<source>Result</source>
@@ -2771,7 +2771,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">55</context>
</context-group>
<target state="needs-translation">Needs attention</target>
<target state="translated">注意が必要</target>
</trans-unit>
<trans-unit id="3307214558906712688" datatype="html">
<source>In progress</source>
@@ -2779,7 +2779,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">56</context>
</context-group>
<target state="needs-translation">In progress</target>
<target state="translated">進行中</target>
</trans-unit>
<trans-unit id="1371178633976505412" datatype="html">
<source>Recently completed</source>
@@ -2787,7 +2787,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">57</context>
</context-group>
<target state="needs-translation">Recently completed</target>
<target state="translated">最近完了したタスク</target>
</trans-unit>
<trans-unit id="2936452645673957909" datatype="html">
<source>Consume File</source>
@@ -2795,7 +2795,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">66</context>
</context-group>
<target state="needs-translation">Consume File</target>
<target state="translated">ファイルの取り込み</target>
</trans-unit>
<trans-unit id="5622386857946132209" datatype="html">
<source>Train Classifier</source>
@@ -2811,7 +2811,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">74</context>
</context-group>
<target state="needs-translation">Sanity Check</target>
<target state="translated">整合性チェック</target>
</trans-unit>
<trans-unit id="7502272564743467653" datatype="html">
<source>Mail Fetch</source>
@@ -2839,7 +2839,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">80</context>
</context-group>
<target state="needs-translation">Empty Trash</target>
<target state="translated">ゴミ箱を空にする</target>
</trans-unit>
<trans-unit id="4173754487295857311" datatype="html">
<source>Check Workflows</source>
@@ -2847,7 +2847,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">84</context>
</context-group>
<target state="needs-translation">Check Workflows</target>
<target state="translated">ワークフローの確認</target>
</trans-unit>
<trans-unit id="2065831805515896240" datatype="html">
<source>Bulk Update</source>
@@ -2855,7 +2855,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">88</context>
</context-group>
<target state="needs-translation">Bulk Update</target>
<target state="translated">一括更新</target>
</trans-unit>
<trans-unit id="5062348912423871240" datatype="html">
<source>Reprocess Document</source>
@@ -2863,7 +2863,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">92</context>
</context-group>
<target state="needs-translation">Reprocess Document</target>
<target state="translated">ドキュメントの再処理</target>
</trans-unit>
<trans-unit id="6903696804811148799" datatype="html">
<source>Build Share Link</source>
@@ -2871,7 +2871,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">96</context>
</context-group>
<target state="needs-translation">Build Share Link</target>
<target state="translated">共有リンクの生成</target>
</trans-unit>
<trans-unit id="6581973658756561124" datatype="html">
<source>Bulk Delete</source>
@@ -2879,7 +2879,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">100</context>
</context-group>
<target state="needs-translation">Bulk Delete</target>
<target state="translated">一括削除</target>
</trans-unit>
<trans-unit id="5672013191898828327" datatype="html">
<source>Apply AI Suggestions</source>
+20 -20
View File
@@ -17,7 +17,7 @@
<context context-type="linenumber">386,393</context>
</context-group>
<note priority="1" from="description">Currently selected slide number read by screen reader</note>
<target state="needs-translation"> Slide <x id="INTERPOLATION" equiv-text="xt(source?: Ngb"/> of <x id="INTERPOLATION_1" equiv-text="this._cycleToSe"/> </target>
<target state="translated"> Shlid <x id="INTERPOLATION" equiv-text="xt(source?: Ngb"/> i <x id="INTERPOLATION_1" equiv-text="this._cycleToSe"/></target>
</trans-unit>
<trans-unit id="ngb.carousel.previous" datatype="html">
<source>Previous</source>
@@ -571,7 +571,7 @@
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">35</context>
</context-group>
<target state="needs-translation">This value overrides <x id="INTERPOLATION" equiv-text="{{option.config_key}}"/>, which is set outside Paperless.</target>
<target state="translated">Ky vlerë mbizotëron <x id="INTERPOLATION" equiv-text="{{option.config_key}}"/>, i cili është vendosur jashtë Paperless.</target>
</trans-unit>
<trans-unit id="7221396516204435584" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{option.config_key}}"/> is set outside Paperless. Enter a value here to override it.</source>
@@ -579,7 +579,7 @@
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">37</context>
</context-group>
<target state="needs-translation"><x id="INTERPOLATION" equiv-text="{{option.config_key}}"/> is set outside Paperless. Enter a value here to override it.</target>
<target state="translated"><x id="INTERPOLATION" equiv-text="{{option.config_key}}"/> është vendosur jashtë Paperless. Shkruani një vlerë këtu për ta mbingarkuar atë.</target>
</trans-unit>
<trans-unit id="8318849619178340389" datatype="html">
<source>Use the externally configured value</source>
@@ -587,7 +587,7 @@
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">42</context>
</context-group>
<target state="needs-translation">Use the externally configured value</target>
<target state="translated">Përdorni vlerën e konfiguruar nga jashtë</target>
</trans-unit>
<trans-unit id="6032629623003430385" datatype="html">
<source>Reset to external</source>
@@ -595,7 +595,7 @@
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">43</context>
</context-group>
<target state="needs-translation">Reset to external</target>
<target state="translated">Rinisni në të jashtëm</target>
</trans-unit>
<trans-unit id="7808756054397155068" datatype="html">
<source>Reset</source>
@@ -1135,7 +1135,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">115,116</context>
</context-group>
<target state="needs-translation">Sidebar items to show:</target>
<target state="translated">Elementët e shiritit anësor për t'u shfaqur:</target>
</trans-unit>
<trans-unit id="1356890996281769972" datatype="html">
<source>Dark mode</source>
@@ -1403,7 +1403,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">259,260</context>
</context-group>
<target state="needs-translation">Suggestions default to</target>
<target state="translated">Sugjerimet parazgjedhore janë</target>
</trans-unit>
<trans-unit id="6251586038459969294" datatype="html">
<source>Both</source>
@@ -1411,7 +1411,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">264</context>
</context-group>
<target state="needs-translation">Both</target>
<target state="translated">Të dyja</target>
</trans-unit>
<trans-unit id="8967224283122923634" datatype="html">
<source>ML only</source>
@@ -1419,7 +1419,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">266</context>
</context-group>
<target state="needs-translation">ML only</target>
<target state="translated">Vetëm ML</target>
</trans-unit>
<trans-unit id="6418969597665857901" datatype="html">
<source>AI only</source>
@@ -1427,7 +1427,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">268</context>
</context-group>
<target state="needs-translation">AI only</target>
<target state="translated">Vetëm AI</target>
</trans-unit>
<trans-unit id="1043449647727456069" datatype="html">
<source>Automatically request suggestions for inbox documents</source>
@@ -1435,7 +1435,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">276</context>
</context-group>
<target state="needs-translation">Automatically request suggestions for inbox documents</target>
<target state="translated">Kërkoni automatikisht sugjerime për dokumentet në kutinë e postës elektronike</target>
</trans-unit>
<trans-unit id="460700039378563125" datatype="html">
<source>If un-checked, suggestions must be requested via the Suggest button.</source>
@@ -1443,7 +1443,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">276</context>
</context-group>
<target state="needs-translation">If un-checked, suggestions must be requested via the Suggest button.</target>
<target state="translated">Nëse nuk është shënuar, sugjerimet duhet të kërkohen përmes butonit Sugjero.</target>
</trans-unit>
<trans-unit id="8793267604636304297" datatype="html">
<source>Show document thumbnail during loading</source>
@@ -2319,7 +2319,7 @@
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
<context context-type="linenumber">21</context>
</context-group>
<target state="needs-translation">Clear search</target>
<target state="translated">Pastro kërkimin</target>
</trans-unit>
<trans-unit id="3570013039254960445" datatype="html">
<source>Search tasks</source>
@@ -2651,7 +2651,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">207</context>
</context-group>
<target state="needs-translation">View task details</target>
<target state="translated">Shiko detajet e detyrës</target>
</trans-unit>
<trans-unit id="1536087519743707362" datatype="html">
<source>Dismiss</source>
@@ -2887,7 +2887,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">104</context>
</context-group>
<target state="needs-translation">Apply AI Suggestions</target>
<target state="translated">Apliko sugjerimet e AI</target>
</trans-unit>
<trans-unit id="9172233176401579786" datatype="html">
<source>Scheduled</source>
@@ -3783,7 +3783,7 @@
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">42</context>
</context-group>
<target state="needs-translation">User menu</target>
<target state="translated">Menuja e përdoruesit</target>
</trans-unit>
<trans-unit id="2448391510242468907" datatype="html">
<source>Logged in as <x id="INTERPOLATION" equiv-text="{{this.settingsService.displayName}}"/></source>
@@ -3939,7 +3939,7 @@
<context context-type="sourcefile">src/app/components/manage/share-links/share-links.component.html</context>
<context context-type="linenumber">2,3</context>
</context-group>
<target state="needs-translation">Share links</target>
<target state="translated">Ndani lidhjet</target>
</trans-unit>
<trans-unit id="7844706011418789951" datatype="html">
<source>Administration</source>
@@ -4015,7 +4015,7 @@
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">403</context>
</context-group>
<target state="needs-translation">Configure update checking</target>
<target state="translated">Konfiguro kontrollin e përditësimeve</target>
</trans-unit>
<trans-unit id="1542489069631984294" datatype="html">
<source>Sidebar views updated</source>
@@ -4259,7 +4259,7 @@
<context context-type="sourcefile">src/app/data/saved-view-icons.ts</context>
<context context-type="linenumber">21</context>
</context-group>
<target state="needs-translation">Chat</target>
<target state="translated">Bisedë</target>
</trans-unit>
<trans-unit id="8066608938393600549" datatype="html">
<source>Message</source>
@@ -4379,7 +4379,7 @@
<context context-type="sourcefile">src/app/components/common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component.html</context>
<context context-type="linenumber">8,9</context>
</context-group>
<target state="needs-translation">Root document:</target>
<target state="translated">Dokumenti rrënjësor:</target>
</trans-unit>
<trans-unit id="2121026240713807342" datatype="html">
<source>Versions (oldest first):</source>
+2
View File
@@ -130,12 +130,14 @@ class DocumentMetadataOverrides:
get_users_with_perms(
doc,
only_with_perms_in=["view_document"],
with_group_users=False,
).values_list("id", flat=True),
)
overrides.change_users = list(
get_users_with_perms(
doc,
only_with_perms_in=["change_document"],
with_group_users=False,
).values_list("id", flat=True),
)
overrides.custom_fields = {
@@ -3,6 +3,7 @@ from itertools import islice
from pathlib import Path
from typing import TYPE_CHECKING
from typing import Any
from typing import Final
from allauth.mfa.models import Authenticator
from allauth.socialaccount.models import SocialAccount
@@ -70,6 +71,15 @@ from paperless.models import ApplicationConfiguration
from paperless_mail.models import MailAccount
from paperless_mail.models import MailRule
# Derived names replace the original's extension with these suffixes
THUMBNAIL_SUFFIX: Final[str] = "-thumbnail.webp"
ARCHIVE_SUFFIX: Final[str] = "-archive.pdf"
# Leave room for the longest derived suffix, which replaces the original's extension
MAX_BASE_NAME_BYTES: Final[int] = Document.MAX_PUBLIC_FILENAME_BYTES - max(
len(THUMBNAIL_SUFFIX),
len(ARCHIVE_SUFFIX),
)
def serialize_queryset_batched(
queryset: "QuerySet[Any]",
@@ -512,7 +522,10 @@ class Command(CryptMixin, PaperlessCommand):
counter=filename_counter,
)
else:
base_name = document.get_public_filename(counter=filename_counter)
base_name = document.get_public_filename(
counter=filename_counter,
max_bytes=MAX_BASE_NAME_BYTES,
)
# The thumbnail and archive names are derived from the stem, so
# uniqueness must hold without the original's extension
@@ -541,7 +554,7 @@ class Command(CryptMixin, PaperlessCommand):
document_dict[EXPORTER_FILE_NAME] = original_arc
if not self.no_thumbnail:
thumbnail_name = base_name.parent / (base_name.stem + "-thumbnail.webp")
thumbnail_name = base_name.parent / (base_name.stem + THUMBNAIL_SUFFIX)
if self.use_folder_prefix:
thumbnail_name = Path("thumbnails") / thumbnail_name
thumbnail_arc = thumbnail_name.as_posix()
@@ -550,7 +563,7 @@ class Command(CryptMixin, PaperlessCommand):
thumbnail_arc = None
if not self.no_archive and document.has_archive_version:
archive_name = base_name.parent / (base_name.stem + "-archive.pdf")
archive_name = base_name.parent / (base_name.stem + ARCHIVE_SUFFIX)
if self.use_folder_prefix:
archive_name = Path("archive") / archive_name
archive_arc = archive_name.as_posix()
+23 -12
View File
@@ -157,6 +157,8 @@ class StoragePath(MatchingModel):
class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-missing]
MAX_STORED_FILENAME_LENGTH: Final[int] = 1024
# Per-component limit of common filesystems (ext4, XFS, btrfs, ZFS, APFS)
MAX_PUBLIC_FILENAME_BYTES: Final[int] = 255
correspondent = models.ForeignKey(
Correspondent,
@@ -484,28 +486,37 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
def archive_file(self):
return Path(self.archive_path).open("rb")
def get_public_filename(self, *, archive=False, counter=0, suffix=None) -> str:
def get_public_filename(
self,
*,
archive: bool = False,
counter: int = 0,
max_bytes: int = MAX_PUBLIC_FILENAME_BYTES,
) -> str:
"""
Returns a sanitized filename for the document, not including any paths.
The name fits in max_bytes (UTF-8). A long title is shortened before
the counter and extension are appended, because sanitize_filename
truncates from the end. The tail is cut only when max_bytes is
smaller than the tail itself.
"""
# Root owns metadata for all versions
context_document = (
self.root_document if self.root_document_id is not None else self
)
result = str(context_document)
if counter:
result += f"_{counter:02}"
extension = ".pdf" if archive else self.file_type
tail = f"_{counter:02}{extension}" if counter else extension
if suffix:
result += suffix
head_bytes = max(0, max_bytes - len(tail.encode()))
head = str(context_document).encode()[:head_bytes].decode(errors="ignore")
if archive:
result += ".pdf"
else:
result += self.file_type
return pathvalidate.sanitize_filename(result, replacement_text="-")
return pathvalidate.sanitize_filename(
head + tail,
replacement_text="-",
max_len=max_bytes,
)
@property
def file_type(self):
+72 -48
View File
@@ -1,11 +1,5 @@
from collections.abc import Mapping
from collections.abc import Sequence
from typing import Any
from typing import ClassVar
from typing import Literal
from typing import TypeGuard
from typing import TypeVar
from typing import cast
from django.contrib.auth.models import Group
from django.contrib.auth.models import Permission
@@ -68,23 +62,28 @@ class PaperlessAdminPermissions(BasePermission):
return request.user.is_active and request.user.is_staff
def _is_active_and_authenticated(user: User | None) -> TypeGuard[User]:
return (
user is not None
and getattr(user, "is_active", False)
and getattr(user, "is_authenticated", False)
)
def has_global_statistics_permission(user: User | None) -> bool:
return _is_active_and_authenticated(user) and (
getattr(user, "is_superuser", False)
or user.has_perm("paperless.view_global_statistics")
if (
user is None
or not getattr(user, "is_active", False)
or not getattr(user, "is_authenticated", False)
):
return False
return getattr(user, "is_superuser", False) or user.has_perm(
"paperless.view_global_statistics",
)
def has_system_status_permission(user: User | None) -> bool:
return _is_active_and_authenticated(user) and (
if (
user is None
or not getattr(user, "is_active", False)
or not getattr(user, "is_authenticated", False)
):
return False
return (
getattr(user, "is_superuser", False)
or getattr(user, "is_staff", False)
or user.has_perm("paperless.view_system_monitoring")
@@ -512,6 +511,28 @@ def restrict_queryset_to_visible(
return queryset.filter(pk__in=permitted_object_ids(user, queryset.model, perm))
def user_is_active_superuser(user: User | None) -> bool:
"""
True only for an active, authenticated superuser.
"""
return user is not None and user_is_unrestricted(user)
def restrict_queryset_to_owner_visible(
queryset: QuerySet[ModelT],
user: User | None,
perm: str,
) -> QuerySet[ModelT]:
"""
Like ``restrict_queryset_to_visible``, but ``user=None`` sees only unowned
rows (``permitted_object_ids(None, ...)`` semantics) instead of every row.
See ``user_is_active_superuser``.
"""
if user_is_active_superuser(user):
return queryset
return queryset.filter(pk__in=permitted_object_ids(user, queryset.model, perm))
def permitted_document_ids(
user: User | None,
*,
@@ -706,32 +727,7 @@ def has_perms_owner_aware(user, perms, obj):
return obj.owner is None or obj.owner == user or checker.has_perm(perms, obj)
# HEAD is deliberately absent: GlobalPermissionsByMethod checks it against GET.
MappedMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
class GlobalPermissionsByMethod(BasePermission):
"""
Requires the global (model-level) permissions listed in ``perms_map`` for
the request's HTTP method. HEAD is checked against the ``"GET"`` entry,
since DRF runs the GET handler for it. A method missing from
``perms_map`` is denied.
"""
perms_map: ClassVar[Mapping[MappedMethod, Sequence[str]]]
def has_permission(self, request: Any, view: Any) -> bool:
if not request.user or not request.user.is_authenticated:
return False
method = "GET" if request.method == "HEAD" else request.method
# An unknown method is not a MappedMethod, but .get() returns None for
# it, so it is still denied.
perms = self.perms_map.get(cast("MappedMethod", method))
return perms is not None and request.user.has_perms(perms)
class ViewDocumentsPermissions(GlobalPermissionsByMethod):
class ViewDocumentsPermissions(BasePermission):
"""
Permissions class that checks for model permissions for only viewing Documents.
"""
@@ -742,18 +738,31 @@ class ViewDocumentsPermissions(GlobalPermissionsByMethod):
"POST": ["documents.view_document"],
}
def has_permission(self, request, view):
if not request.user or (not request.user.is_authenticated): # pragma: no cover
return False
class TrashPermissions(GlobalPermissionsByMethod):
return request.user.has_perms(self.perms_map.get(request.method, []))
class TrashPermissions(BasePermission):
"""Check the global document permission for each trash operation."""
perms_map = {
"OPTIONS": ["documents.view_document"],
"HEAD": ["documents.view_document"],
"GET": ["documents.view_document"],
"POST": ["documents.delete_document"],
}
def has_permission(self, request, view):
if not request.user or not request.user.is_authenticated: # pragma: no cover
return False
class PaperlessNotePermissions(GlobalPermissionsByMethod):
return request.user.has_perms(self.perms_map.get(request.method, []))
class PaperlessNotePermissions(BasePermission):
"""
Permissions class that checks for model permissions for Notes.
"""
@@ -773,13 +782,28 @@ class PaperlessNotePermissions(GlobalPermissionsByMethod):
],
}
def has_permission(self, request, view):
if not request.user or (not request.user.is_authenticated): # pragma: no cover
return False
class AcknowledgeTasksPermissions(GlobalPermissionsByMethod):
perms = self.perms_map[request.method]
return request.user.has_perms(perms)
class AcknowledgeTasksPermissions(BasePermission):
"""
Permissions class that checks for model permissions for acknowledging tasks.
"""
perms_map = {
"OPTIONS": ["documents.change_paperlesstask"],
"POST": ["documents.change_paperlesstask"],
}
def has_permission(self, request: Any, view: Any) -> bool:
if not request.user or not request.user.is_authenticated: # pragma: no cover
return False
perms = self.perms_map.get(request.method, [])
return request.user.has_perms(perms)
-28
View File
@@ -3653,34 +3653,6 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_notes_head_and_unsupported_methods(self) -> None:
"""
GIVEN:
- A user allowed to view notes on their own document
WHEN:
- The notes endpoint is requested with HEAD, PUT and PATCH
THEN:
- HEAD is allowed like GET
- PUT and PATCH are rejected with 403 rather than a server error
"""
user = UserFactory()
grant_global(user, "view_note", "view_document")
doc = DocumentFactory(owner=user)
self.client.force_authenticate(user)
self.assertEqual(
self.client.head(f"/api/documents/{doc.pk}/notes/").status_code,
status.HTTP_200_OK,
)
self.assertEqual(
self.client.put(f"/api/documents/{doc.pk}/notes/").status_code,
status.HTTP_403_FORBIDDEN,
)
self.assertEqual(
self.client.patch(f"/api/documents/{doc.pk}/notes/").status_code,
status.HTTP_403_FORBIDDEN,
)
def test_delete_note(self) -> None:
"""
GIVEN:
-15
View File
@@ -1001,21 +1001,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 10)
def test_search_autocomplete_head_requires_view_document(self) -> None:
"""
GIVEN:
- A user without the global view document permission
WHEN:
- A HEAD request is made for autocomplete
THEN:
- It is rejected like the equivalent GET request
"""
self.client.force_authenticate(user=UserFactory())
response = self.client.head("/api/search/autocomplete/?term=test")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_search_autocomplete_respect_permissions(self) -> None:
"""
GIVEN:
+28
View File
@@ -34,8 +34,10 @@ from documents.tests.utils import GetConsumerMixin
from paperless_mail.models import MailRule
from paperless_testing.assertions import FileSystemAssertsMixin
from paperless_testing.dirs import DirectoriesMixin
from paperless_testing.factories import DocumentFactory
from paperless_testing.factories import UserFactory
from paperless_testing.fakes.progress import FakeProgressManager
from paperless_testing.permissions import grant_object
class _BaseNewStyleParser:
@@ -1683,6 +1685,32 @@ class TestMetadataOverrides(TestCase):
self.assertEqual(base.actor_id, 2)
self.assertEqual(base.version_label, "v2")
def test_from_document_copies_user_and_group_grants_separately(self) -> None:
"""
GIVEN:
- A document with view and change granted directly to one user and
to a group with another user as a member
WHEN:
- Overrides are built from the document
THEN:
- The user lists hold only the directly granted user
- The group lists hold the group
"""
direct_user = UserFactory()
group_member = UserFactory()
group = Group.objects.create(name="group")
group_member.groups.add(group)
document = DocumentFactory()
grant_object(direct_user, document, "view_document", "change_document")
grant_object(group, document, "view_document", "change_document")
overrides = DocumentMetadataOverrides.from_document(document)
self.assertEqual(overrides.view_users, [direct_user.pk])
self.assertEqual(overrides.change_users, [direct_user.pk])
self.assertEqual(overrides.view_groups, [group.pk])
self.assertEqual(overrides.change_groups, [group.pk])
class TestBarcodeApplyDetectedASN(TestCase):
"""
@@ -4,13 +4,16 @@ from datetime import date
from pathlib import Path
from unittest import mock
import pytest
from django.test import TestCase
from django.test import override_settings
from faker import Faker
from pytest_mock import MockerFixture
from documents.models import Correspondent
from documents.models import Document
from documents.tasks import empty_trash
from paperless_testing.factories import DocumentFactory
class TestDocument(TestCase):
@@ -257,3 +260,72 @@ def test_suggestion_content() -> None:
)
doc.content = content_over_limit
assert doc.suggestion_content == expected_cropped_content
class TestPublicFilenameLength:
@pytest.fixture(autouse=True)
def _no_parser_registry(self, mocker: MockerFixture) -> None:
# The parser registry reads ApplicationConfiguration from the database
extensions = {"application/pdf": ".pdf", "image/jpeg": ".jpg"}
mocker.patch(
"documents.models.get_default_file_extension",
side_effect=extensions.__getitem__,
)
LIMIT = Document.MAX_PUBLIC_FILENAME_BYTES
@pytest.mark.parametrize(
("title", "archive", "counter", "max_bytes", "tail"),
[
pytest.param("a" * 300, False, 0, LIMIT, ".jpg", id="ascii"),
pytest.param("a" * 300, True, 0, LIMIT, ".pdf", id="archive"),
pytest.param("ä" * 200, False, 0, LIMIT, ".jpg", id="umlaut"),
pytest.param("ä" * 200, False, 1, LIMIT, "_01.jpg", id="count"),
pytest.param("a" * 300, False, 1, 100, "_01.jpg", id="max"),
],
)
def test_long_title_keeps_tail_within_limit(
self,
title: str,
*,
archive: bool,
counter: int,
max_bytes: int,
tail: str,
) -> None:
"""
GIVEN:
- A document whose title alone is longer than the byte limit
WHEN:
- The public file name is generated
THEN:
- The name ends with the counter and extension
- The name fits in the limit
"""
name = DocumentFactory.build(
title=title,
mime_type="image/jpeg",
).get_public_filename(
archive=archive,
counter=counter,
max_bytes=max_bytes,
)
assert name.endswith(tail)
assert len(name.encode()) <= max_bytes
def test_long_title_counters_give_distinct_names(self) -> None:
"""
GIVEN:
- A document whose title alone is longer than the byte limit
WHEN:
- Public file names are generated with increasing counters
THEN:
- Each counter produces a distinct name
"""
doc = DocumentFactory.build(title="ä" * 200, mime_type="application/pdf")
names = [doc.get_public_filename(counter=counter) for counter in range(3)]
assert len(set(names)) == 3
assert all(len(n.encode()) <= self.LIMIT for n in names)
@@ -1537,3 +1537,70 @@ class TestExportDerivedFileNameCollisions:
assert (
target / record[EXPORTER_ARCHIVE_NAME]
).read_text() == f"archive {pk}"
def test_long_titles_export_within_file_name_limit(
self,
paperless_dirs: PaperlessDirs,
tmp_path: Path,
) -> None:
"""
GIVEN:
- Two documents with the same created date and the same title
- The title alone is longer than 255 bytes and contains umlauts
WHEN:
- The exporter runs
THEN:
- The export finishes with distinct original, thumbnail and archive names
- Every exported file name fits in the byte limit
- Each exported file holds its own document's content
"""
for pk in (1, 2):
(paperless_dirs.originals_dir / f"{pk:07}.pdf").write_bytes(
f"original {pk}".encode(),
)
(paperless_dirs.archive_dir / f"{pk:07}.pdf").write_bytes(
f"archive {pk}".encode(),
)
(paperless_dirs.thumbnail_dir / f"{pk:07}.webp").write_bytes(
f"thumbnail {pk}".encode(),
)
DocumentFactory(
pk=pk,
title="Bestellbestätigung " * 15,
created=date(2026, 10, 15),
filename=f"{pk:07}.pdf",
archive_filename=f"{pk:07}.pdf",
mime_type="application/pdf",
)
target = tmp_path / "export"
target.mkdir()
call_command(
"document_exporter",
str(target),
"--no-progress-bar",
skip_checks=True,
)
manifest = json.loads((target / "manifest.json").read_text())
records = {r["pk"]: r for r in manifest if r["model"] == "documents.document"}
for key in (
EXPORTER_FILE_NAME,
EXPORTER_THUMBNAIL_NAME,
EXPORTER_ARCHIVE_NAME,
):
assert records[1][key] != records[2][key]
for pk in (1, 2):
record = records[pk]
for key, content in (
(EXPORTER_FILE_NAME, f"original {pk}"),
(EXPORTER_THUMBNAIL_NAME, f"thumbnail {pk}"),
(EXPORTER_ARCHIVE_NAME, f"archive {pk}"),
):
assert (
len(Path(record[key]).name.encode())
<= Document.MAX_PUBLIC_FILENAME_BYTES
)
assert (target / record[key]).read_text() == content
+44
View File
@@ -6176,6 +6176,50 @@ class TestApplyAISuggestionsWorkflowAction(
self.assertEqual(self.doc.tags.count(), 0)
def test_unowned_document_does_not_get_private_objects_by_id(self) -> None:
"""
GIVEN:
- An unowned document
- AI suggestions selecting another user's private tag and
correspondent by ID, plus an unowned tag
WHEN:
- The suggestions are applied
THEN:
- Only the unowned tag is assigned; an unowned document must not
be treated as unrestricted
"""
self.doc.owner = None
self.doc.save(update_fields=["owner"])
other = User.objects.create(username="someone-else")
private_tag = Tag.objects.create(name="Private Tag", owner=other)
public_tag = Tag.objects.create(name="Public Tag", owner=None)
private_correspondent = Correspondent.objects.create(
name="Private Correspondent",
owner=other,
)
action = self.make_action(
ai_suggestion_fields=[
WorkflowAction.AISuggestionField.TAGS,
WorkflowAction.AISuggestionField.CORRESPONDENT,
],
)
suggestions: ClassificationSuggestions = {
**SUGGESTIONS,
"tags": {
"existing_ids": [private_tag.pk, public_tag.pk],
"new_names": [],
},
"correspondents": {
"existing_ids": [private_correspondent.pk],
"new_names": [],
},
}
self.apply(action, suggestions)
self.assertEqual(list(self.doc.tags.all()), [public_tag])
self.assertIsNone(self.doc.correspondent)
def test_unparsable_dates_are_skipped(self) -> None:
"""
GIVEN:
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+21 -11
View File
@@ -5,7 +5,7 @@ from django.contrib.auth.models import User
from documents.models import Document
from documents.permissions import permitted_document_ids
from documents.permissions import user_is_unrestricted
from documents.permissions import user_is_active_superuser
from documents.versioning import annotate_effective_content
from paperless.config import AIConfig
from paperless_ai.base_model import ClassificationSuggestions
@@ -50,16 +50,17 @@ def _fulltext_similar_documents(
similarity - cruder, but far better than no candidates at all.
more_like_this_ids returns only a ranked ID list, no scores, so weight is
synthesized from rank (descending from top_k) rather than claiming a
similarity magnitude that doesn't exist. An unrestricted user (none, or an
active superuser - see user_is_unrestricted) is normalized to ``None``
before calling, since the backend's permission filter has no superuser
short-circuit of its own. Results are re-checked with
similarity magnitude that doesn't exist. An active superuser is
normalized to ``None`` (no filtering) before calling, since the backend's
permission filter has no superuser short-circuit of its own. ``user=None``
means the document is unowned, so results are limited to unowned documents
rather than the whole library. Results are re-checked with
permitted_document_ids() since Tantivy's indexed permission fields lag the
DB via async reindexing and judge a version by its own owner.
"""
from documents.search import get_backend
unrestricted = user_is_unrestricted(user)
unrestricted = user_is_active_superuser(user)
search_user = None if unrestricted else user
backend = get_backend()
similar_ids = backend.more_like_this_ids(
@@ -187,13 +188,16 @@ def get_taxonomy_context(
candidates/context rather than propagating the exception - neither a
vector-store outage nor a search-index issue should block classification,
only its context-assisted enrichment.
``user=None`` means "acting for an unowned document" (workflows act as the
document owner), NOT "no restriction": only unowned documents and taxonomy
are considered, so another user's private documents never reach the prompt.
"""
ai_config = AIConfig()
try:
if ai_config.llm_embedding_backend:
# None means "no restriction" to retrieve_similar_nodes. An
# unrestricted user (no user at all, or an active superuser -- see
# user_is_unrestricted) can see every document, so skip
# None means "no restriction" to retrieve_similar_nodes. An active
# superuser can see every document, so skip
# materializing every visible pk into a Python list and passing it
# through as an IN filter: for a large library that is a wasted
# quadratic scan in the vector store at best, and past ~32,763
@@ -204,7 +208,7 @@ def get_taxonomy_context(
# which documents are considered -- only how we get there.
visible_document_ids = (
None
if user_is_unrestricted(user)
if user_is_active_superuser(user)
else list(permitted_document_ids(user))
)
nodes = retrieve_similar_nodes(
@@ -226,8 +230,14 @@ def get_taxonomy_context(
# similar_documents is already ordered by descending weight; don't lose it.
similar_document_ids = [s["document_id"] for s in similar_documents]
similar_queryset = Document.objects.all()
if not user_is_active_superuser(user):
# Already scoped by retrieval but re-checked here before being used in the prompt
similar_queryset = similar_queryset.filter(
id__in=permitted_document_ids(user),
)
similar_documents_by_id = annotate_effective_content(
Document.objects.all(),
similar_queryset,
).in_bulk(similar_document_ids)
similar_docs = [
similar_documents_by_id[document_id]
+2 -2
View File
@@ -12,7 +12,7 @@ from documents.models import DocumentType
from documents.models import StoragePath
from documents.models import Tag
from documents.permissions import permitted_object_ids
from documents.permissions import restrict_queryset_to_visible
from documents.permissions import restrict_queryset_to_owner_visible
MATCH_THRESHOLD = 0.8
@@ -34,7 +34,7 @@ def _resolve_visible_ids(
"""
if not ids:
return []
queryset = restrict_queryset_to_visible(
queryset = restrict_queryset_to_owner_visible(
model.objects.filter(pk__in=ids),
user,
perm,
+2 -2
View File
@@ -13,7 +13,7 @@ from documents.models import Document
from documents.models import DocumentType
from documents.models import StoragePath
from documents.models import Tag
from documents.permissions import restrict_queryset_to_visible
from documents.permissions import restrict_queryset_to_owner_visible
from paperless_ai.prompts.context import TaxonomyBlockPromptContext
from paperless_ai.prompts.render import render_prompt
@@ -97,7 +97,7 @@ def _visible_ranked_candidates(
"""
if not weighted_ids:
return []
visible_queryset = restrict_queryset_to_visible(
visible_queryset = restrict_queryset_to_owner_visible(
model.objects.filter(pk__in=weighted_ids),
user,
perm,
+92 -11
View File
@@ -613,32 +613,83 @@ class TestGetTaxonomyContextVisibility:
@pytest.mark.django_db
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
def test_skips_permission_lookup_when_no_user(
def test_restricts_to_unowned_documents_when_no_user(
self,
mocker: pytest_mock.MockerFixture,
) -> None:
"""
GIVEN:
- No user is supplied
- No user is supplied (a workflow acting for an unowned document)
- Another user's private document
WHEN:
- get_taxonomy_context() is called
THEN:
- Permission lookup is skipped and no document_ids restriction is
passed to retrieve_similar_nodes()
- Only unowned documents are passed to retrieve_similar_nodes(),
never the whole library
"""
document = DocumentFactory.create(content="Some content")
other_owner = UserFactory.create()
document = DocumentFactory.create(content="Some content", owner=None)
unowned = DocumentFactory.create(content="Public", owner=None)
private = DocumentFactory.create(content="Private", owner=other_owner)
mock_retrieve = mocker.patch(
"paperless_ai.ai_classifier.retrieve_similar_nodes",
return_value=[],
)
mock_permitted = mocker.patch(
"paperless_ai.ai_classifier.permitted_document_ids",
)
get_taxonomy_context(document, None)
mock_permitted.assert_not_called()
assert mock_retrieve.call_args.kwargs["document_ids"] is None
visible = mock_retrieve.call_args.kwargs["document_ids"]
assert visible is not None
assert document.pk in visible
assert unowned.pk in visible
assert private.pk not in visible
@pytest.mark.django_db
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
def test_private_document_content_never_reaches_context_when_no_user(
self,
mocker: pytest_mock.MockerFixture,
) -> None:
"""
GIVEN:
- No user is supplied (a workflow acting for an unowned document)
- Retrieval returns another user's private document anyway (e.g.
a stale vector store)
WHEN:
- get_taxonomy_context() is called
THEN:
- The private document's title and content are not in the RAG
context, while an unowned neighbour's are
"""
other_owner = UserFactory.create()
document = DocumentFactory.create(content="Some content", owner=None)
unowned = DocumentFactory.create(
title="Public neighbour",
content="public text",
owner=None,
)
private = DocumentFactory.create(
title="Secret neighbour",
content="secret text",
owner=other_owner,
)
mocker.patch(
"paperless_ai.ai_classifier.retrieve_similar_nodes",
return_value=[],
)
mocker.patch(
"paperless_ai.ai_classifier._node_document_weights",
return_value=[
SimilarDocument(document_id=private.pk, weight=0.9),
SimilarDocument(document_id=unowned.pk, weight=0.5),
],
)
_candidates, context = get_taxonomy_context(document, None)
assert "Public neighbour" in context
assert "Secret neighbour" not in context
assert "secret text" not in context
@pytest.mark.django_db
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
@@ -668,7 +719,9 @@ class TestGetTaxonomyContextVisibility:
get_taxonomy_context(document, user)
mock_permitted.assert_called_once_with(user)
# Once for retrieval, once to re-check the documents whose text goes
# into the prompt
assert {c.args for c in mock_permitted.call_args_list} == {(user,)}
assert mock_retrieve.call_args.kwargs["document_ids"] == [1, 2, 3]
@pytest.mark.django_db
@@ -877,6 +930,34 @@ class TestFulltextSimilarDocuments:
assert [s["document_id"] for s in result] == [other.pk]
def test_no_user_only_returns_unowned_documents(
self,
fulltext_backend: TantivyBackend,
) -> None:
"""
GIVEN:
- An unowned source document, an unowned similar document and a
similar document privately owned by another user
WHEN:
- _fulltext_similar_documents() is called with no user (a workflow
acting for an unowned document)
THEN:
- Only the unowned similar document is returned
"""
other_owner = UserFactory.create()
source = DocumentFactory.create(content="shared content phrase", owner=None)
unowned = DocumentFactory.create(content="shared content phrase", owner=None)
private = DocumentFactory.create(
content="shared content phrase",
owner=other_owner,
)
for doc in (source, unowned, private):
fulltext_backend.add_or_update(doc)
result = _fulltext_similar_documents(source, user=None, top_k=5)
assert [s["document_id"] for s in result] == [unowned.pk]
def test_excludes_stale_permitted_document_for_regular_user(
self,
fulltext_backend: TantivyBackend,
+10 -18
View File
@@ -154,27 +154,19 @@ class TestResolveTagIds:
user = UserFactory.create()
assert resolve_tag_ids([], user) == []
def test_user_none_means_unrestricted_not_owner_isnull(
self,
mocker: pytest_mock.MockerFixture,
) -> None:
"""GIVEN a tag owned by another user and user=None
WHEN resolving the tag's id
THEN the tag is returned unfiltered and permitted_object_ids is never
called - user=None means "no restriction", not the narrower
"only unowned rows" meaning permitted_object_ids(None, ...) has.
Same convention as build_taxonomy_candidates's own call site.
def test_user_none_only_resolves_unowned_tags(self) -> None:
"""GIVEN a tag owned by another user, an unowned tag and user=None
(a workflow acting for an unowned document)
WHEN resolving both tags' ids
THEN only the unowned tag is returned - user=None must not expose
another user's private taxonomy.
"""
tag = TagFactory.create(name="Owned")
owner = UserFactory.create()
tag.owner = owner
tag.save()
spy = mocker.patch("documents.permissions.permitted_object_ids")
owned = TagFactory.create(name="Owned", owner=UserFactory.create())
unowned = TagFactory.create(name="Unowned", owner=None)
result = resolve_tag_ids([tag.pk], None)
result = resolve_tag_ids([owned.pk, unowned.pk], None)
assert result == [tag]
spy.assert_not_called()
assert result == [unowned]
@pytest.mark.django_db
+10 -22
View File
@@ -271,39 +271,27 @@ class TestBuildTaxonomyCandidates:
assert result["tags"] == []
def test_user_none_means_unrestricted_not_owner_isnull(
self,
mocker: pytest_mock.MockerFixture,
) -> None:
def test_user_none_only_includes_unowned_taxonomy(self) -> None:
"""
GIVEN:
- An owned tag (owner is not None)
- user=None (system/superuser/no-auth classification)
- A similar document with a tag owned by another user and an
unowned tag
- user=None (a workflow acting for an unowned document)
WHEN:
- build_taxonomy_candidates() is called
THEN:
- The tag is included (no permission filtering occurs)
- permitted_object_ids() is never called
- Only the unowned tag is a candidate - user=None must not expose
another user's private taxonomy
"""
# user=None means "no restriction" throughout ai_classifier.py (the
# same superuser/no-user fast path get_taxonomy_context uses).
# permitted_object_ids(None, ...) itself means something
# different ("only unowned rows") - it must not be called at all
# when user is None, or an owned tag like this one would be wrongly
# dropped for every unauthenticated/system-triggered classification.
tag = TagFactory.create(name="Owned")
owner = UserFactory.create()
tag.owner = owner
tag.save()
owned = TagFactory.create(name="Owned", owner=UserFactory.create())
unowned = TagFactory.create(name="Unowned", owner=None)
document = DocumentFactory.create()
document.tags.add(tag)
document.tags.add(owned, unowned)
similar_documents = [make_similar(document.pk, 0.5)]
spy = mocker.patch("documents.permissions.permitted_object_ids")
result = build_taxonomy_candidates(similar_documents, user=None)
assert result["tags"][0]["name"] == "Owned"
spy.assert_not_called()
assert [c["name"] for c in result["tags"]] == ["Unowned"]
class TestFormatTaxonomyForPrompt:
Generated
+4 -4
View File
@@ -1272,11 +1272,11 @@ wheels = [
[[package]]
name = "filelock"
version = "3.32.6"
version = "4.0.12"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/38/46/126b1831dca12060d4a8296bf9c4fe5c93c4f22197fa239cb0cc82042bba/filelock-3.32.6.tar.gz", hash = "sha256:a3f55a18af3652a94d8f47d6055df434f254ca1d02ef2524850c6d249ca2512c", size = 225172, upload-time = "2026-09-08T22:57:11.528Z" }
sdist = { url = "https://files.pythonhosted.org/packages/53/e4/34efcb869715cf299e47d1ac7b2624d2bcb6f2d3dffc2f0abe8417f65ab2/filelock-4.0.12.tar.gz", hash = "sha256:cf42711a7ac791818b299fab0332a088c65aeeefa36290de98db92c434303b0c", size = 253880, upload-time = "2026-10-05T13:17:43.677Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/06/4f138f618dbea66803291274f228f01daf29f306fe8b96bc30dab765df75/filelock-3.32.6-py3-none-any.whl", hash = "sha256:3f16ecd0117feae0dfc147e8c62eb5daeccd8bd800378c3ddf416de9b4feb6b1", size = 100189, upload-time = "2026-09-08T22:57:10.182Z" },
{ url = "https://files.pythonhosted.org/packages/da/a1/b047e1c486a2c66283633b7dbb73872ca16ca1ce6a426f70d4d037fc8e1c/filelock-4.0.12-py3-none-any.whl", hash = "sha256:5f17ee83ecee8a6f3e389c75822fb70a1c2f0506b99438a6dffa1d56793588c8", size = 111732, upload-time = "2026-10-05T13:17:42.361Z" },
]
[[package]]
@@ -3184,7 +3184,7 @@ requires-dist = [
{ name = "drf-spectacular", specifier = "~=0.30" },
{ name = "drf-spectacular-sidecar", specifier = ">=2026.7.1,<2026.10" },
{ name = "drf-writable-nested", specifier = "~=0.7.1" },
{ name = "filelock", specifier = "~=3.32.0" },
{ name = "filelock", specifier = ">=3.32,<4.1" },
{ name = "flower", specifier = ">=2.0.1,<2.3" },
{ name = "gotenberg-client", extras = ["httpx"], specifier = "~=1.0" },
{ name = "granian", extras = ["uvloop"], marker = "extra == 'webserver'", specifier = ">=2.7,<2.9" },