mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-17 13:28:12 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b410e9b7b | ||
|
|
0cb2cdbfdd | ||
|
|
1f3d451a3a | ||
|
|
183cfd2da4 | ||
|
|
30a24ee02e | ||
|
|
1943c99b27 | ||
|
|
8b26ecafb6 | ||
|
|
d4a57204eb | ||
|
|
c50c6f5517 |
No files matched your search
+1
-1
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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 <strong>current user only</strong>.</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -34,7 +34,6 @@ from documents.models import DocumentType
|
||||
from documents.models import PaperlessTask
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.permissions import PermissionsSpec
|
||||
from documents.permissions import set_permissions_for_objects
|
||||
from documents.plugins.helpers import DocumentsStatusManager
|
||||
from documents.tasks import bulk_update_documents
|
||||
@@ -446,7 +445,7 @@ def reprocess(doc_ids: list[int], *, remote_ocr: bool = False) -> Literal["OK"]:
|
||||
|
||||
def set_permissions(
|
||||
doc_ids: list[int],
|
||||
set_permissions: PermissionsSpec,
|
||||
set_permissions: dict,
|
||||
*,
|
||||
owner: User | None = None,
|
||||
merge: bool = False,
|
||||
|
||||
@@ -54,6 +54,10 @@ def _predict_with_threshold(classifier, X, threshold: float) -> int | None:
|
||||
cutoff so that uncertain predictions are discarded rather than assigned.
|
||||
"""
|
||||
probas = classifier.predict_proba(X)[0]
|
||||
if len(classifier.classes_) == 1:
|
||||
# scikit-learn treats a single-class fit as binary, so predict_proba
|
||||
# returns [1 - p, p] for one class. Column 0 is the real class.
|
||||
probas = probas[:1]
|
||||
best_idx = int(probas.argmax())
|
||||
best_class = int(classifier.classes_[best_idx])
|
||||
|
||||
|
||||
@@ -38,8 +38,7 @@ from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.parsers import ParseError
|
||||
from documents.permissions import PermissionsSpec
|
||||
from documents.permissions import set_permissions_for_objects
|
||||
from documents.permissions import set_permissions_for_object
|
||||
from documents.plugins.base import AlwaysRunPluginMixin
|
||||
from documents.plugins.base import ConsumeTaskPlugin
|
||||
from documents.plugins.base import NoCleanupPluginMixin
|
||||
@@ -949,7 +948,7 @@ class ConsumerPlugin(
|
||||
or self.metadata.change_users is not None
|
||||
or self.metadata.change_groups is not None
|
||||
):
|
||||
permissions: PermissionsSpec = {
|
||||
permissions = {
|
||||
"view": {
|
||||
"users": self.metadata.view_users or [],
|
||||
"groups": self.metadata.view_groups or [],
|
||||
@@ -959,7 +958,7 @@ class ConsumerPlugin(
|
||||
"groups": self.metadata.change_groups or [],
|
||||
},
|
||||
}
|
||||
set_permissions_for_objects(permissions, Document, [document.pk])
|
||||
set_permissions_for_object(permissions=permissions, object=document)
|
||||
|
||||
if self.metadata.custom_fields:
|
||||
for field in CustomField.objects.filter(
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
+142
-37
@@ -1,10 +1,4 @@
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
from typing import Final
|
||||
from typing import Literal
|
||||
from typing import TypeAlias
|
||||
from typing import TypedDict
|
||||
from typing import TypeVar
|
||||
|
||||
from django.contrib.auth.models import Group
|
||||
@@ -26,29 +20,16 @@ from django.db.models.functions import Coalesce
|
||||
from guardian.core import ObjectPermissionChecker
|
||||
from guardian.models import GroupObjectPermission
|
||||
from guardian.models import UserObjectPermission
|
||||
from guardian.shortcuts import assign_perm
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
from guardian.shortcuts import get_users_with_perms
|
||||
from guardian.shortcuts import remove_perm
|
||||
from rest_framework.permissions import BasePermission
|
||||
from rest_framework.permissions import DjangoObjectPermissions
|
||||
|
||||
from documents.models import Document
|
||||
from documents.versioning import get_root_document
|
||||
|
||||
PermissionAction = Literal["view", "change"]
|
||||
PERMISSION_ACTIONS: Final[tuple[PermissionAction, ...]] = ("view", "change")
|
||||
|
||||
|
||||
class PermissionGrantees(TypedDict, total=False):
|
||||
"""
|
||||
The user and group ids to grant one permission action to. A missing key
|
||||
leaves that kind of grant untouched.
|
||||
"""
|
||||
|
||||
users: Iterable[int]
|
||||
groups: Iterable[int]
|
||||
|
||||
|
||||
PermissionsSpec: TypeAlias = Mapping[PermissionAction, PermissionGrantees]
|
||||
|
||||
|
||||
class PaperlessObjectPermissions(DjangoObjectPermissions):
|
||||
"""
|
||||
@@ -123,10 +104,87 @@ def get_groups_with_only_permission(obj, codename):
|
||||
return Group.objects.filter(id__in=group_object_perm_group_ids).distinct()
|
||||
|
||||
|
||||
def set_permissions_for_object(
|
||||
permissions: dict,
|
||||
object,
|
||||
*,
|
||||
merge: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Set permissions for an object. The permissions are given as a mapping of actions
|
||||
to a dict of user / group id lists, e.g.
|
||||
{"view": {"users": [1], "groups": [2]}, "change": {"users": [], "groups": []}}.
|
||||
|
||||
If merge is True, the permissions are merged with the existing permissions and
|
||||
no users or groups are removed. If False, the permissions are set to exactly
|
||||
the given list of users and groups.
|
||||
"""
|
||||
|
||||
for action, entry in permissions.items():
|
||||
permission = f"{action}_{object.__class__.__name__.lower()}"
|
||||
if "users" in entry:
|
||||
# users
|
||||
users_to_add = User.objects.filter(id__in=entry["users"])
|
||||
users_to_remove = (
|
||||
get_users_with_perms(
|
||||
object,
|
||||
only_with_perms_in=[permission],
|
||||
with_group_users=False,
|
||||
)
|
||||
if not merge
|
||||
else User.objects.none()
|
||||
)
|
||||
if users_to_add.exists() and users_to_remove.exists():
|
||||
users_to_remove = users_to_remove.exclude(id__in=users_to_add)
|
||||
if users_to_remove.exists():
|
||||
for user in users_to_remove:
|
||||
remove_perm(permission, user, object)
|
||||
if users_to_add.exists():
|
||||
for user in users_to_add:
|
||||
assign_perm(permission, user, object)
|
||||
if action == "change":
|
||||
# change gives view too
|
||||
assign_perm(
|
||||
f"view_{object.__class__.__name__.lower()}",
|
||||
user,
|
||||
object,
|
||||
)
|
||||
if "groups" in entry:
|
||||
# groups
|
||||
groups_to_add = Group.objects.filter(id__in=entry["groups"])
|
||||
groups_to_remove = (
|
||||
get_groups_with_only_permission(
|
||||
object,
|
||||
permission,
|
||||
)
|
||||
if not merge
|
||||
else Group.objects.none()
|
||||
)
|
||||
if groups_to_add.exists() and groups_to_remove.exists():
|
||||
groups_to_remove = groups_to_remove.exclude(id__in=groups_to_add)
|
||||
if groups_to_remove.exists():
|
||||
for group in groups_to_remove:
|
||||
remove_perm(permission, group, object)
|
||||
if groups_to_add.exists():
|
||||
for group in groups_to_add:
|
||||
assign_perm(permission, group, object)
|
||||
if action == "change":
|
||||
# change gives view too
|
||||
assign_perm(
|
||||
f"view_{object.__class__.__name__.lower()}",
|
||||
group,
|
||||
object,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_permissions(codenames: set[str], ctype: ContentType) -> list[Permission]:
|
||||
"""
|
||||
Resolve ``codenames`` to Permission rows, raising for any that don't exist
|
||||
so a bad action name fails loudly instead of granting nothing.
|
||||
Resolves `codenames` to Permission rows, raising like the single-object
|
||||
assign_perm() this bulk path replaces does (via a `.get()` internally)
|
||||
if any codename doesn't exist. SetPermissionsSerializer rejects unknown
|
||||
action names at the API, but a caller passing one directly would
|
||||
otherwise get a plain `.filter()` that silently builds zero rows and
|
||||
no-ops instead of reporting the bad input.
|
||||
"""
|
||||
permission_objs = list(
|
||||
Permission.objects.filter(content_type=ctype, codename__in=codenames),
|
||||
@@ -145,13 +203,16 @@ def _apply_bulk_permission_entry(
|
||||
perm_model: type[UserObjectPermission] | type[GroupObjectPermission],
|
||||
identity_model: type[User] | type[Group],
|
||||
identity_field: str,
|
||||
ids: Iterable[int],
|
||||
ids: list[int],
|
||||
codename: str,
|
||||
permission_objs: list[Permission],
|
||||
ctype: ContentType,
|
||||
object_pks: list[str],
|
||||
merge: bool,
|
||||
) -> None:
|
||||
# Only the ids are needed to build permission rows (via `<field>_id=`),
|
||||
# so avoid fetching full User/Group rows for identities that may not
|
||||
# even end up being granted anything new.
|
||||
add_ids = set(
|
||||
identity_model.objects.filter(id__in=ids).values_list("id", flat=True),
|
||||
)
|
||||
@@ -189,26 +250,45 @@ def _apply_bulk_permission_entry(
|
||||
for pk in object_pks
|
||||
for identity_id in add_ids
|
||||
]
|
||||
# ignore_conflicts skips grants that already exist (unique constraint).
|
||||
# ignore_conflicts skips only rows that already exist as an exact
|
||||
# (identity, permission, object) match -- the same de-dup the
|
||||
# underlying (user|group, permission, object_pk) unique constraint
|
||||
# already enforces for the single-object assign_perm() this replaces,
|
||||
# so it doesn't change what counts as "already granted". batch_size
|
||||
# caps how many rows go into a single INSERT statement.
|
||||
perm_model.objects.bulk_create(rows, ignore_conflicts=True, batch_size=1000)
|
||||
|
||||
|
||||
def set_permissions_for_objects(
|
||||
permissions: PermissionsSpec,
|
||||
permissions: dict,
|
||||
model: type[Model],
|
||||
pks: QuerySet | list,
|
||||
*,
|
||||
merge: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Set the view/change grants for every object in ``pks``. With ``merge``,
|
||||
the given users and groups are added; without it, each given action's
|
||||
direct grants are replaced. Granting change also grants view, and an
|
||||
action entry with neither key is left untouched.
|
||||
Bulk equivalent of set_permissions_for_object: applies the same
|
||||
permission changes to every object identified by `pks` at once.
|
||||
|
||||
guardian's bulk assign_perm is avoided because it skips the direct row
|
||||
for anyone who already has access through a group, so revoking the group
|
||||
later would silently remove access that was granted explicitly.
|
||||
Takes a model + pks (rather than model instances) deliberately -- the
|
||||
permission rows built below only ever need `pk`, `content_type`, and
|
||||
identity ids, so callers shouldn't have to fetch full rows (with every
|
||||
other field) just to hand them to this function.
|
||||
|
||||
Deliberately does not use guardian's queryset/list-aware assign_perm:
|
||||
passing a list as the object routes to bulk_assign_perm, which skips
|
||||
creating a direct permission row for anyone who already has the
|
||||
permission via ANY group membership (it checks
|
||||
ObjectPermissionChecker.has_perm, which is group-inheritance-aware) --
|
||||
unlike the single-object assign_perm this replaces, which always
|
||||
ensures a direct row via get_or_create regardless of group-derived
|
||||
access. Losing that guarantee would mean a later revocation of the
|
||||
group's grant silently strips access an admin explicitly asked to be
|
||||
direct. Bulk-creating rows straight against the permission models
|
||||
instead (see _apply_bulk_permission_entry) preserves the original
|
||||
always-create-a-direct-row semantics while still batching every object
|
||||
and every identity into one query per action, rather than one query per
|
||||
(object, user) pair.
|
||||
"""
|
||||
object_pks = [str(pk) for pk in pks]
|
||||
if not object_pks: # pragma: no cover
|
||||
@@ -217,9 +297,12 @@ def set_permissions_for_objects(
|
||||
model_name = model.__name__.lower()
|
||||
ctype = ContentType.objects.get_for_model(model)
|
||||
|
||||
# Resolve every action before writing, so a bad one can't leave the
|
||||
# actions ahead of it applied.
|
||||
permissions_by_action: dict[PermissionAction, list[Permission]] = {}
|
||||
# Every action is resolved up front, before anything is written, so an
|
||||
# unrecognized action name (see _resolve_permissions) aborts the whole
|
||||
# call instead of leaving the actions ahead of it already applied.
|
||||
# SetPermissionsSerializer rejects unknown actions at the API, so this
|
||||
# guards any other caller.
|
||||
permissions_by_action: dict[str, list[Permission]] = {}
|
||||
for action, entry in permissions.items():
|
||||
if "users" not in entry and "groups" not in entry:
|
||||
continue
|
||||
@@ -428,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,
|
||||
*,
|
||||
|
||||
@@ -78,16 +78,12 @@ from documents.models import WorkflowActionEmail
|
||||
from documents.models import WorkflowActionWebhook
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.parsers import is_mime_type_supported
|
||||
from documents.permissions import PERMISSION_ACTIONS
|
||||
from documents.permissions import PermissionAction
|
||||
from documents.permissions import PermissionGrantees
|
||||
from documents.permissions import PermissionsSpec
|
||||
from documents.permissions import get_document_count_filter_for_user
|
||||
from documents.permissions import get_groups_with_only_permission
|
||||
from documents.permissions import has_perms_owner_aware
|
||||
from documents.permissions import permitted_document_ids
|
||||
from documents.permissions import restrict_queryset_to_visible
|
||||
from documents.permissions import set_permissions_for_objects
|
||||
from documents.permissions import set_permissions_for_object
|
||||
from documents.regex import validate_regex_pattern
|
||||
from documents.templating.filepath import validate_filepath_template_and_render
|
||||
from documents.templating.utils import convert_format_str_to_template_format
|
||||
@@ -215,6 +211,9 @@ class MatchingModelSerializer(serializers.ModelSerializer[Any]):
|
||||
return match
|
||||
|
||||
|
||||
PERMISSION_ACTIONS = ("view", "change")
|
||||
|
||||
|
||||
class SetPermissionsMixin:
|
||||
def _validate_user_ids(self, user_ids):
|
||||
users = User.objects.none()
|
||||
@@ -236,10 +235,8 @@ class SetPermissionsMixin:
|
||||
)
|
||||
return groups
|
||||
|
||||
def validate_set_permissions(self, set_permissions=None) -> PermissionsSpec:
|
||||
permissions_dict: dict[PermissionAction, PermissionGrantees] = {
|
||||
action: {} for action in PERMISSION_ACTIONS
|
||||
}
|
||||
def validate_set_permissions(self, set_permissions=None):
|
||||
permissions_dict = {action: {} for action in PERMISSION_ACTIONS}
|
||||
if set_permissions is not None:
|
||||
for action in PERMISSION_ACTIONS:
|
||||
if action in set_permissions:
|
||||
@@ -247,18 +244,18 @@ class SetPermissionsMixin:
|
||||
users = set_permissions[action]["users"]
|
||||
permissions_dict[action]["users"] = self._validate_user_ids(
|
||||
users,
|
||||
).values_list("id", flat=True)
|
||||
)
|
||||
if "groups" in set_permissions[action]:
|
||||
groups = set_permissions[action]["groups"]
|
||||
permissions_dict[action]["groups"] = self._validate_group_ids(
|
||||
groups,
|
||||
).values_list("id", flat=True)
|
||||
)
|
||||
else:
|
||||
del permissions_dict[action]
|
||||
return permissions_dict
|
||||
|
||||
def _set_permissions(self, permissions: PermissionsSpec, object) -> None:
|
||||
set_permissions_for_objects(permissions, type(object), [object.pk])
|
||||
def _set_permissions(self, permissions, object) -> None:
|
||||
set_permissions_for_object(permissions, object)
|
||||
|
||||
|
||||
class SerializerWithPerms(serializers.Serializer[dict[str, Any]]):
|
||||
@@ -3089,7 +3086,7 @@ class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
|
||||
)
|
||||
return objects
|
||||
|
||||
def _validate_permissions(self, permissions) -> PermissionsSpec:
|
||||
def _validate_permissions(self, permissions) -> dict:
|
||||
return self.validate_set_permissions(
|
||||
permissions,
|
||||
)
|
||||
|
||||
@@ -23,7 +23,6 @@ from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.permissions import PermissionsSpec
|
||||
from documents.permissions import set_permissions_for_objects
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
@@ -446,7 +445,7 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
||||
|
||||
grant_object(self.group1, self.doc1, "view_document")
|
||||
|
||||
permissions: PermissionsSpec = {
|
||||
permissions = {
|
||||
"view": {
|
||||
"users": [self.user1.id, self.user2.id],
|
||||
"groups": [self.group2.id],
|
||||
@@ -489,7 +488,7 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
||||
grant_object(self.user1, self.doc1, "view_document")
|
||||
grant_object(self.group1, self.doc1, "view_document")
|
||||
|
||||
permissions: PermissionsSpec = {
|
||||
permissions = {
|
||||
"view": {
|
||||
"users": [self.user2.id],
|
||||
"groups": [self.group2.id],
|
||||
@@ -540,7 +539,7 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
||||
batched call rather than one call per (document, identity)
|
||||
pair
|
||||
"""
|
||||
permissions: PermissionsSpec = {
|
||||
permissions = {
|
||||
"view": {
|
||||
"users": [self.user1.id, self.user2.id],
|
||||
"groups": [self.group2.id],
|
||||
@@ -647,7 +646,7 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
||||
"""
|
||||
with self.assertRaises(Permission.DoesNotExist):
|
||||
set_permissions_for_objects(
|
||||
{"not_a_real_action": {"users": [self.user1.id], "groups": []}}, # type: ignore[bad-assignment]
|
||||
{"not_a_real_action": {"users": [self.user1.id], "groups": []}},
|
||||
Document,
|
||||
[self.doc1.pk],
|
||||
)
|
||||
@@ -675,7 +674,7 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
||||
set_permissions_for_objects(
|
||||
{
|
||||
"view": {"users": [self.user1.id], "groups": []},
|
||||
"not_a_real_action": {"users": [self.user1.id], "groups": []}, # type: ignore[bad-assignment]
|
||||
"not_a_real_action": {"users": [self.user1.id], "groups": []},
|
||||
},
|
||||
Document,
|
||||
[self.doc1.pk],
|
||||
|
||||
@@ -14,6 +14,7 @@ from django.test import override_settings
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from pytest_django.fixtures import Settings
|
||||
from pytest_mock import MockerFixture
|
||||
from sklearn.neural_network import MLPClassifier
|
||||
|
||||
from documents.classifier import ClassifierModelCorruptError
|
||||
from documents.classifier import DocumentClassifier
|
||||
@@ -1036,6 +1037,49 @@ def test_classifier_match_threshold_default() -> None:
|
||||
assert settings.CLASSIFIER_MATCH_THRESHOLD == 0.3
|
||||
|
||||
|
||||
class TestPredictWithThresholdSingleClass:
|
||||
@pytest.fixture
|
||||
def single_class_model(self) -> tuple[MLPClassifier, np.ndarray]:
|
||||
X = np.random.default_rng(0).random((2, 5))
|
||||
return MLPClassifier(tol=0.01, random_state=0).fit(X, [3, 3]), X[:1]
|
||||
|
||||
def test_returns_only_class(
|
||||
self,
|
||||
single_class_model: tuple[MLPClassifier, np.ndarray],
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A classifier fitted on a single class (every document shares one label)
|
||||
WHEN:
|
||||
- A prediction is requested with the threshold disabled
|
||||
THEN:
|
||||
- No error is raised and the only class is returned
|
||||
"""
|
||||
classifier, X = single_class_model
|
||||
|
||||
assert _predict_with_threshold(classifier, X, threshold=0.0) == 3
|
||||
|
||||
def test_applies_threshold(
|
||||
self,
|
||||
single_class_model: tuple[MLPClassifier, np.ndarray],
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A classifier fitted on a single class
|
||||
WHEN:
|
||||
- A prediction is requested
|
||||
THEN:
|
||||
- The class's confidence is still compared against the threshold
|
||||
"""
|
||||
classifier, X = single_class_model
|
||||
confidence = classifier.predict_proba(X)[0][0]
|
||||
|
||||
assert _predict_with_threshold(classifier, X, threshold=confidence - 0.01) == 3
|
||||
assert (
|
||||
_predict_with_threshold(classifier, X, threshold=confidence + 0.01) is None
|
||||
)
|
||||
|
||||
|
||||
class TestPreprocessContent:
|
||||
@pytest.fixture
|
||||
def samples(self) -> Path:
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -7,8 +7,7 @@ from documents.data_models import DocumentMetadataOverrides
|
||||
from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
from documents.models import WorkflowAction
|
||||
from documents.permissions import PermissionsSpec
|
||||
from documents.permissions import set_permissions_for_objects
|
||||
from documents.permissions import set_permissions_for_object
|
||||
from documents.templating.workflows import parse_w_workflow_placeholders
|
||||
|
||||
logger = logging.getLogger("paperless.workflows.mutations")
|
||||
@@ -74,7 +73,7 @@ def apply_assignment_to_document(
|
||||
action.has_assign_change_groups,
|
||||
],
|
||||
):
|
||||
permissions: PermissionsSpec = {
|
||||
permissions = {
|
||||
"view": {
|
||||
"users": action.assign_view_users.values_list("id", flat=True),
|
||||
"groups": action.assign_view_groups.values_list("id", flat=True),
|
||||
@@ -84,10 +83,9 @@ def apply_assignment_to_document(
|
||||
"groups": action.assign_change_groups.values_list("id", flat=True),
|
||||
},
|
||||
}
|
||||
set_permissions_for_objects(
|
||||
permissions,
|
||||
Document,
|
||||
[document.pk],
|
||||
set_permissions_for_object(
|
||||
permissions=permissions,
|
||||
object=document,
|
||||
merge=True,
|
||||
)
|
||||
|
||||
@@ -245,14 +243,13 @@ def apply_removal_to_document(
|
||||
document.owner = None
|
||||
|
||||
if action.remove_all_permissions:
|
||||
permissions: PermissionsSpec = {
|
||||
permissions = {
|
||||
"view": {"users": [], "groups": []},
|
||||
"change": {"users": [], "groups": []},
|
||||
}
|
||||
set_permissions_for_objects(
|
||||
permissions,
|
||||
Document,
|
||||
[document.pk],
|
||||
set_permissions_for_object(
|
||||
permissions=permissions,
|
||||
object=document,
|
||||
merge=False,
|
||||
)
|
||||
|
||||
|
||||
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
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in new issue
Block a user