mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-12-12 01:11:17 -06:00
Compare commits
1 Commits
feature/ne
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e55c5ae98 |
@@ -5,14 +5,14 @@
|
|||||||
<trans-unit id="ngb.alert.close" datatype="html">
|
<trans-unit id="ngb.alert.close" datatype="html">
|
||||||
<source>Close</source>
|
<source>Close</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/alert/alert.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/alert/alert.ts</context>
|
||||||
<context context-type="linenumber">50</context>
|
<context context-type="linenumber">50</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.carousel.slide-number" datatype="html">
|
<trans-unit id="ngb.carousel.slide-number" datatype="html">
|
||||||
<source> Slide <x id="INTERPOLATION" equiv-text="ueryList<NgbSli"/> of <x id="INTERPOLATION_1" equiv-text="EventSource = N"/> </source>
|
<source> Slide <x id="INTERPOLATION" equiv-text="ueryList<NgbSli"/> of <x id="INTERPOLATION_1" equiv-text="EventSource = N"/> </source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/carousel/carousel.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/carousel/carousel.ts</context>
|
||||||
<context context-type="linenumber">131,135</context>
|
<context context-type="linenumber">131,135</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<note priority="1" from="description">Currently selected slide number read by screen reader</note>
|
<note priority="1" from="description">Currently selected slide number read by screen reader</note>
|
||||||
@@ -20,212 +20,212 @@
|
|||||||
<trans-unit id="ngb.carousel.previous" datatype="html">
|
<trans-unit id="ngb.carousel.previous" datatype="html">
|
||||||
<source>Previous</source>
|
<source>Previous</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/carousel/carousel.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/carousel/carousel.ts</context>
|
||||||
<context context-type="linenumber">157,159</context>
|
<context context-type="linenumber">157,159</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.carousel.next" datatype="html">
|
<trans-unit id="ngb.carousel.next" datatype="html">
|
||||||
<source>Next</source>
|
<source>Next</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/carousel/carousel.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/carousel/carousel.ts</context>
|
||||||
<context context-type="linenumber">198</context>
|
<context context-type="linenumber">198</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.datepicker.previous-month" datatype="html">
|
<trans-unit id="ngb.datepicker.previous-month" datatype="html">
|
||||||
<source>Previous month</source>
|
<source>Previous month</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||||
<context context-type="linenumber">83,85</context>
|
<context context-type="linenumber">83,85</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||||
<context context-type="linenumber">112</context>
|
<context context-type="linenumber">112</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.datepicker.next-month" datatype="html">
|
<trans-unit id="ngb.datepicker.next-month" datatype="html">
|
||||||
<source>Next month</source>
|
<source>Next month</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||||
<context context-type="linenumber">112</context>
|
<context context-type="linenumber">112</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||||
<context context-type="linenumber">112</context>
|
<context context-type="linenumber">112</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.HH" datatype="html">
|
<trans-unit id="ngb.timepicker.HH" datatype="html">
|
||||||
<source>HH</source>
|
<source>HH</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/ngb-config.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/ngb-config.ts</context>
|
||||||
<context context-type="linenumber">13</context>
|
<context context-type="linenumber">13</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.toast.close-aria" datatype="html">
|
<trans-unit id="ngb.toast.close-aria" datatype="html">
|
||||||
<source>Close</source>
|
<source>Close</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/ngb-config.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/ngb-config.ts</context>
|
||||||
<context context-type="linenumber">13</context>
|
<context context-type="linenumber">13</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.datepicker.select-month" datatype="html">
|
<trans-unit id="ngb.datepicker.select-month" datatype="html">
|
||||||
<source>Select month</source>
|
<source>Select month</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/ngb-config.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/ngb-config.ts</context>
|
||||||
<context context-type="linenumber">13</context>
|
<context context-type="linenumber">13</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/ngb-config.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/ngb-config.ts</context>
|
||||||
<context context-type="linenumber">13</context>
|
<context context-type="linenumber">13</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.pagination.first" datatype="html">
|
<trans-unit id="ngb.pagination.first" datatype="html">
|
||||||
<source>««</source>
|
<source>««</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/ngb-config.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/ngb-config.ts</context>
|
||||||
<context context-type="linenumber">13</context>
|
<context context-type="linenumber">13</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.hours" datatype="html">
|
<trans-unit id="ngb.timepicker.hours" datatype="html">
|
||||||
<source>Hours</source>
|
<source>Hours</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/ngb-config.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/ngb-config.ts</context>
|
||||||
<context context-type="linenumber">13</context>
|
<context context-type="linenumber">13</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.pagination.previous" datatype="html">
|
<trans-unit id="ngb.pagination.previous" datatype="html">
|
||||||
<source>«</source>
|
<source>«</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/ngb-config.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/ngb-config.ts</context>
|
||||||
<context context-type="linenumber">13</context>
|
<context context-type="linenumber">13</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.MM" datatype="html">
|
<trans-unit id="ngb.timepicker.MM" datatype="html">
|
||||||
<source>MM</source>
|
<source>MM</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/ngb-config.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/ngb-config.ts</context>
|
||||||
<context context-type="linenumber">13</context>
|
<context context-type="linenumber">13</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.pagination.next" datatype="html">
|
<trans-unit id="ngb.pagination.next" datatype="html">
|
||||||
<source>»</source>
|
<source>»</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/ngb-config.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/ngb-config.ts</context>
|
||||||
<context context-type="linenumber">13</context>
|
<context context-type="linenumber">13</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.datepicker.select-year" datatype="html">
|
<trans-unit id="ngb.datepicker.select-year" datatype="html">
|
||||||
<source>Select year</source>
|
<source>Select year</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/ngb-config.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/ngb-config.ts</context>
|
||||||
<context context-type="linenumber">13</context>
|
<context context-type="linenumber">13</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/ngb-config.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/ngb-config.ts</context>
|
||||||
<context context-type="linenumber">13</context>
|
<context context-type="linenumber">13</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.minutes" datatype="html">
|
<trans-unit id="ngb.timepicker.minutes" datatype="html">
|
||||||
<source>Minutes</source>
|
<source>Minutes</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/ngb-config.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/ngb-config.ts</context>
|
||||||
<context context-type="linenumber">13</context>
|
<context context-type="linenumber">13</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.pagination.last" datatype="html">
|
<trans-unit id="ngb.pagination.last" datatype="html">
|
||||||
<source>»»</source>
|
<source>»»</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/ngb-config.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/ngb-config.ts</context>
|
||||||
<context context-type="linenumber">13</context>
|
<context context-type="linenumber">13</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.pagination.first-aria" datatype="html">
|
<trans-unit id="ngb.pagination.first-aria" datatype="html">
|
||||||
<source>First</source>
|
<source>First</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/ngb-config.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/ngb-config.ts</context>
|
||||||
<context context-type="linenumber">13</context>
|
<context context-type="linenumber">13</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.increment-hours" datatype="html">
|
<trans-unit id="ngb.timepicker.increment-hours" datatype="html">
|
||||||
<source>Increment hours</source>
|
<source>Increment hours</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/ngb-config.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/ngb-config.ts</context>
|
||||||
<context context-type="linenumber">13</context>
|
<context context-type="linenumber">13</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.pagination.previous-aria" datatype="html">
|
<trans-unit id="ngb.pagination.previous-aria" datatype="html">
|
||||||
<source>Previous</source>
|
<source>Previous</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/ngb-config.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/ngb-config.ts</context>
|
||||||
<context context-type="linenumber">13</context>
|
<context context-type="linenumber">13</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.decrement-hours" datatype="html">
|
<trans-unit id="ngb.timepicker.decrement-hours" datatype="html">
|
||||||
<source>Decrement hours</source>
|
<source>Decrement hours</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/ngb-config.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/ngb-config.ts</context>
|
||||||
<context context-type="linenumber">13</context>
|
<context context-type="linenumber">13</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.pagination.next-aria" datatype="html">
|
<trans-unit id="ngb.pagination.next-aria" datatype="html">
|
||||||
<source>Next</source>
|
<source>Next</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/ngb-config.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/ngb-config.ts</context>
|
||||||
<context context-type="linenumber">13</context>
|
<context context-type="linenumber">13</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.increment-minutes" datatype="html">
|
<trans-unit id="ngb.timepicker.increment-minutes" datatype="html">
|
||||||
<source>Increment minutes</source>
|
<source>Increment minutes</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/ngb-config.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/ngb-config.ts</context>
|
||||||
<context context-type="linenumber">13</context>
|
<context context-type="linenumber">13</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.pagination.last-aria" datatype="html">
|
<trans-unit id="ngb.pagination.last-aria" datatype="html">
|
||||||
<source>Last</source>
|
<source>Last</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/ngb-config.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/ngb-config.ts</context>
|
||||||
<context context-type="linenumber">13</context>
|
<context context-type="linenumber">13</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.decrement-minutes" datatype="html">
|
<trans-unit id="ngb.timepicker.decrement-minutes" datatype="html">
|
||||||
<source>Decrement minutes</source>
|
<source>Decrement minutes</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/ngb-config.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/ngb-config.ts</context>
|
||||||
<context context-type="linenumber">13</context>
|
<context context-type="linenumber">13</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.SS" datatype="html">
|
<trans-unit id="ngb.timepicker.SS" datatype="html">
|
||||||
<source>SS</source>
|
<source>SS</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/ngb-config.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/ngb-config.ts</context>
|
||||||
<context context-type="linenumber">13</context>
|
<context context-type="linenumber">13</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.seconds" datatype="html">
|
<trans-unit id="ngb.timepicker.seconds" datatype="html">
|
||||||
<source>Seconds</source>
|
<source>Seconds</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/ngb-config.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/ngb-config.ts</context>
|
||||||
<context context-type="linenumber">13</context>
|
<context context-type="linenumber">13</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.increment-seconds" datatype="html">
|
<trans-unit id="ngb.timepicker.increment-seconds" datatype="html">
|
||||||
<source>Increment seconds</source>
|
<source>Increment seconds</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/ngb-config.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/ngb-config.ts</context>
|
||||||
<context context-type="linenumber">13</context>
|
<context context-type="linenumber">13</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.decrement-seconds" datatype="html">
|
<trans-unit id="ngb.timepicker.decrement-seconds" datatype="html">
|
||||||
<source>Decrement seconds</source>
|
<source>Decrement seconds</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/ngb-config.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/ngb-config.ts</context>
|
||||||
<context context-type="linenumber">13</context>
|
<context context-type="linenumber">13</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="ngb.timepicker.PM" datatype="html">
|
<trans-unit id="ngb.timepicker.PM" datatype="html">
|
||||||
<source><x id="INTERPOLATION"/></source>
|
<source><x id="INTERPOLATION"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/ngb-config.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/ngb-config.ts</context>
|
||||||
<context context-type="linenumber">13</context>
|
<context context-type="linenumber">13</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
@@ -233,7 +233,7 @@
|
|||||||
<source><x id="INTERPOLATION" equiv-text="barConfig);
|
<source><x id="INTERPOLATION" equiv-text="barConfig);
|
||||||
pu"/></source>
|
pu"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.15_@angular+core@20.3.15_@angula_40533c760dbaadbd90323f0d78d15fb8/node_modules/src/progressbar/progressbar.ts</context>
|
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.14_@angular+core@20.3.12_@angula_f6978d5a33be250eb7b5e8e65faf7a7d/node_modules/src/progressbar/progressbar.ts</context>
|
||||||
<context context-type="linenumber">41,42</context>
|
<context context-type="linenumber">41,42</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
|||||||
@@ -12,14 +12,14 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^20.2.13",
|
"@angular/cdk": "^20.2.13",
|
||||||
"@angular/common": "~20.3.15",
|
"@angular/common": "~20.3.14",
|
||||||
"@angular/compiler": "~20.3.15",
|
"@angular/compiler": "~20.3.12",
|
||||||
"@angular/core": "~20.3.15",
|
"@angular/core": "~20.3.12",
|
||||||
"@angular/forms": "~20.3.15",
|
"@angular/forms": "~20.3.12",
|
||||||
"@angular/localize": "~20.3.15",
|
"@angular/localize": "~20.3.12",
|
||||||
"@angular/platform-browser": "~20.3.15",
|
"@angular/platform-browser": "~20.3.12",
|
||||||
"@angular/platform-browser-dynamic": "~20.3.15",
|
"@angular/platform-browser-dynamic": "~20.3.12",
|
||||||
"@angular/router": "~20.3.15",
|
"@angular/router": "~20.3.12",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^19.0.1",
|
"@ng-bootstrap/ng-bootstrap": "^19.0.1",
|
||||||
"@ng-select/ng-select": "^20.7.0",
|
"@ng-select/ng-select": "^20.7.0",
|
||||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||||
@@ -42,16 +42,16 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-builders/custom-webpack": "^20.0.0",
|
"@angular-builders/custom-webpack": "^20.0.0",
|
||||||
"@angular-builders/jest": "^20.0.0",
|
"@angular-builders/jest": "^20.0.0",
|
||||||
"@angular-devkit/core": "^20.3.13",
|
"@angular-devkit/core": "^20.3.10",
|
||||||
"@angular-devkit/schematics": "^20.3.13",
|
"@angular-devkit/schematics": "^20.3.10",
|
||||||
"@angular-eslint/builder": "20.6.0",
|
"@angular-eslint/builder": "20.6.0",
|
||||||
"@angular-eslint/eslint-plugin": "20.6.0",
|
"@angular-eslint/eslint-plugin": "20.6.0",
|
||||||
"@angular-eslint/eslint-plugin-template": "20.6.0",
|
"@angular-eslint/eslint-plugin-template": "20.6.0",
|
||||||
"@angular-eslint/schematics": "20.6.0",
|
"@angular-eslint/schematics": "20.6.0",
|
||||||
"@angular-eslint/template-parser": "20.6.0",
|
"@angular-eslint/template-parser": "20.6.0",
|
||||||
"@angular/build": "^20.3.13",
|
"@angular/build": "^20.3.10",
|
||||||
"@angular/cli": "~20.3.13",
|
"@angular/cli": "~20.3.10",
|
||||||
"@angular/compiler-cli": "~20.3.15",
|
"@angular/compiler-cli": "~20.3.12",
|
||||||
"@codecov/webpack-plugin": "^1.9.1",
|
"@codecov/webpack-plugin": "^1.9.1",
|
||||||
"@playwright/test": "^1.57.0",
|
"@playwright/test": "^1.57.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
|
|||||||
579
src-ui/pnpm-lock.yaml
generated
579
src-ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -61,22 +61,21 @@ def get_groups_with_only_permission(obj, codename):
|
|||||||
return Group.objects.filter(id__in=group_object_perm_group_ids).distinct()
|
return Group.objects.filter(id__in=group_object_perm_group_ids).distinct()
|
||||||
|
|
||||||
|
|
||||||
def set_permissions_for_object(permissions: dict, object, *, merge: bool = False):
|
def set_permissions_for_object(permissions: list[str], object, *, merge: bool = False):
|
||||||
"""
|
"""
|
||||||
Set permissions for an object. The permissions are given as a mapping of actions
|
Set permissions for an object. The permissions are given as a list of strings
|
||||||
to a dict of user / group id lists, e.g.
|
in the format "action_modelname", e.g. "view_document".
|
||||||
{"view": {"users": [1], "groups": [2]}, "change": {"users": [], "groups": []}}.
|
|
||||||
|
|
||||||
If merge is True, the permissions are merged with the existing permissions and
|
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
|
no users or groups are removed. If False, the permissions are set to exactly
|
||||||
the given list of users and groups.
|
the given list of users and groups.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
for action, entry in permissions.items():
|
for action in permissions:
|
||||||
permission = f"{action}_{object.__class__.__name__.lower()}"
|
permission = f"{action}_{object.__class__.__name__.lower()}"
|
||||||
if "users" in entry:
|
if "users" in permissions[action]:
|
||||||
# users
|
# users
|
||||||
users_to_add = User.objects.filter(id__in=entry["users"])
|
users_to_add = User.objects.filter(id__in=permissions[action]["users"])
|
||||||
users_to_remove = (
|
users_to_remove = (
|
||||||
get_users_with_perms(
|
get_users_with_perms(
|
||||||
object,
|
object,
|
||||||
@@ -86,12 +85,12 @@ def set_permissions_for_object(permissions: dict, object, *, merge: bool = False
|
|||||||
if not merge
|
if not merge
|
||||||
else User.objects.none()
|
else User.objects.none()
|
||||||
)
|
)
|
||||||
if users_to_add.exists() and users_to_remove.exists():
|
if len(users_to_add) > 0 and len(users_to_remove) > 0:
|
||||||
users_to_remove = users_to_remove.exclude(id__in=users_to_add)
|
users_to_remove = users_to_remove.exclude(id__in=users_to_add)
|
||||||
if users_to_remove.exists():
|
if len(users_to_remove) > 0:
|
||||||
for user in users_to_remove:
|
for user in users_to_remove:
|
||||||
remove_perm(permission, user, object)
|
remove_perm(permission, user, object)
|
||||||
if users_to_add.exists():
|
if len(users_to_add) > 0:
|
||||||
for user in users_to_add:
|
for user in users_to_add:
|
||||||
assign_perm(permission, user, object)
|
assign_perm(permission, user, object)
|
||||||
if action == "change":
|
if action == "change":
|
||||||
@@ -101,9 +100,9 @@ def set_permissions_for_object(permissions: dict, object, *, merge: bool = False
|
|||||||
user,
|
user,
|
||||||
object,
|
object,
|
||||||
)
|
)
|
||||||
if "groups" in entry:
|
if "groups" in permissions[action]:
|
||||||
# groups
|
# groups
|
||||||
groups_to_add = Group.objects.filter(id__in=entry["groups"])
|
groups_to_add = Group.objects.filter(id__in=permissions[action]["groups"])
|
||||||
groups_to_remove = (
|
groups_to_remove = (
|
||||||
get_groups_with_only_permission(
|
get_groups_with_only_permission(
|
||||||
object,
|
object,
|
||||||
@@ -112,12 +111,12 @@ def set_permissions_for_object(permissions: dict, object, *, merge: bool = False
|
|||||||
if not merge
|
if not merge
|
||||||
else Group.objects.none()
|
else Group.objects.none()
|
||||||
)
|
)
|
||||||
if groups_to_add.exists() and groups_to_remove.exists():
|
if len(groups_to_add) > 0 and len(groups_to_remove) > 0:
|
||||||
groups_to_remove = groups_to_remove.exclude(id__in=groups_to_add)
|
groups_to_remove = groups_to_remove.exclude(id__in=groups_to_add)
|
||||||
if groups_to_remove.exists():
|
if len(groups_to_remove) > 0:
|
||||||
for group in groups_to_remove:
|
for group in groups_to_remove:
|
||||||
remove_perm(permission, group, object)
|
remove_perm(permission, group, object)
|
||||||
if groups_to_add.exists():
|
if len(groups_to_add) > 0:
|
||||||
for group in groups_to_add:
|
for group in groups_to_add:
|
||||||
assign_perm(permission, group, object)
|
assign_perm(permission, group, object)
|
||||||
if action == "change":
|
if action == "change":
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ from django.core.validators import MaxLengthValidator
|
|||||||
from django.core.validators import RegexValidator
|
from django.core.validators import RegexValidator
|
||||||
from django.core.validators import integer_validator
|
from django.core.validators import integer_validator
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
from django.db.models.functions import Lower
|
|
||||||
from django.utils.crypto import get_random_string
|
from django.utils.crypto import get_random_string
|
||||||
from django.utils.dateparse import parse_datetime
|
from django.utils.dateparse import parse_datetime
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
@@ -39,7 +38,6 @@ from guardian.utils import get_user_obj_perms_model
|
|||||||
from rest_framework import fields
|
from rest_framework import fields
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.fields import SerializerMethodField
|
from rest_framework.fields import SerializerMethodField
|
||||||
from rest_framework.filters import OrderingFilter
|
|
||||||
|
|
||||||
if settings.AUDIT_LOG_ENABLED:
|
if settings.AUDIT_LOG_ENABLED:
|
||||||
from auditlog.context import set_actor
|
from auditlog.context import set_actor
|
||||||
@@ -577,33 +575,16 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
|
|||||||
)
|
)
|
||||||
def get_children(self, obj):
|
def get_children(self, obj):
|
||||||
filter_q = self.context.get("document_count_filter")
|
filter_q = self.context.get("document_count_filter")
|
||||||
request = self.context.get("request")
|
|
||||||
if filter_q is None:
|
if filter_q is None:
|
||||||
|
request = self.context.get("request")
|
||||||
user = getattr(request, "user", None) if request else None
|
user = getattr(request, "user", None) if request else None
|
||||||
filter_q = get_document_count_filter_for_user(user)
|
filter_q = get_document_count_filter_for_user(user)
|
||||||
self.context["document_count_filter"] = filter_q
|
self.context["document_count_filter"] = filter_q
|
||||||
|
serializer = TagSerializer(
|
||||||
children_queryset = (
|
|
||||||
obj.get_children_queryset()
|
obj.get_children_queryset()
|
||||||
.select_related("owner")
|
.select_related("owner")
|
||||||
.annotate(document_count=Count("documents", filter=filter_q))
|
.annotate(document_count=Count("documents", filter=filter_q)),
|
||||||
)
|
|
||||||
|
|
||||||
view = self.context.get("view")
|
|
||||||
ordering = (
|
|
||||||
OrderingFilter().get_ordering(request, children_queryset, view)
|
|
||||||
if request and view
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
ordering = ordering or (Lower("name"),)
|
|
||||||
children_queryset = children_queryset.order_by(*ordering)
|
|
||||||
|
|
||||||
serializer = TagSerializer(
|
|
||||||
children_queryset,
|
|
||||||
many=True,
|
many=True,
|
||||||
user=self.user,
|
|
||||||
full_perms=self.full_perms,
|
|
||||||
all_fields=self.all_fields,
|
|
||||||
context=self.context,
|
context=self.context,
|
||||||
)
|
)
|
||||||
return serializer.data
|
return serializer.data
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
|
import socket
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
from celery import states
|
from celery import states
|
||||||
from celery.signals import before_task_publish
|
from celery.signals import before_task_publish
|
||||||
@@ -23,15 +27,20 @@ from django.db.models import Q
|
|||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from filelock import FileLock
|
from filelock import FileLock
|
||||||
|
from guardian.shortcuts import remove_perm
|
||||||
|
|
||||||
from documents import matching
|
from documents import matching
|
||||||
from documents.caching import clear_document_caches
|
from documents.caching import clear_document_caches
|
||||||
from documents.file_handling import create_source_path_directory
|
from documents.file_handling import create_source_path_directory
|
||||||
from documents.file_handling import delete_empty_directories
|
from documents.file_handling import delete_empty_directories
|
||||||
from documents.file_handling import generate_unique_filename
|
from documents.file_handling import generate_unique_filename
|
||||||
|
from documents.mail import EmailAttachment
|
||||||
|
from documents.mail import send_email
|
||||||
|
from documents.models import Correspondent
|
||||||
from documents.models import CustomField
|
from documents.models import CustomField
|
||||||
from documents.models import CustomFieldInstance
|
from documents.models import CustomFieldInstance
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
|
from documents.models import DocumentType
|
||||||
from documents.models import MatchingModel
|
from documents.models import MatchingModel
|
||||||
from documents.models import PaperlessTask
|
from documents.models import PaperlessTask
|
||||||
from documents.models import SavedView
|
from documents.models import SavedView
|
||||||
@@ -42,14 +51,8 @@ from documents.models import WorkflowAction
|
|||||||
from documents.models import WorkflowRun
|
from documents.models import WorkflowRun
|
||||||
from documents.models import WorkflowTrigger
|
from documents.models import WorkflowTrigger
|
||||||
from documents.permissions import get_objects_for_user_owner_aware
|
from documents.permissions import get_objects_for_user_owner_aware
|
||||||
from documents.workflows.actions import build_workflow_action_context
|
from documents.permissions import set_permissions_for_object
|
||||||
from documents.workflows.actions import execute_email_action
|
from documents.templating.workflows import parse_w_workflow_placeholders
|
||||||
from documents.workflows.actions import execute_webhook_action
|
|
||||||
from documents.workflows.mutations import apply_assignment_to_document
|
|
||||||
from documents.workflows.mutations import apply_assignment_to_overrides
|
|
||||||
from documents.workflows.mutations import apply_removal_to_document
|
|
||||||
from documents.workflows.mutations import apply_removal_to_overrides
|
|
||||||
from documents.workflows.utils import get_workflows_for_trigger
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from documents.classifier import DocumentClassifier
|
from documents.classifier import DocumentClassifier
|
||||||
@@ -670,6 +673,92 @@ def run_workflows_updated(sender, document: Document, logging_group=None, **kwar
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_public_ip(ip: str) -> bool:
|
||||||
|
try:
|
||||||
|
obj = ipaddress.ip_address(ip)
|
||||||
|
return not (
|
||||||
|
obj.is_private
|
||||||
|
or obj.is_loopback
|
||||||
|
or obj.is_link_local
|
||||||
|
or obj.is_multicast
|
||||||
|
or obj.is_unspecified
|
||||||
|
)
|
||||||
|
except ValueError: # pragma: no cover
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_first_ip(host: str) -> str | None:
|
||||||
|
try:
|
||||||
|
info = socket.getaddrinfo(host, None)
|
||||||
|
return info[0][4][0] if info else None
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(
|
||||||
|
retry_backoff=True,
|
||||||
|
autoretry_for=(httpx.HTTPStatusError,),
|
||||||
|
max_retries=3,
|
||||||
|
throws=(httpx.HTTPError,),
|
||||||
|
)
|
||||||
|
def send_webhook(
|
||||||
|
url: str,
|
||||||
|
data: str | dict,
|
||||||
|
headers: dict,
|
||||||
|
files: dict,
|
||||||
|
*,
|
||||||
|
as_json: bool = False,
|
||||||
|
):
|
||||||
|
p = urlparse(url)
|
||||||
|
if p.scheme.lower() not in settings.WEBHOOKS_ALLOWED_SCHEMES or not p.hostname:
|
||||||
|
logger.warning("Webhook blocked: invalid scheme/hostname")
|
||||||
|
raise ValueError("Invalid URL scheme or hostname.")
|
||||||
|
|
||||||
|
port = p.port or (443 if p.scheme == "https" else 80)
|
||||||
|
if (
|
||||||
|
len(settings.WEBHOOKS_ALLOWED_PORTS) > 0
|
||||||
|
and port not in settings.WEBHOOKS_ALLOWED_PORTS
|
||||||
|
):
|
||||||
|
logger.warning("Webhook blocked: port not permitted")
|
||||||
|
raise ValueError("Destination port not permitted.")
|
||||||
|
|
||||||
|
ip = _resolve_first_ip(p.hostname)
|
||||||
|
if not ip or (
|
||||||
|
not _is_public_ip(ip) and not settings.WEBHOOKS_ALLOW_INTERNAL_REQUESTS
|
||||||
|
):
|
||||||
|
logger.warning("Webhook blocked: destination not allowed")
|
||||||
|
raise ValueError("Destination host is not allowed.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
post_args = {
|
||||||
|
"url": url,
|
||||||
|
"headers": {
|
||||||
|
k: v for k, v in (headers or {}).items() if k.lower() != "host"
|
||||||
|
},
|
||||||
|
"files": files or None,
|
||||||
|
"timeout": 5.0,
|
||||||
|
"follow_redirects": False,
|
||||||
|
}
|
||||||
|
if as_json:
|
||||||
|
post_args["json"] = data
|
||||||
|
elif isinstance(data, dict):
|
||||||
|
post_args["data"] = data
|
||||||
|
else:
|
||||||
|
post_args["content"] = data
|
||||||
|
|
||||||
|
httpx.post(
|
||||||
|
**post_args,
|
||||||
|
).raise_for_status()
|
||||||
|
logger.info(
|
||||||
|
f"Webhook sent to {url}",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Failed attempt sending webhook to {url}: {e}",
|
||||||
|
)
|
||||||
|
raise e
|
||||||
|
|
||||||
|
|
||||||
def run_workflows(
|
def run_workflows(
|
||||||
trigger_type: WorkflowTrigger.WorkflowTriggerType,
|
trigger_type: WorkflowTrigger.WorkflowTriggerType,
|
||||||
document: Document | ConsumableDocument,
|
document: Document | ConsumableDocument,
|
||||||
@@ -678,17 +767,573 @@ def run_workflows(
|
|||||||
overrides: DocumentMetadataOverrides | None = None,
|
overrides: DocumentMetadataOverrides | None = None,
|
||||||
original_file: Path | None = None,
|
original_file: Path | None = None,
|
||||||
) -> tuple[DocumentMetadataOverrides, str] | None:
|
) -> tuple[DocumentMetadataOverrides, str] | None:
|
||||||
"""
|
"""Run workflows which match a Document (or ConsumableDocument) for a specific trigger type or a single workflow if given.
|
||||||
Execute workflows matching a document for the given trigger. When `overrides` is provided
|
|
||||||
(consumption flow), actions mutate that object and the function returns `(overrides, messages)`.
|
|
||||||
Otherwise actions mutate the actual document and return nothing.
|
|
||||||
|
|
||||||
Attachments for email/webhook actions use `original_file` when given, otherwise fall back to
|
Assignment or removal actions are either applied directly to the document or an overrides object. If an overrides
|
||||||
`document.source_path` (Document) or `document.original_file` (ConsumableDocument).
|
object is provided, the function returns the object with the applied changes or None if no actions were applied and a string
|
||||||
|
of messages for each action. If no overrides object is provided, the changes are applied directly to the document and the
|
||||||
Passing `workflow_to_run` skips the workflow query (currently only used by scheduled runs).
|
function returns None.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def assignment_action():
|
||||||
|
if action.assign_tags.exists():
|
||||||
|
tag_ids_to_add: set[int] = set()
|
||||||
|
for tag in action.assign_tags.all():
|
||||||
|
tag_ids_to_add.add(tag.pk)
|
||||||
|
tag_ids_to_add.update(int(pk) for pk in tag.get_ancestors_pks())
|
||||||
|
|
||||||
|
if not use_overrides:
|
||||||
|
doc_tag_ids[:] = list(set(doc_tag_ids) | tag_ids_to_add)
|
||||||
|
else:
|
||||||
|
if overrides.tag_ids is None:
|
||||||
|
overrides.tag_ids = []
|
||||||
|
overrides.tag_ids = list(set(overrides.tag_ids) | tag_ids_to_add)
|
||||||
|
|
||||||
|
if action.assign_correspondent:
|
||||||
|
if not use_overrides:
|
||||||
|
document.correspondent = action.assign_correspondent
|
||||||
|
else:
|
||||||
|
overrides.correspondent_id = action.assign_correspondent.pk
|
||||||
|
|
||||||
|
if action.assign_document_type:
|
||||||
|
if not use_overrides:
|
||||||
|
document.document_type = action.assign_document_type
|
||||||
|
else:
|
||||||
|
overrides.document_type_id = action.assign_document_type.pk
|
||||||
|
|
||||||
|
if action.assign_storage_path:
|
||||||
|
if not use_overrides:
|
||||||
|
document.storage_path = action.assign_storage_path
|
||||||
|
else:
|
||||||
|
overrides.storage_path_id = action.assign_storage_path.pk
|
||||||
|
|
||||||
|
if action.assign_owner:
|
||||||
|
if not use_overrides:
|
||||||
|
document.owner = action.assign_owner
|
||||||
|
else:
|
||||||
|
overrides.owner_id = action.assign_owner.pk
|
||||||
|
|
||||||
|
if action.assign_title:
|
||||||
|
if not use_overrides:
|
||||||
|
try:
|
||||||
|
document.title = parse_w_workflow_placeholders(
|
||||||
|
action.assign_title,
|
||||||
|
document.correspondent.name if document.correspondent else "",
|
||||||
|
document.document_type.name if document.document_type else "",
|
||||||
|
document.owner.username if document.owner else "",
|
||||||
|
timezone.localtime(document.added),
|
||||||
|
document.original_filename or "",
|
||||||
|
document.filename or "",
|
||||||
|
document.created,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
f"Error occurred parsing title assignment '{action.assign_title}', falling back to original",
|
||||||
|
extra={"group": logging_group},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
overrides.title = action.assign_title
|
||||||
|
|
||||||
|
if any(
|
||||||
|
[
|
||||||
|
action.assign_view_users.exists(),
|
||||||
|
action.assign_view_groups.exists(),
|
||||||
|
action.assign_change_users.exists(),
|
||||||
|
action.assign_change_groups.exists(),
|
||||||
|
],
|
||||||
|
):
|
||||||
|
permissions = {
|
||||||
|
"view": {
|
||||||
|
"users": action.assign_view_users.values_list("id", flat=True),
|
||||||
|
"groups": action.assign_view_groups.values_list("id", flat=True),
|
||||||
|
},
|
||||||
|
"change": {
|
||||||
|
"users": action.assign_change_users.values_list("id", flat=True),
|
||||||
|
"groups": action.assign_change_groups.values_list("id", flat=True),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if not use_overrides:
|
||||||
|
set_permissions_for_object(
|
||||||
|
permissions=permissions,
|
||||||
|
object=document,
|
||||||
|
merge=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
overrides.view_users = list(
|
||||||
|
set(
|
||||||
|
(overrides.view_users or [])
|
||||||
|
+ list(permissions["view"]["users"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
overrides.view_groups = list(
|
||||||
|
set(
|
||||||
|
(overrides.view_groups or [])
|
||||||
|
+ list(permissions["view"]["groups"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
overrides.change_users = list(
|
||||||
|
set(
|
||||||
|
(overrides.change_users or [])
|
||||||
|
+ list(permissions["change"]["users"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
overrides.change_groups = list(
|
||||||
|
set(
|
||||||
|
(overrides.change_groups or [])
|
||||||
|
+ list(permissions["change"]["groups"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if action.assign_custom_fields.exists():
|
||||||
|
if not use_overrides:
|
||||||
|
for field in action.assign_custom_fields.all():
|
||||||
|
value_field_name = CustomFieldInstance.get_value_field_name(
|
||||||
|
data_type=field.data_type,
|
||||||
|
)
|
||||||
|
args = {
|
||||||
|
value_field_name: action.assign_custom_fields_values.get(
|
||||||
|
str(field.pk),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
# for some reason update_or_create doesn't work here
|
||||||
|
instance = CustomFieldInstance.objects.filter(
|
||||||
|
field=field,
|
||||||
|
document=document,
|
||||||
|
).first()
|
||||||
|
if instance and args[value_field_name] is not None:
|
||||||
|
setattr(instance, value_field_name, args[value_field_name])
|
||||||
|
instance.save()
|
||||||
|
elif not instance:
|
||||||
|
CustomFieldInstance.objects.create(
|
||||||
|
**args,
|
||||||
|
field=field,
|
||||||
|
document=document,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if overrides.custom_fields is None:
|
||||||
|
overrides.custom_fields = {}
|
||||||
|
overrides.custom_fields.update(
|
||||||
|
{
|
||||||
|
field.pk: action.assign_custom_fields_values.get(
|
||||||
|
str(field.pk),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
for field in action.assign_custom_fields.all()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def removal_action():
|
||||||
|
if action.remove_all_tags:
|
||||||
|
if not use_overrides:
|
||||||
|
doc_tag_ids.clear()
|
||||||
|
else:
|
||||||
|
overrides.tag_ids = None
|
||||||
|
else:
|
||||||
|
tag_ids_to_remove: set[int] = set()
|
||||||
|
for tag in action.remove_tags.all():
|
||||||
|
tag_ids_to_remove.add(tag.pk)
|
||||||
|
tag_ids_to_remove.update(int(pk) for pk in tag.get_descendants_pks())
|
||||||
|
|
||||||
|
if not use_overrides:
|
||||||
|
doc_tag_ids[:] = [t for t in doc_tag_ids if t not in tag_ids_to_remove]
|
||||||
|
elif overrides.tag_ids:
|
||||||
|
overrides.tag_ids = [
|
||||||
|
t for t in overrides.tag_ids if t not in tag_ids_to_remove
|
||||||
|
]
|
||||||
|
|
||||||
|
if not use_overrides and (
|
||||||
|
action.remove_all_correspondents
|
||||||
|
or (
|
||||||
|
document.correspondent
|
||||||
|
and action.remove_correspondents.filter(
|
||||||
|
pk=document.correspondent.pk,
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
):
|
||||||
|
document.correspondent = None
|
||||||
|
elif use_overrides and (
|
||||||
|
action.remove_all_correspondents
|
||||||
|
or (
|
||||||
|
overrides.correspondent_id
|
||||||
|
and action.remove_correspondents.filter(
|
||||||
|
pk=overrides.correspondent_id,
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
):
|
||||||
|
overrides.correspondent_id = None
|
||||||
|
|
||||||
|
if not use_overrides and (
|
||||||
|
action.remove_all_document_types
|
||||||
|
or (
|
||||||
|
document.document_type
|
||||||
|
and action.remove_document_types.filter(
|
||||||
|
pk=document.document_type.pk,
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
):
|
||||||
|
document.document_type = None
|
||||||
|
elif use_overrides and (
|
||||||
|
action.remove_all_document_types
|
||||||
|
or (
|
||||||
|
overrides.document_type_id
|
||||||
|
and action.remove_document_types.filter(
|
||||||
|
pk=overrides.document_type_id,
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
):
|
||||||
|
overrides.document_type_id = None
|
||||||
|
|
||||||
|
if not use_overrides and (
|
||||||
|
action.remove_all_storage_paths
|
||||||
|
or (
|
||||||
|
document.storage_path
|
||||||
|
and action.remove_storage_paths.filter(
|
||||||
|
pk=document.storage_path.pk,
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
):
|
||||||
|
document.storage_path = None
|
||||||
|
elif use_overrides and (
|
||||||
|
action.remove_all_storage_paths
|
||||||
|
or (
|
||||||
|
overrides.storage_path_id
|
||||||
|
and action.remove_storage_paths.filter(
|
||||||
|
pk=overrides.storage_path_id,
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
):
|
||||||
|
overrides.storage_path_id = None
|
||||||
|
|
||||||
|
if not use_overrides and (
|
||||||
|
action.remove_all_owners
|
||||||
|
or (
|
||||||
|
document.owner
|
||||||
|
and action.remove_owners.filter(pk=document.owner.pk).exists()
|
||||||
|
)
|
||||||
|
):
|
||||||
|
document.owner = None
|
||||||
|
elif use_overrides and (
|
||||||
|
action.remove_all_owners
|
||||||
|
or (
|
||||||
|
overrides.owner_id
|
||||||
|
and action.remove_owners.filter(pk=overrides.owner_id).exists()
|
||||||
|
)
|
||||||
|
):
|
||||||
|
overrides.owner_id = None
|
||||||
|
|
||||||
|
if action.remove_all_permissions:
|
||||||
|
if not use_overrides:
|
||||||
|
permissions = {
|
||||||
|
"view": {"users": [], "groups": []},
|
||||||
|
"change": {"users": [], "groups": []},
|
||||||
|
}
|
||||||
|
set_permissions_for_object(
|
||||||
|
permissions=permissions,
|
||||||
|
object=document,
|
||||||
|
merge=False,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
overrides.view_users = None
|
||||||
|
overrides.view_groups = None
|
||||||
|
overrides.change_users = None
|
||||||
|
overrides.change_groups = None
|
||||||
|
elif any(
|
||||||
|
[
|
||||||
|
action.remove_view_users.exists(),
|
||||||
|
action.remove_view_groups.exists(),
|
||||||
|
action.remove_change_users.exists(),
|
||||||
|
action.remove_change_groups.exists(),
|
||||||
|
],
|
||||||
|
):
|
||||||
|
if not use_overrides:
|
||||||
|
for user in action.remove_view_users.all():
|
||||||
|
remove_perm("view_document", user, document)
|
||||||
|
for user in action.remove_change_users.all():
|
||||||
|
remove_perm("change_document", user, document)
|
||||||
|
for group in action.remove_view_groups.all():
|
||||||
|
remove_perm("view_document", group, document)
|
||||||
|
for group in action.remove_change_groups.all():
|
||||||
|
remove_perm("change_document", group, document)
|
||||||
|
else:
|
||||||
|
if overrides.view_users:
|
||||||
|
for user in action.remove_view_users.filter(
|
||||||
|
pk__in=overrides.view_users,
|
||||||
|
):
|
||||||
|
overrides.view_users.remove(user.pk)
|
||||||
|
if overrides.change_users:
|
||||||
|
for user in action.remove_change_users.filter(
|
||||||
|
pk__in=overrides.change_users,
|
||||||
|
):
|
||||||
|
overrides.change_users.remove(user.pk)
|
||||||
|
if overrides.view_groups:
|
||||||
|
for group in action.remove_view_groups.filter(
|
||||||
|
pk__in=overrides.view_groups,
|
||||||
|
):
|
||||||
|
overrides.view_groups.remove(group.pk)
|
||||||
|
if overrides.change_groups:
|
||||||
|
for group in action.remove_change_groups.filter(
|
||||||
|
pk__in=overrides.change_groups,
|
||||||
|
):
|
||||||
|
overrides.change_groups.remove(group.pk)
|
||||||
|
|
||||||
|
if action.remove_all_custom_fields:
|
||||||
|
if not use_overrides:
|
||||||
|
CustomFieldInstance.objects.filter(document=document).hard_delete()
|
||||||
|
else:
|
||||||
|
overrides.custom_fields = None
|
||||||
|
elif action.remove_custom_fields.exists():
|
||||||
|
if not use_overrides:
|
||||||
|
CustomFieldInstance.objects.filter(
|
||||||
|
field__in=action.remove_custom_fields.all(),
|
||||||
|
document=document,
|
||||||
|
).hard_delete()
|
||||||
|
elif overrides.custom_fields:
|
||||||
|
for field in action.remove_custom_fields.filter(
|
||||||
|
pk__in=overrides.custom_fields.keys(),
|
||||||
|
):
|
||||||
|
overrides.custom_fields.pop(field.pk, None)
|
||||||
|
|
||||||
|
def email_action():
|
||||||
|
if not settings.EMAIL_ENABLED:
|
||||||
|
logger.error(
|
||||||
|
"Email backend has not been configured, cannot send email notifications",
|
||||||
|
extra={"group": logging_group},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not use_overrides:
|
||||||
|
title = document.title
|
||||||
|
doc_url = (
|
||||||
|
f"{settings.PAPERLESS_URL}{settings.BASE_URL}documents/{document.pk}/"
|
||||||
|
)
|
||||||
|
correspondent = (
|
||||||
|
document.correspondent.name if document.correspondent else ""
|
||||||
|
)
|
||||||
|
document_type = (
|
||||||
|
document.document_type.name if document.document_type else ""
|
||||||
|
)
|
||||||
|
owner_username = document.owner.username if document.owner else ""
|
||||||
|
filename = document.original_filename or ""
|
||||||
|
current_filename = document.filename or ""
|
||||||
|
added = timezone.localtime(document.added)
|
||||||
|
created = document.created
|
||||||
|
else:
|
||||||
|
title = overrides.title if overrides.title else str(document.original_file)
|
||||||
|
doc_url = ""
|
||||||
|
correspondent = (
|
||||||
|
Correspondent.objects.filter(pk=overrides.correspondent_id).first()
|
||||||
|
if overrides.correspondent_id
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
document_type = (
|
||||||
|
DocumentType.objects.filter(pk=overrides.document_type_id).first().name
|
||||||
|
if overrides.document_type_id
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
owner_username = (
|
||||||
|
User.objects.filter(pk=overrides.owner_id).first().username
|
||||||
|
if overrides.owner_id
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
filename = document.original_file if document.original_file else ""
|
||||||
|
current_filename = filename
|
||||||
|
added = timezone.localtime(timezone.now())
|
||||||
|
created = overrides.created
|
||||||
|
|
||||||
|
subject = (
|
||||||
|
parse_w_workflow_placeholders(
|
||||||
|
action.email.subject,
|
||||||
|
correspondent,
|
||||||
|
document_type,
|
||||||
|
owner_username,
|
||||||
|
added,
|
||||||
|
filename,
|
||||||
|
current_filename,
|
||||||
|
created,
|
||||||
|
title,
|
||||||
|
doc_url,
|
||||||
|
)
|
||||||
|
if action.email.subject
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
body = (
|
||||||
|
parse_w_workflow_placeholders(
|
||||||
|
action.email.body,
|
||||||
|
correspondent,
|
||||||
|
document_type,
|
||||||
|
owner_username,
|
||||||
|
added,
|
||||||
|
filename,
|
||||||
|
current_filename,
|
||||||
|
created,
|
||||||
|
title,
|
||||||
|
doc_url,
|
||||||
|
)
|
||||||
|
if action.email.body
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
attachments: list[EmailAttachment] = []
|
||||||
|
if action.email.include_document:
|
||||||
|
attachment: EmailAttachment | None = None
|
||||||
|
if trigger_type in [
|
||||||
|
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||||
|
WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
||||||
|
] and isinstance(document, Document):
|
||||||
|
friendly_name = (
|
||||||
|
Path(current_filename).name
|
||||||
|
if current_filename
|
||||||
|
else document.source_path.name
|
||||||
|
)
|
||||||
|
attachment = EmailAttachment(
|
||||||
|
path=document.source_path,
|
||||||
|
mime_type=document.mime_type,
|
||||||
|
friendly_name=friendly_name,
|
||||||
|
)
|
||||||
|
elif original_file:
|
||||||
|
friendly_name = (
|
||||||
|
Path(current_filename).name
|
||||||
|
if current_filename
|
||||||
|
else original_file.name
|
||||||
|
)
|
||||||
|
attachment = EmailAttachment(
|
||||||
|
path=original_file,
|
||||||
|
mime_type=document.mime_type,
|
||||||
|
friendly_name=friendly_name,
|
||||||
|
)
|
||||||
|
if attachment:
|
||||||
|
attachments = [attachment]
|
||||||
|
n_messages = send_email(
|
||||||
|
subject=subject,
|
||||||
|
body=body,
|
||||||
|
to=action.email.to.split(","),
|
||||||
|
attachments=attachments,
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"Sent {n_messages} notification email(s) to {action.email.to}",
|
||||||
|
extra={"group": logging_group},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(
|
||||||
|
f"Error occurred sending notification email: {e}",
|
||||||
|
extra={"group": logging_group},
|
||||||
|
)
|
||||||
|
|
||||||
|
def webhook_action():
|
||||||
|
if not use_overrides:
|
||||||
|
title = document.title
|
||||||
|
doc_url = (
|
||||||
|
f"{settings.PAPERLESS_URL}{settings.BASE_URL}documents/{document.pk}/"
|
||||||
|
)
|
||||||
|
correspondent = (
|
||||||
|
document.correspondent.name if document.correspondent else ""
|
||||||
|
)
|
||||||
|
document_type = (
|
||||||
|
document.document_type.name if document.document_type else ""
|
||||||
|
)
|
||||||
|
owner_username = document.owner.username if document.owner else ""
|
||||||
|
filename = document.original_filename or ""
|
||||||
|
current_filename = document.filename or ""
|
||||||
|
added = timezone.localtime(document.added)
|
||||||
|
created = document.created
|
||||||
|
else:
|
||||||
|
title = overrides.title if overrides.title else str(document.original_file)
|
||||||
|
doc_url = ""
|
||||||
|
correspondent = (
|
||||||
|
Correspondent.objects.filter(pk=overrides.correspondent_id).first()
|
||||||
|
if overrides.correspondent_id
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
document_type = (
|
||||||
|
DocumentType.objects.filter(pk=overrides.document_type_id).first().name
|
||||||
|
if overrides.document_type_id
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
owner_username = (
|
||||||
|
User.objects.filter(pk=overrides.owner_id).first().username
|
||||||
|
if overrides.owner_id
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
filename = document.original_file if document.original_file else ""
|
||||||
|
current_filename = filename
|
||||||
|
added = timezone.localtime(timezone.now())
|
||||||
|
created = overrides.created
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = {}
|
||||||
|
if action.webhook.use_params:
|
||||||
|
if action.webhook.params:
|
||||||
|
try:
|
||||||
|
for key, value in action.webhook.params.items():
|
||||||
|
data[key] = parse_w_workflow_placeholders(
|
||||||
|
value,
|
||||||
|
correspondent,
|
||||||
|
document_type,
|
||||||
|
owner_username,
|
||||||
|
added,
|
||||||
|
filename,
|
||||||
|
current_filename,
|
||||||
|
created,
|
||||||
|
title,
|
||||||
|
doc_url,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error occurred parsing webhook params: {e}",
|
||||||
|
extra={"group": logging_group},
|
||||||
|
)
|
||||||
|
elif action.webhook.body:
|
||||||
|
data = parse_w_workflow_placeholders(
|
||||||
|
action.webhook.body,
|
||||||
|
correspondent,
|
||||||
|
document_type,
|
||||||
|
owner_username,
|
||||||
|
added,
|
||||||
|
filename,
|
||||||
|
current_filename,
|
||||||
|
created,
|
||||||
|
title,
|
||||||
|
doc_url,
|
||||||
|
)
|
||||||
|
headers = {}
|
||||||
|
if action.webhook.headers:
|
||||||
|
try:
|
||||||
|
headers = {
|
||||||
|
str(k): str(v) for k, v in action.webhook.headers.items()
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error occurred parsing webhook headers: {e}",
|
||||||
|
extra={"group": logging_group},
|
||||||
|
)
|
||||||
|
files = None
|
||||||
|
if action.webhook.include_document:
|
||||||
|
with original_file.open("rb") as f:
|
||||||
|
files = {
|
||||||
|
"file": (
|
||||||
|
filename,
|
||||||
|
f.read(),
|
||||||
|
document.mime_type,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
send_webhook.delay(
|
||||||
|
url=action.webhook.url,
|
||||||
|
data=data,
|
||||||
|
headers=headers,
|
||||||
|
files=files,
|
||||||
|
as_json=action.webhook.as_json,
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"Webhook to {action.webhook.url} queued",
|
||||||
|
extra={"group": logging_group},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(
|
||||||
|
f"Error occurred sending webhook: {e}",
|
||||||
|
extra={"group": logging_group},
|
||||||
|
)
|
||||||
|
|
||||||
use_overrides = overrides is not None
|
use_overrides = overrides is not None
|
||||||
if original_file is None:
|
if original_file is None:
|
||||||
original_file = (
|
original_file = (
|
||||||
@@ -696,7 +1341,30 @@ def run_workflows(
|
|||||||
)
|
)
|
||||||
messages = []
|
messages = []
|
||||||
|
|
||||||
workflows = get_workflows_for_trigger(trigger_type, workflow_to_run)
|
workflows = (
|
||||||
|
(
|
||||||
|
Workflow.objects.filter(enabled=True, triggers__type=trigger_type)
|
||||||
|
.prefetch_related(
|
||||||
|
"actions",
|
||||||
|
"actions__assign_view_users",
|
||||||
|
"actions__assign_view_groups",
|
||||||
|
"actions__assign_change_users",
|
||||||
|
"actions__assign_change_groups",
|
||||||
|
"actions__assign_custom_fields",
|
||||||
|
"actions__remove_tags",
|
||||||
|
"actions__remove_correspondents",
|
||||||
|
"actions__remove_document_types",
|
||||||
|
"actions__remove_storage_paths",
|
||||||
|
"actions__remove_custom_fields",
|
||||||
|
"actions__remove_owners",
|
||||||
|
"triggers",
|
||||||
|
)
|
||||||
|
.order_by("order")
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
if workflow_to_run is None
|
||||||
|
else [workflow_to_run]
|
||||||
|
)
|
||||||
|
|
||||||
for workflow in workflows:
|
for workflow in workflows:
|
||||||
if not use_overrides:
|
if not use_overrides:
|
||||||
@@ -716,39 +1384,13 @@ def run_workflows(
|
|||||||
messages.append(message)
|
messages.append(message)
|
||||||
|
|
||||||
if action.type == WorkflowAction.WorkflowActionType.ASSIGNMENT:
|
if action.type == WorkflowAction.WorkflowActionType.ASSIGNMENT:
|
||||||
if use_overrides and overrides:
|
assignment_action()
|
||||||
apply_assignment_to_overrides(action, overrides)
|
|
||||||
else:
|
|
||||||
apply_assignment_to_document(
|
|
||||||
action,
|
|
||||||
document,
|
|
||||||
doc_tag_ids,
|
|
||||||
logging_group,
|
|
||||||
)
|
|
||||||
elif action.type == WorkflowAction.WorkflowActionType.REMOVAL:
|
elif action.type == WorkflowAction.WorkflowActionType.REMOVAL:
|
||||||
if use_overrides and overrides:
|
removal_action()
|
||||||
apply_removal_to_overrides(action, overrides)
|
|
||||||
else:
|
|
||||||
apply_removal_to_document(action, document, doc_tag_ids)
|
|
||||||
elif action.type == WorkflowAction.WorkflowActionType.EMAIL:
|
elif action.type == WorkflowAction.WorkflowActionType.EMAIL:
|
||||||
context = build_workflow_action_context(document, overrides)
|
email_action()
|
||||||
execute_email_action(
|
|
||||||
action,
|
|
||||||
document,
|
|
||||||
context,
|
|
||||||
logging_group,
|
|
||||||
original_file,
|
|
||||||
trigger_type,
|
|
||||||
)
|
|
||||||
elif action.type == WorkflowAction.WorkflowActionType.WEBHOOK:
|
elif action.type == WorkflowAction.WorkflowActionType.WEBHOOK:
|
||||||
context = build_workflow_action_context(document, overrides)
|
webhook_action()
|
||||||
execute_webhook_action(
|
|
||||||
action,
|
|
||||||
document,
|
|
||||||
context,
|
|
||||||
logging_group,
|
|
||||||
original_file,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not use_overrides:
|
if not use_overrides:
|
||||||
# limit title to 128 characters
|
# limit title to 128 characters
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ from documents.models import DocumentType
|
|||||||
from documents.models import PaperlessTask
|
from documents.models import PaperlessTask
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
|
from documents.models import Workflow
|
||||||
from documents.models import WorkflowRun
|
from documents.models import WorkflowRun
|
||||||
from documents.models import WorkflowTrigger
|
from documents.models import WorkflowTrigger
|
||||||
from documents.parsers import DocumentParser
|
from documents.parsers import DocumentParser
|
||||||
@@ -53,7 +54,6 @@ from documents.sanity_checker import SanityCheckFailedException
|
|||||||
from documents.signals import document_updated
|
from documents.signals import document_updated
|
||||||
from documents.signals.handlers import cleanup_document_deletion
|
from documents.signals.handlers import cleanup_document_deletion
|
||||||
from documents.signals.handlers import run_workflows
|
from documents.signals.handlers import run_workflows
|
||||||
from documents.workflows.utils import get_workflows_for_trigger
|
|
||||||
|
|
||||||
if settings.AUDIT_LOG_ENABLED:
|
if settings.AUDIT_LOG_ENABLED:
|
||||||
from auditlog.models import LogEntry
|
from auditlog.models import LogEntry
|
||||||
@@ -400,8 +400,13 @@ def check_scheduled_workflows():
|
|||||||
|
|
||||||
Once a document satisfies this condition, and recurring/non-recurring constraints are met, the workflow is run.
|
Once a document satisfies this condition, and recurring/non-recurring constraints are met, the workflow is run.
|
||||||
"""
|
"""
|
||||||
scheduled_workflows = get_workflows_for_trigger(
|
scheduled_workflows: list[Workflow] = (
|
||||||
WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
Workflow.objects.filter(
|
||||||
|
triggers__type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
.prefetch_related("triggers")
|
||||||
)
|
)
|
||||||
if scheduled_workflows.count() > 0:
|
if scheduled_workflows.count() > 0:
|
||||||
logger.debug(f"Checking {len(scheduled_workflows)} scheduled workflows")
|
logger.debug(f"Checking {len(scheduled_workflows)} scheduled workflows")
|
||||||
|
|||||||
@@ -1289,7 +1289,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
content_type__app_label="admin",
|
content_type__app_label="admin",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
set_permissions([4, 5], set_permissions={}, owner=user2, merge=False)
|
set_permissions([4, 5], set_permissions=[], owner=user2, merge=False)
|
||||||
|
|
||||||
with index.open_index_writer() as writer:
|
with index.open_index_writer() as writer:
|
||||||
index.update_document(writer, d1)
|
index.update_document(writer, d1)
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from rest_framework.test import APITestCase
|
|||||||
from documents.file_handling import create_source_path_directory
|
from documents.file_handling import create_source_path_directory
|
||||||
from documents.file_handling import generate_unique_filename
|
from documents.file_handling import generate_unique_filename
|
||||||
from documents.signals.handlers import run_workflows
|
from documents.signals.handlers import run_workflows
|
||||||
from documents.workflows.webhooks import send_webhook
|
from documents.signals.handlers import send_webhook
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
@@ -2858,7 +2858,7 @@ class TestWorkflows(
|
|||||||
|
|
||||||
mock_email_send.return_value = 1
|
mock_email_send.return_value = 1
|
||||||
|
|
||||||
with self.assertNoLogs("paperless.workflows", level="ERROR"):
|
with self.assertNoLogs("paperless.handlers", level="ERROR"):
|
||||||
run_workflows(
|
run_workflows(
|
||||||
WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
consumable_document,
|
consumable_document,
|
||||||
@@ -3096,7 +3096,7 @@ class TestWorkflows(
|
|||||||
original_filename="sample.pdf",
|
original_filename="sample.pdf",
|
||||||
)
|
)
|
||||||
|
|
||||||
with self.assertLogs("paperless.workflows.actions", level="ERROR") as cm:
|
with self.assertLogs("paperless.handlers", level="ERROR") as cm:
|
||||||
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
||||||
|
|
||||||
expected_str = "Email backend has not been configured"
|
expected_str = "Email backend has not been configured"
|
||||||
@@ -3144,7 +3144,7 @@ class TestWorkflows(
|
|||||||
original_filename="sample.pdf",
|
original_filename="sample.pdf",
|
||||||
)
|
)
|
||||||
|
|
||||||
with self.assertLogs("paperless.workflows", level="ERROR") as cm:
|
with self.assertLogs("paperless.handlers", level="ERROR") as cm:
|
||||||
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
||||||
|
|
||||||
expected_str = "Error occurred sending email"
|
expected_str = "Error occurred sending email"
|
||||||
@@ -3215,7 +3215,7 @@ class TestWorkflows(
|
|||||||
PAPERLESS_FORCE_SCRIPT_NAME="/paperless",
|
PAPERLESS_FORCE_SCRIPT_NAME="/paperless",
|
||||||
BASE_URL="/paperless/",
|
BASE_URL="/paperless/",
|
||||||
)
|
)
|
||||||
@mock.patch("documents.workflows.webhooks.send_webhook.delay")
|
@mock.patch("documents.signals.handlers.send_webhook.delay")
|
||||||
def test_workflow_webhook_action_body(self, mock_post):
|
def test_workflow_webhook_action_body(self, mock_post):
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -3274,7 +3274,7 @@ class TestWorkflows(
|
|||||||
@override_settings(
|
@override_settings(
|
||||||
PAPERLESS_URL="http://localhost:8000",
|
PAPERLESS_URL="http://localhost:8000",
|
||||||
)
|
)
|
||||||
@mock.patch("documents.workflows.webhooks.send_webhook.delay")
|
@mock.patch("documents.signals.handlers.send_webhook.delay")
|
||||||
def test_workflow_webhook_action_w_files(self, mock_post):
|
def test_workflow_webhook_action_w_files(self, mock_post):
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -3377,7 +3377,7 @@ class TestWorkflows(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# fails because no file
|
# fails because no file
|
||||||
with self.assertLogs("paperless.workflows", level="ERROR") as cm:
|
with self.assertLogs("paperless.handlers", level="ERROR") as cm:
|
||||||
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
||||||
|
|
||||||
expected_str = "Error occurred sending webhook"
|
expected_str = "Error occurred sending webhook"
|
||||||
@@ -3420,7 +3420,7 @@ class TestWorkflows(
|
|||||||
original_filename="sample.pdf",
|
original_filename="sample.pdf",
|
||||||
)
|
)
|
||||||
|
|
||||||
with self.assertLogs("paperless.workflows", level="ERROR") as cm:
|
with self.assertLogs("paperless.handlers", level="ERROR") as cm:
|
||||||
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
||||||
|
|
||||||
expected_str = "Error occurred parsing webhook params"
|
expected_str = "Error occurred parsing webhook params"
|
||||||
@@ -3436,7 +3436,7 @@ class TestWorkflows(
|
|||||||
raise_for_status=mock.Mock(),
|
raise_for_status=mock.Mock(),
|
||||||
)
|
)
|
||||||
|
|
||||||
with self.assertLogs("paperless.workflows") as cm:
|
with self.assertLogs("paperless.handlers") as cm:
|
||||||
send_webhook(
|
send_webhook(
|
||||||
url="http://paperless-ngx.com",
|
url="http://paperless-ngx.com",
|
||||||
data="Test message",
|
data="Test message",
|
||||||
@@ -3482,7 +3482,7 @@ class TestWorkflows(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
with self.assertLogs("paperless.workflows") as cm:
|
with self.assertLogs("paperless.handlers") as cm:
|
||||||
with self.assertRaises(HTTPStatusError):
|
with self.assertRaises(HTTPStatusError):
|
||||||
send_webhook(
|
send_webhook(
|
||||||
url="http://paperless-ngx.com",
|
url="http://paperless-ngx.com",
|
||||||
@@ -3498,7 +3498,7 @@ class TestWorkflows(
|
|||||||
)
|
)
|
||||||
self.assertIn(expected_str, cm.output[0])
|
self.assertIn(expected_str, cm.output[0])
|
||||||
|
|
||||||
@mock.patch("documents.workflows.webhooks.send_webhook.delay")
|
@mock.patch("documents.signals.handlers.send_webhook.delay")
|
||||||
def test_workflow_webhook_action_consumption(self, mock_post):
|
def test_workflow_webhook_action_consumption(self, mock_post):
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -1,261 +0,0 @@
|
|||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib.auth.models import User
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
from documents.data_models import ConsumableDocument
|
|
||||||
from documents.data_models import DocumentMetadataOverrides
|
|
||||||
from documents.mail import EmailAttachment
|
|
||||||
from documents.mail import send_email
|
|
||||||
from documents.models import Correspondent
|
|
||||||
from documents.models import Document
|
|
||||||
from documents.models import DocumentType
|
|
||||||
from documents.models import WorkflowAction
|
|
||||||
from documents.models import WorkflowTrigger
|
|
||||||
from documents.templating.workflows import parse_w_workflow_placeholders
|
|
||||||
from documents.workflows.webhooks import send_webhook
|
|
||||||
|
|
||||||
logger = logging.getLogger("paperless.workflows.actions")
|
|
||||||
|
|
||||||
|
|
||||||
def build_workflow_action_context(
|
|
||||||
document: Document | ConsumableDocument,
|
|
||||||
overrides: DocumentMetadataOverrides | None,
|
|
||||||
) -> dict:
|
|
||||||
"""
|
|
||||||
Build context dictionary for workflow action placeholder parsing.
|
|
||||||
"""
|
|
||||||
use_overrides = overrides is not None
|
|
||||||
|
|
||||||
if not use_overrides:
|
|
||||||
return {
|
|
||||||
"title": document.title,
|
|
||||||
"doc_url": f"{settings.PAPERLESS_URL}{settings.BASE_URL}documents/{document.pk}/",
|
|
||||||
"correspondent": document.correspondent.name
|
|
||||||
if document.correspondent
|
|
||||||
else "",
|
|
||||||
"document_type": document.document_type.name
|
|
||||||
if document.document_type
|
|
||||||
else "",
|
|
||||||
"owner_username": document.owner.username if document.owner else "",
|
|
||||||
"filename": document.original_filename or "",
|
|
||||||
"current_filename": document.filename or "",
|
|
||||||
"added": timezone.localtime(document.added),
|
|
||||||
"created": document.created,
|
|
||||||
}
|
|
||||||
|
|
||||||
correspondent_obj = (
|
|
||||||
Correspondent.objects.filter(pk=overrides.correspondent_id).first()
|
|
||||||
if overrides and overrides.correspondent_id
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
document_type_obj = (
|
|
||||||
DocumentType.objects.filter(pk=overrides.document_type_id).first()
|
|
||||||
if overrides and overrides.document_type_id
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
owner_obj = (
|
|
||||||
User.objects.filter(pk=overrides.owner_id).first()
|
|
||||||
if overrides and overrides.owner_id
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
filename = document.original_file if document.original_file else ""
|
|
||||||
return {
|
|
||||||
"title": overrides.title
|
|
||||||
if overrides and overrides.title
|
|
||||||
else str(document.original_file),
|
|
||||||
"doc_url": "",
|
|
||||||
"correspondent": correspondent_obj.name if correspondent_obj else "",
|
|
||||||
"document_type": document_type_obj.name if document_type_obj else "",
|
|
||||||
"owner_username": owner_obj.username if owner_obj else "",
|
|
||||||
"filename": filename,
|
|
||||||
"current_filename": filename,
|
|
||||||
"added": timezone.localtime(timezone.now()),
|
|
||||||
"created": overrides.created if overrides else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def execute_email_action(
|
|
||||||
action: WorkflowAction,
|
|
||||||
document: Document | ConsumableDocument,
|
|
||||||
context: dict,
|
|
||||||
logging_group,
|
|
||||||
original_file: Path,
|
|
||||||
trigger_type: WorkflowTrigger.WorkflowTriggerType,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Execute an email action for a workflow.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not settings.EMAIL_ENABLED:
|
|
||||||
logger.error(
|
|
||||||
"Email backend has not been configured, cannot send email notifications",
|
|
||||||
extra={"group": logging_group},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
subject = (
|
|
||||||
parse_w_workflow_placeholders(
|
|
||||||
action.email.subject,
|
|
||||||
context["correspondent"],
|
|
||||||
context["document_type"],
|
|
||||||
context["owner_username"],
|
|
||||||
context["added"],
|
|
||||||
context["filename"],
|
|
||||||
context["current_filename"],
|
|
||||||
context["created"],
|
|
||||||
context["title"],
|
|
||||||
context["doc_url"],
|
|
||||||
)
|
|
||||||
if action.email.subject
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
body = (
|
|
||||||
parse_w_workflow_placeholders(
|
|
||||||
action.email.body,
|
|
||||||
context["correspondent"],
|
|
||||||
context["document_type"],
|
|
||||||
context["owner_username"],
|
|
||||||
context["added"],
|
|
||||||
context["filename"],
|
|
||||||
context["current_filename"],
|
|
||||||
context["created"],
|
|
||||||
context["title"],
|
|
||||||
context["doc_url"],
|
|
||||||
)
|
|
||||||
if action.email.body
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
attachments: list[EmailAttachment] = []
|
|
||||||
if action.email.include_document:
|
|
||||||
attachment: EmailAttachment | None = None
|
|
||||||
if trigger_type in [
|
|
||||||
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
||||||
WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
|
||||||
] and isinstance(document, Document):
|
|
||||||
friendly_name = (
|
|
||||||
Path(context["current_filename"]).name
|
|
||||||
if context["current_filename"]
|
|
||||||
else document.source_path.name
|
|
||||||
)
|
|
||||||
attachment = EmailAttachment(
|
|
||||||
path=document.source_path,
|
|
||||||
mime_type=document.mime_type,
|
|
||||||
friendly_name=friendly_name,
|
|
||||||
)
|
|
||||||
elif original_file:
|
|
||||||
friendly_name = (
|
|
||||||
Path(context["current_filename"]).name
|
|
||||||
if context["current_filename"]
|
|
||||||
else original_file.name
|
|
||||||
)
|
|
||||||
attachment = EmailAttachment(
|
|
||||||
path=original_file,
|
|
||||||
mime_type=document.mime_type,
|
|
||||||
friendly_name=friendly_name,
|
|
||||||
)
|
|
||||||
if attachment:
|
|
||||||
attachments = [attachment]
|
|
||||||
|
|
||||||
n_messages = send_email(
|
|
||||||
subject=subject,
|
|
||||||
body=body,
|
|
||||||
to=action.email.to.split(","),
|
|
||||||
attachments=attachments,
|
|
||||||
)
|
|
||||||
logger.debug(
|
|
||||||
f"Sent {n_messages} notification email(s) to {action.email.to}",
|
|
||||||
extra={"group": logging_group},
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(
|
|
||||||
f"Error occurred sending notification email: {e}",
|
|
||||||
extra={"group": logging_group},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def execute_webhook_action(
|
|
||||||
action: WorkflowAction,
|
|
||||||
document: Document | ConsumableDocument,
|
|
||||||
context: dict,
|
|
||||||
logging_group,
|
|
||||||
original_file: Path,
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
data = {}
|
|
||||||
if action.webhook.use_params:
|
|
||||||
if action.webhook.params:
|
|
||||||
try:
|
|
||||||
for key, value in action.webhook.params.items():
|
|
||||||
data[key] = parse_w_workflow_placeholders(
|
|
||||||
value,
|
|
||||||
context["correspondent"],
|
|
||||||
context["document_type"],
|
|
||||||
context["owner_username"],
|
|
||||||
context["added"],
|
|
||||||
context["filename"],
|
|
||||||
context["current_filename"],
|
|
||||||
context["created"],
|
|
||||||
context["title"],
|
|
||||||
context["doc_url"],
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"Error occurred parsing webhook params: {e}",
|
|
||||||
extra={"group": logging_group},
|
|
||||||
)
|
|
||||||
elif action.webhook.body:
|
|
||||||
data = parse_w_workflow_placeholders(
|
|
||||||
action.webhook.body,
|
|
||||||
context["correspondent"],
|
|
||||||
context["document_type"],
|
|
||||||
context["owner_username"],
|
|
||||||
context["added"],
|
|
||||||
context["filename"],
|
|
||||||
context["current_filename"],
|
|
||||||
context["created"],
|
|
||||||
context["title"],
|
|
||||||
context["doc_url"],
|
|
||||||
)
|
|
||||||
headers = {}
|
|
||||||
if action.webhook.headers:
|
|
||||||
try:
|
|
||||||
headers = {str(k): str(v) for k, v in action.webhook.headers.items()}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"Error occurred parsing webhook headers: {e}",
|
|
||||||
extra={"group": logging_group},
|
|
||||||
)
|
|
||||||
files = None
|
|
||||||
if action.webhook.include_document:
|
|
||||||
with original_file.open("rb") as f:
|
|
||||||
files = {
|
|
||||||
"file": (
|
|
||||||
str(context["filename"])
|
|
||||||
if context["filename"]
|
|
||||||
else original_file.name,
|
|
||||||
f.read(),
|
|
||||||
document.mime_type,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
send_webhook.delay(
|
|
||||||
url=action.webhook.url,
|
|
||||||
data=data,
|
|
||||||
headers=headers,
|
|
||||||
files=files,
|
|
||||||
as_json=action.webhook.as_json,
|
|
||||||
)
|
|
||||||
logger.debug(
|
|
||||||
f"Webhook to {action.webhook.url} queued",
|
|
||||||
extra={"group": logging_group},
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(
|
|
||||||
f"Error occurred sending webhook: {e}",
|
|
||||||
extra={"group": logging_group},
|
|
||||||
)
|
|
||||||
@@ -1,357 +0,0 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
from django.utils import timezone
|
|
||||||
from guardian.shortcuts import remove_perm
|
|
||||||
|
|
||||||
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 set_permissions_for_object
|
|
||||||
from documents.templating.workflows import parse_w_workflow_placeholders
|
|
||||||
|
|
||||||
logger = logging.getLogger("paperless.workflows.mutations")
|
|
||||||
|
|
||||||
|
|
||||||
def apply_assignment_to_document(
|
|
||||||
action: WorkflowAction,
|
|
||||||
document: Document,
|
|
||||||
doc_tag_ids: list[int],
|
|
||||||
logging_group,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Apply assignment actions to a Document instance.
|
|
||||||
|
|
||||||
action: WorkflowAction, annotated with 'has_assign_*' boolean fields
|
|
||||||
"""
|
|
||||||
if action.has_assign_tags:
|
|
||||||
tag_ids_to_add: set[int] = set()
|
|
||||||
for tag in action.assign_tags.all():
|
|
||||||
tag_ids_to_add.add(tag.pk)
|
|
||||||
tag_ids_to_add.update(int(pk) for pk in tag.get_ancestors_pks())
|
|
||||||
|
|
||||||
doc_tag_ids[:] = list(set(doc_tag_ids) | tag_ids_to_add)
|
|
||||||
|
|
||||||
if action.assign_correspondent:
|
|
||||||
document.correspondent = action.assign_correspondent
|
|
||||||
|
|
||||||
if action.assign_document_type:
|
|
||||||
document.document_type = action.assign_document_type
|
|
||||||
|
|
||||||
if action.assign_storage_path:
|
|
||||||
document.storage_path = action.assign_storage_path
|
|
||||||
|
|
||||||
if action.assign_owner:
|
|
||||||
document.owner = action.assign_owner
|
|
||||||
|
|
||||||
if action.assign_title:
|
|
||||||
try:
|
|
||||||
document.title = parse_w_workflow_placeholders(
|
|
||||||
action.assign_title,
|
|
||||||
document.correspondent.name if document.correspondent else "",
|
|
||||||
document.document_type.name if document.document_type else "",
|
|
||||||
document.owner.username if document.owner else "",
|
|
||||||
timezone.localtime(document.added),
|
|
||||||
document.original_filename or "",
|
|
||||||
document.filename or "",
|
|
||||||
document.created,
|
|
||||||
)
|
|
||||||
except Exception: # pragma: no cover
|
|
||||||
logger.exception(
|
|
||||||
f"Error occurred parsing title assignment '{action.assign_title}', falling back to original",
|
|
||||||
extra={"group": logging_group},
|
|
||||||
)
|
|
||||||
|
|
||||||
if any(
|
|
||||||
[
|
|
||||||
action.has_assign_view_users,
|
|
||||||
action.has_assign_view_groups,
|
|
||||||
action.has_assign_change_users,
|
|
||||||
action.has_assign_change_groups,
|
|
||||||
],
|
|
||||||
):
|
|
||||||
permissions = {
|
|
||||||
"view": {
|
|
||||||
"users": action.assign_view_users.values_list("id", flat=True),
|
|
||||||
"groups": action.assign_view_groups.values_list("id", flat=True),
|
|
||||||
},
|
|
||||||
"change": {
|
|
||||||
"users": action.assign_change_users.values_list("id", flat=True),
|
|
||||||
"groups": action.assign_change_groups.values_list("id", flat=True),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
set_permissions_for_object(
|
|
||||||
permissions=permissions,
|
|
||||||
object=document,
|
|
||||||
merge=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if action.has_assign_custom_fields:
|
|
||||||
for field in action.assign_custom_fields.all():
|
|
||||||
value_field_name = CustomFieldInstance.get_value_field_name(
|
|
||||||
data_type=field.data_type,
|
|
||||||
)
|
|
||||||
args = {
|
|
||||||
value_field_name: action.assign_custom_fields_values.get(
|
|
||||||
str(field.pk),
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
# for some reason update_or_create doesn't work here
|
|
||||||
instance = CustomFieldInstance.objects.filter(
|
|
||||||
field=field,
|
|
||||||
document=document,
|
|
||||||
).first()
|
|
||||||
if instance and args[value_field_name] is not None:
|
|
||||||
setattr(instance, value_field_name, args[value_field_name])
|
|
||||||
instance.save()
|
|
||||||
elif not instance:
|
|
||||||
CustomFieldInstance.objects.create(
|
|
||||||
**args,
|
|
||||||
field=field,
|
|
||||||
document=document,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def apply_assignment_to_overrides(
|
|
||||||
action: WorkflowAction,
|
|
||||||
overrides: DocumentMetadataOverrides,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Apply assignment actions to DocumentMetadataOverrides.
|
|
||||||
|
|
||||||
action: WorkflowAction, annotated with 'has_assign_*' boolean fields
|
|
||||||
"""
|
|
||||||
if action.has_assign_tags:
|
|
||||||
if overrides.tag_ids is None:
|
|
||||||
overrides.tag_ids = []
|
|
||||||
tag_ids_to_add: set[int] = set()
|
|
||||||
for tag in action.assign_tags.all():
|
|
||||||
tag_ids_to_add.add(tag.pk)
|
|
||||||
tag_ids_to_add.update(int(pk) for pk in tag.get_ancestors_pks())
|
|
||||||
|
|
||||||
overrides.tag_ids = list(set(overrides.tag_ids) | tag_ids_to_add)
|
|
||||||
|
|
||||||
if action.assign_correspondent:
|
|
||||||
overrides.correspondent_id = action.assign_correspondent.pk
|
|
||||||
|
|
||||||
if action.assign_document_type:
|
|
||||||
overrides.document_type_id = action.assign_document_type.pk
|
|
||||||
|
|
||||||
if action.assign_storage_path:
|
|
||||||
overrides.storage_path_id = action.assign_storage_path.pk
|
|
||||||
|
|
||||||
if action.assign_owner:
|
|
||||||
overrides.owner_id = action.assign_owner.pk
|
|
||||||
|
|
||||||
if action.assign_title:
|
|
||||||
overrides.title = action.assign_title
|
|
||||||
|
|
||||||
if any(
|
|
||||||
[
|
|
||||||
action.has_assign_view_users,
|
|
||||||
action.has_assign_view_groups,
|
|
||||||
action.has_assign_change_users,
|
|
||||||
action.has_assign_change_groups,
|
|
||||||
],
|
|
||||||
):
|
|
||||||
overrides.view_users = list(
|
|
||||||
set(
|
|
||||||
(overrides.view_users or [])
|
|
||||||
+ list(action.assign_view_users.values_list("id", flat=True)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
overrides.view_groups = list(
|
|
||||||
set(
|
|
||||||
(overrides.view_groups or [])
|
|
||||||
+ list(action.assign_view_groups.values_list("id", flat=True)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
overrides.change_users = list(
|
|
||||||
set(
|
|
||||||
(overrides.change_users or [])
|
|
||||||
+ list(action.assign_change_users.values_list("id", flat=True)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
overrides.change_groups = list(
|
|
||||||
set(
|
|
||||||
(overrides.change_groups or [])
|
|
||||||
+ list(action.assign_change_groups.values_list("id", flat=True)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if action.has_assign_custom_fields:
|
|
||||||
if overrides.custom_fields is None:
|
|
||||||
overrides.custom_fields = {}
|
|
||||||
overrides.custom_fields.update(
|
|
||||||
{
|
|
||||||
field.pk: action.assign_custom_fields_values.get(
|
|
||||||
str(field.pk),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
for field in action.assign_custom_fields.all()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def apply_removal_to_document(
|
|
||||||
action: WorkflowAction,
|
|
||||||
document: Document,
|
|
||||||
doc_tag_ids: list[int],
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Apply removal actions to a Document instance.
|
|
||||||
|
|
||||||
action: WorkflowAction, annotated with 'has_remove_*' boolean fields
|
|
||||||
"""
|
|
||||||
|
|
||||||
if action.remove_all_tags:
|
|
||||||
doc_tag_ids.clear()
|
|
||||||
else:
|
|
||||||
tag_ids_to_remove: set[int] = set()
|
|
||||||
for tag in action.remove_tags.all():
|
|
||||||
tag_ids_to_remove.add(tag.pk)
|
|
||||||
tag_ids_to_remove.update(int(pk) for pk in tag.get_descendants_pks())
|
|
||||||
|
|
||||||
doc_tag_ids[:] = [t for t in doc_tag_ids if t not in tag_ids_to_remove]
|
|
||||||
|
|
||||||
if action.remove_all_correspondents or (
|
|
||||||
document.correspondent
|
|
||||||
and action.remove_correspondents.filter(pk=document.correspondent.pk).exists()
|
|
||||||
):
|
|
||||||
document.correspondent = None
|
|
||||||
|
|
||||||
if action.remove_all_document_types or (
|
|
||||||
document.document_type
|
|
||||||
and action.remove_document_types.filter(pk=document.document_type.pk).exists()
|
|
||||||
):
|
|
||||||
document.document_type = None
|
|
||||||
|
|
||||||
if action.remove_all_storage_paths or (
|
|
||||||
document.storage_path
|
|
||||||
and action.remove_storage_paths.filter(pk=document.storage_path.pk).exists()
|
|
||||||
):
|
|
||||||
document.storage_path = None
|
|
||||||
|
|
||||||
if action.remove_all_owners or (
|
|
||||||
document.owner and action.remove_owners.filter(pk=document.owner.pk).exists()
|
|
||||||
):
|
|
||||||
document.owner = None
|
|
||||||
|
|
||||||
if action.remove_all_permissions:
|
|
||||||
permissions = {
|
|
||||||
"view": {"users": [], "groups": []},
|
|
||||||
"change": {"users": [], "groups": []},
|
|
||||||
}
|
|
||||||
set_permissions_for_object(
|
|
||||||
permissions=permissions,
|
|
||||||
object=document,
|
|
||||||
merge=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
if any(
|
|
||||||
[
|
|
||||||
action.has_remove_view_users,
|
|
||||||
action.has_remove_view_groups,
|
|
||||||
action.has_remove_change_users,
|
|
||||||
action.has_remove_change_groups,
|
|
||||||
],
|
|
||||||
):
|
|
||||||
for user in action.remove_view_users.all():
|
|
||||||
remove_perm("view_document", user, document)
|
|
||||||
for user in action.remove_change_users.all():
|
|
||||||
remove_perm("change_document", user, document)
|
|
||||||
for group in action.remove_view_groups.all():
|
|
||||||
remove_perm("view_document", group, document)
|
|
||||||
for group in action.remove_change_groups.all():
|
|
||||||
remove_perm("change_document", group, document)
|
|
||||||
|
|
||||||
if action.remove_all_custom_fields:
|
|
||||||
CustomFieldInstance.objects.filter(document=document).hard_delete()
|
|
||||||
elif action.has_remove_custom_fields:
|
|
||||||
CustomFieldInstance.objects.filter(
|
|
||||||
field__in=action.remove_custom_fields.all(),
|
|
||||||
document=document,
|
|
||||||
).hard_delete()
|
|
||||||
|
|
||||||
|
|
||||||
def apply_removal_to_overrides(
|
|
||||||
action: WorkflowAction,
|
|
||||||
overrides: DocumentMetadataOverrides,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Apply removal actions to DocumentMetadataOverrides.
|
|
||||||
|
|
||||||
action: WorkflowAction, annotated with 'has_remove_*' boolean fields
|
|
||||||
"""
|
|
||||||
if action.remove_all_tags:
|
|
||||||
overrides.tag_ids = None
|
|
||||||
elif overrides.tag_ids:
|
|
||||||
tag_ids_to_remove: set[int] = set()
|
|
||||||
for tag in action.remove_tags.all():
|
|
||||||
tag_ids_to_remove.add(tag.pk)
|
|
||||||
tag_ids_to_remove.update(int(pk) for pk in tag.get_descendants_pks())
|
|
||||||
|
|
||||||
overrides.tag_ids = [t for t in overrides.tag_ids if t not in tag_ids_to_remove]
|
|
||||||
|
|
||||||
if action.remove_all_correspondents or (
|
|
||||||
overrides.correspondent_id
|
|
||||||
and action.remove_correspondents.filter(pk=overrides.correspondent_id).exists()
|
|
||||||
):
|
|
||||||
overrides.correspondent_id = None
|
|
||||||
|
|
||||||
if action.remove_all_document_types or (
|
|
||||||
overrides.document_type_id
|
|
||||||
and action.remove_document_types.filter(pk=overrides.document_type_id).exists()
|
|
||||||
):
|
|
||||||
overrides.document_type_id = None
|
|
||||||
|
|
||||||
if action.remove_all_storage_paths or (
|
|
||||||
overrides.storage_path_id
|
|
||||||
and action.remove_storage_paths.filter(pk=overrides.storage_path_id).exists()
|
|
||||||
):
|
|
||||||
overrides.storage_path_id = None
|
|
||||||
|
|
||||||
if action.remove_all_owners or (
|
|
||||||
overrides.owner_id
|
|
||||||
and action.remove_owners.filter(pk=overrides.owner_id).exists()
|
|
||||||
):
|
|
||||||
overrides.owner_id = None
|
|
||||||
|
|
||||||
if action.remove_all_permissions:
|
|
||||||
overrides.view_users = None
|
|
||||||
overrides.view_groups = None
|
|
||||||
overrides.change_users = None
|
|
||||||
overrides.change_groups = None
|
|
||||||
elif any(
|
|
||||||
[
|
|
||||||
action.has_remove_view_users,
|
|
||||||
action.has_remove_view_groups,
|
|
||||||
action.has_remove_change_users,
|
|
||||||
action.has_remove_change_groups,
|
|
||||||
],
|
|
||||||
):
|
|
||||||
if overrides.view_users:
|
|
||||||
for user in action.remove_view_users.filter(pk__in=overrides.view_users):
|
|
||||||
overrides.view_users.remove(user.pk)
|
|
||||||
if overrides.change_users:
|
|
||||||
for user in action.remove_change_users.filter(
|
|
||||||
pk__in=overrides.change_users,
|
|
||||||
):
|
|
||||||
overrides.change_users.remove(user.pk)
|
|
||||||
if overrides.view_groups:
|
|
||||||
for group in action.remove_view_groups.filter(pk__in=overrides.view_groups):
|
|
||||||
overrides.view_groups.remove(group.pk)
|
|
||||||
if overrides.change_groups:
|
|
||||||
for group in action.remove_change_groups.filter(
|
|
||||||
pk__in=overrides.change_groups,
|
|
||||||
):
|
|
||||||
overrides.change_groups.remove(group.pk)
|
|
||||||
|
|
||||||
if action.remove_all_custom_fields:
|
|
||||||
overrides.custom_fields = None
|
|
||||||
elif action.has_remove_custom_fields and overrides.custom_fields:
|
|
||||||
for field in action.remove_custom_fields.filter(
|
|
||||||
pk__in=overrides.custom_fields.keys(),
|
|
||||||
):
|
|
||||||
overrides.custom_fields.pop(field.pk, None)
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
from django.db.models import Exists
|
|
||||||
from django.db.models import OuterRef
|
|
||||||
from django.db.models import Prefetch
|
|
||||||
|
|
||||||
from documents.models import Workflow
|
|
||||||
from documents.models import WorkflowAction
|
|
||||||
from documents.models import WorkflowTrigger
|
|
||||||
|
|
||||||
logger = logging.getLogger("paperless.workflows")
|
|
||||||
|
|
||||||
|
|
||||||
def get_workflows_for_trigger(
|
|
||||||
trigger_type: WorkflowTrigger.WorkflowTriggerType,
|
|
||||||
workflow_to_run: Workflow | None = None,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Return workflows relevant to a trigger. If a specific workflow is given,
|
|
||||||
wrap it in a list; otherwise fetch enabled workflows for the trigger with
|
|
||||||
the prefetches used by the runner.
|
|
||||||
"""
|
|
||||||
if workflow_to_run is not None:
|
|
||||||
return [workflow_to_run]
|
|
||||||
|
|
||||||
annotated_actions = (
|
|
||||||
WorkflowAction.objects.select_related(
|
|
||||||
"assign_correspondent",
|
|
||||||
"assign_document_type",
|
|
||||||
"assign_storage_path",
|
|
||||||
"assign_owner",
|
|
||||||
"email",
|
|
||||||
"webhook",
|
|
||||||
)
|
|
||||||
.prefetch_related(
|
|
||||||
"assign_tags",
|
|
||||||
"assign_view_users",
|
|
||||||
"assign_view_groups",
|
|
||||||
"assign_change_users",
|
|
||||||
"assign_change_groups",
|
|
||||||
"assign_custom_fields",
|
|
||||||
"remove_tags",
|
|
||||||
"remove_correspondents",
|
|
||||||
"remove_document_types",
|
|
||||||
"remove_storage_paths",
|
|
||||||
"remove_custom_fields",
|
|
||||||
"remove_owners",
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
has_assign_tags=Exists(
|
|
||||||
WorkflowAction.assign_tags.through.objects.filter(
|
|
||||||
workflowaction_id=OuterRef("pk"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
has_assign_view_users=Exists(
|
|
||||||
WorkflowAction.assign_view_users.through.objects.filter(
|
|
||||||
workflowaction_id=OuterRef("pk"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
has_assign_view_groups=Exists(
|
|
||||||
WorkflowAction.assign_view_groups.through.objects.filter(
|
|
||||||
workflowaction_id=OuterRef("pk"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
has_assign_change_users=Exists(
|
|
||||||
WorkflowAction.assign_change_users.through.objects.filter(
|
|
||||||
workflowaction_id=OuterRef("pk"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
has_assign_change_groups=Exists(
|
|
||||||
WorkflowAction.assign_change_groups.through.objects.filter(
|
|
||||||
workflowaction_id=OuterRef("pk"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
has_assign_custom_fields=Exists(
|
|
||||||
WorkflowAction.assign_custom_fields.through.objects.filter(
|
|
||||||
workflowaction_id=OuterRef("pk"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
has_remove_view_users=Exists(
|
|
||||||
WorkflowAction.remove_view_users.through.objects.filter(
|
|
||||||
workflowaction_id=OuterRef("pk"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
has_remove_view_groups=Exists(
|
|
||||||
WorkflowAction.remove_view_groups.through.objects.filter(
|
|
||||||
workflowaction_id=OuterRef("pk"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
has_remove_change_users=Exists(
|
|
||||||
WorkflowAction.remove_change_users.through.objects.filter(
|
|
||||||
workflowaction_id=OuterRef("pk"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
has_remove_change_groups=Exists(
|
|
||||||
WorkflowAction.remove_change_groups.through.objects.filter(
|
|
||||||
workflowaction_id=OuterRef("pk"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
has_remove_custom_fields=Exists(
|
|
||||||
WorkflowAction.remove_custom_fields.through.objects.filter(
|
|
||||||
workflowaction_id=OuterRef("pk"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
Workflow.objects.filter(enabled=True, triggers__type=trigger_type)
|
|
||||||
.prefetch_related(
|
|
||||||
Prefetch("actions", queryset=annotated_actions),
|
|
||||||
"triggers",
|
|
||||||
)
|
|
||||||
.order_by("order")
|
|
||||||
.distinct()
|
|
||||||
)
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import ipaddress
|
|
||||||
import logging
|
|
||||||
import socket
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from celery import shared_task
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
logger = logging.getLogger("paperless.workflows.webhooks")
|
|
||||||
|
|
||||||
|
|
||||||
def _is_public_ip(ip: str) -> bool:
|
|
||||||
try:
|
|
||||||
obj = ipaddress.ip_address(ip)
|
|
||||||
return not (
|
|
||||||
obj.is_private
|
|
||||||
or obj.is_loopback
|
|
||||||
or obj.is_link_local
|
|
||||||
or obj.is_multicast
|
|
||||||
or obj.is_unspecified
|
|
||||||
)
|
|
||||||
except ValueError: # pragma: no cover
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_first_ip(host: str) -> str | None:
|
|
||||||
try:
|
|
||||||
info = socket.getaddrinfo(host, None)
|
|
||||||
return info[0][4][0] if info else None
|
|
||||||
except Exception: # pragma: no cover
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task(
|
|
||||||
retry_backoff=True,
|
|
||||||
autoretry_for=(httpx.HTTPStatusError,),
|
|
||||||
max_retries=3,
|
|
||||||
throws=(httpx.HTTPError,),
|
|
||||||
)
|
|
||||||
def send_webhook(
|
|
||||||
url: str,
|
|
||||||
data: str | dict,
|
|
||||||
headers: dict,
|
|
||||||
files: dict,
|
|
||||||
*,
|
|
||||||
as_json: bool = False,
|
|
||||||
):
|
|
||||||
p = urlparse(url)
|
|
||||||
if p.scheme.lower() not in settings.WEBHOOKS_ALLOWED_SCHEMES or not p.hostname:
|
|
||||||
logger.warning("Webhook blocked: invalid scheme/hostname")
|
|
||||||
raise ValueError("Invalid URL scheme or hostname.")
|
|
||||||
|
|
||||||
port = p.port or (443 if p.scheme == "https" else 80)
|
|
||||||
if (
|
|
||||||
len(settings.WEBHOOKS_ALLOWED_PORTS) > 0
|
|
||||||
and port not in settings.WEBHOOKS_ALLOWED_PORTS
|
|
||||||
):
|
|
||||||
logger.warning("Webhook blocked: port not permitted")
|
|
||||||
raise ValueError("Destination port not permitted.")
|
|
||||||
|
|
||||||
ip = _resolve_first_ip(p.hostname)
|
|
||||||
if not ip or (
|
|
||||||
not _is_public_ip(ip) and not settings.WEBHOOKS_ALLOW_INTERNAL_REQUESTS
|
|
||||||
):
|
|
||||||
logger.warning("Webhook blocked: destination not allowed")
|
|
||||||
raise ValueError("Destination host is not allowed.")
|
|
||||||
|
|
||||||
try:
|
|
||||||
post_args = {
|
|
||||||
"url": url,
|
|
||||||
"headers": {
|
|
||||||
k: v for k, v in (headers or {}).items() if k.lower() != "host"
|
|
||||||
},
|
|
||||||
"files": files or None,
|
|
||||||
"timeout": 5.0,
|
|
||||||
"follow_redirects": False,
|
|
||||||
}
|
|
||||||
if as_json:
|
|
||||||
post_args["json"] = data
|
|
||||||
elif isinstance(data, dict):
|
|
||||||
post_args["data"] = data
|
|
||||||
else:
|
|
||||||
post_args["content"] = data
|
|
||||||
|
|
||||||
httpx.post(
|
|
||||||
**post_args,
|
|
||||||
).raise_for_status()
|
|
||||||
logger.info(
|
|
||||||
f"Webhook sent to {url}",
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"Failed attempt sending webhook to {url}: {e}",
|
|
||||||
)
|
|
||||||
raise e
|
|
||||||
@@ -2,7 +2,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2025-12-10 16:39+0000\n"
|
"POT-Creation-Date: 2025-11-14 16:09+0000\n"
|
||||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: English\n"
|
"Language-Team: English\n"
|
||||||
@@ -1224,35 +1224,35 @@ msgstr ""
|
|||||||
msgid "Invalid regular expression: %(error)s"
|
msgid "Invalid regular expression: %(error)s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:622
|
#: documents/serialisers.py:619
|
||||||
msgid "Invalid color."
|
msgid "Invalid color."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:1808
|
#: documents/serialisers.py:1805
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "File type %(type)s not supported"
|
msgid "File type %(type)s not supported"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:1852
|
#: documents/serialisers.py:1849
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Custom field id must be an integer: %(id)s"
|
msgid "Custom field id must be an integer: %(id)s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:1859
|
#: documents/serialisers.py:1856
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Custom field with id %(id)s does not exist"
|
msgid "Custom field with id %(id)s does not exist"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:1876 documents/serialisers.py:1886
|
#: documents/serialisers.py:1873 documents/serialisers.py:1883
|
||||||
msgid ""
|
msgid ""
|
||||||
"Custom fields must be a list of integers or an object mapping ids to values."
|
"Custom fields must be a list of integers or an object mapping ids to values."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:1881
|
#: documents/serialisers.py:1878
|
||||||
msgid "Some custom fields don't exist or were specified twice."
|
msgid "Some custom fields don't exist or were specified twice."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:1996
|
#: documents/serialisers.py:1993
|
||||||
msgid "Invalid variable detected."
|
msgid "Invalid variable detected."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ ALLOWED_SVG_TAGS: set[str] = {
|
|||||||
"text",
|
"text",
|
||||||
"tspan",
|
"tspan",
|
||||||
"defs",
|
"defs",
|
||||||
"lineargradient",
|
"linearGradient",
|
||||||
"radialgradient",
|
"radialGradient",
|
||||||
"stop",
|
"stop",
|
||||||
"clippath",
|
"clipPath",
|
||||||
"use",
|
"use",
|
||||||
"title",
|
"title",
|
||||||
"desc",
|
"desc",
|
||||||
@@ -52,14 +52,14 @@ ALLOWED_SVG_ATTRIBUTES: set[str] = {
|
|||||||
"y1",
|
"y1",
|
||||||
"x2",
|
"x2",
|
||||||
"y2",
|
"y2",
|
||||||
"gradienttransform",
|
"gradientTransform",
|
||||||
"gradientunits",
|
"gradientUnits",
|
||||||
"offset",
|
"offset",
|
||||||
"stop-color",
|
"stop-color",
|
||||||
"stop-opacity",
|
"stop-opacity",
|
||||||
"clip-path",
|
"clip-path",
|
||||||
"viewbox",
|
"viewBox",
|
||||||
"preserveaspectratio",
|
"preserveAspectRatio",
|
||||||
"href",
|
"href",
|
||||||
"xlink:href",
|
"xlink:href",
|
||||||
"font-family",
|
"font-family",
|
||||||
@@ -68,7 +68,6 @@ ALLOWED_SVG_ATTRIBUTES: set[str] = {
|
|||||||
"text-anchor",
|
"text-anchor",
|
||||||
"xmlns",
|
"xmlns",
|
||||||
"xmlns:xlink",
|
"xmlns:xlink",
|
||||||
"version",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
52
uv.lock
generated
52
uv.lock
generated
@@ -331,15 +331,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "channels"
|
name = "channels"
|
||||||
version = "4.3.1"
|
version = "4.3.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "asgiref", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "asgiref", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
{ name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/12/a0/46450fcf9e56af18a6b0440ba49db6635419bb7bc84142c35f4143b1a66c/channels-4.3.1.tar.gz", hash = "sha256:97413ffd674542db08e16a9ef09cd86ec0113e5f8125fbd33cf0854adcf27cdb", size = 26896, upload-time = "2025-08-01T13:25:19.952Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/74/92/b18d4bb54d14986a8b35215a1c9e6a7f9f4d57ca63ac9aee8290ebb4957d/channels-4.3.2.tar.gz", hash = "sha256:f2bb6bfb73ad7fb4705041d07613c7b4e69528f01ef8cb9fb6c21d9295f15667", size = 27023, upload-time = "2025-11-20T15:13:05.102Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/1c/eae1c2a8c195760376e7f65d0bdcc3e966695d29cfbe5c54841ce5c71408/channels-4.3.1-py3-none-any.whl", hash = "sha256:b091d4b26f91d807de3e84aead7ba785314f27eaf5bac31dd51b1c956b883859", size = 31286, upload-time = "2025-08-01T13:25:18.845Z" },
|
{ url = "https://files.pythonhosted.org/packages/16/34/c32915288b7ef482377b6adc401192f98c6a99b3a145423d3b8aed807898/channels-4.3.2-py3-none-any.whl", hash = "sha256:fef47e9055a603900cf16cef85f050d522d9ac4b3daccf24835bd9580705c176", size = 31313, upload-time = "2025-11-20T15:13:02.357Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -839,14 +839,14 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-soft-delete"
|
name = "django-soft-delete"
|
||||||
version = "1.0.21"
|
version = "1.0.22"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/da/bf/13996c18bffee3bbcf294830c1737bfb5564164b8319c51e6714b6bdf783/django_soft_delete-1.0.21.tar.gz", hash = "sha256:542bd4650d2769105a4363ea7bb7fbdb3c28429dbaa66417160f8f4b5dc689d5", size = 21153, upload-time = "2025-09-17T08:46:30.476Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/98/d1/c990b731676f93bd4594dee4b5133df52f5d0eee1eb8a969b4030014ac54/django_soft_delete-1.0.22.tar.gz", hash = "sha256:32d0bb95f180c28a40163e78a558acc18901fd56011f91f8ee735c171a6d4244", size = 21982, upload-time = "2025-10-25T13:11:46.199Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/e6/8f4fed14499c63e35ca33cf9f424ad2e14e963ec5545594d7c7dc2f710f4/django_soft_delete-1.0.21-py3-none-any.whl", hash = "sha256:dd91e671d9d431ff96f4db727ce03e7fbb4008ae4541b1d162d5d06cc9becd2a", size = 18681, upload-time = "2025-09-17T08:46:29.272Z" },
|
{ url = "https://files.pythonhosted.org/packages/f5/c2/fca2bf69b7ca7e18aed9ac059e89f1043663e207a514e8fb652450e49631/django_soft_delete-1.0.22-py3-none-any.whl", hash = "sha256:81973c541d21452d249151085d617ebbfb5ec463899f47cd6b1306677481e94c", size = 19221, upload-time = "2025-10-25T13:11:44.755Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -885,11 +885,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-treenode"
|
name = "django-treenode"
|
||||||
version = "0.23.2"
|
version = "0.23.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/75/f3/274b84607fd64c0844e98659985f964190a46c2460f2523a446c4a946216/django_treenode-0.23.2.tar.gz", hash = "sha256:3c5a6ff5e0c83e34da88749f602b3013dd1ab0527f51952c616e3c21bf265d52", size = 26700, upload-time = "2025-09-04T21:16:53.497Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/25/58/86edbbd1075bb8bc0962c6feb13bc06822405a10fea8352ad73ab2babdd9/django_treenode-0.23.3.tar.gz", hash = "sha256:714c825d5b925a3d2848d0709f29973941ea41a606b8e2b64cbec46010a8cce3", size = 27812, upload-time = "2025-12-01T23:01:24.847Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/61/e17d3dee5c6bb24b8faf0c101e17f9a8cafeba6384166176e066c80e8cbb/django_treenode-0.23.2-py3-none-any.whl", hash = "sha256:9363cb50f753654a9acfad6ec4df2a664a5f89dfdf8b55ffd964f27461bef85e", size = 21879, upload-time = "2025-09-04T21:16:51.811Z" },
|
{ url = "https://files.pythonhosted.org/packages/bc/52/696db237167483324ef38d8d090fb0fcc33dbb70ebe66c75868005fb7c75/django_treenode-0.23.3-py3-none-any.whl", hash = "sha256:8072e1ac688c1ed3ab95a98a797c5e965380de5228a389d60a4ef8b9a6449387", size = 22014, upload-time = "2025-12-01T23:01:23.266Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3502,25 +3502,25 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.14.5"
|
version = "0.14.8"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/82/fa/fbb67a5780ae0f704876cb8ac92d6d76da41da4dc72b7ed3565ab18f2f52/ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1", size = 5615944, upload-time = "2025-11-13T19:58:51.155Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/ed/d9/f7a0c4b3a2bf2556cd5d99b05372c29980249ef71e8e32669ba77428c82c/ruff-0.14.8.tar.gz", hash = "sha256:774ed0dd87d6ce925e3b8496feb3a00ac564bea52b9feb551ecd17e0a23d1eed", size = 5765385, upload-time = "2025-12-04T15:06:17.669Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/68/31/c07e9c535248d10836a94e4f4e8c5a31a1beed6f169b31405b227872d4f4/ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594", size = 13171630, upload-time = "2025-11-13T19:57:54.894Z" },
|
{ url = "https://files.pythonhosted.org/packages/48/b8/9537b52010134b1d2b72870cc3f92d5fb759394094741b09ceccae183fbe/ruff-0.14.8-py3-none-linux_armv6l.whl", hash = "sha256:ec071e9c82eca417f6111fd39f7043acb53cd3fde9b1f95bbed745962e345afb", size = 13441540, upload-time = "2025-12-04T15:06:14.896Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8e/5c/283c62516dca697cd604c2796d1487396b7a436b2f0ecc3fd412aca470e0/ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72", size = 13413925, upload-time = "2025-11-13T19:57:59.181Z" },
|
{ url = "https://files.pythonhosted.org/packages/24/00/99031684efb025829713682012b6dd37279b1f695ed1b01725f85fd94b38/ruff-0.14.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8cdb162a7159f4ca36ce980a18c43d8f036966e7f73f866ac8f493b75e0c27e9", size = 13669384, upload-time = "2025-12-04T15:06:51.809Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b6/f3/aa319f4afc22cb6fcba2b9cdfc0f03bbf747e59ab7a8c5e90173857a1361/ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a", size = 12574040, upload-time = "2025-11-13T19:58:02.056Z" },
|
{ url = "https://files.pythonhosted.org/packages/72/64/3eb5949169fc19c50c04f28ece2c189d3b6edd57e5b533649dae6ca484fe/ruff-0.14.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2e2fcbefe91f9fad0916850edf0854530c15bd1926b6b779de47e9ab619ea38f", size = 12806917, upload-time = "2025-12-04T15:06:08.925Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f9/7f/cb5845fcc7c7e88ed57f58670189fc2ff517fe2134c3821e77e29fd3b0c8/ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f", size = 13009755, upload-time = "2025-11-13T19:58:05.172Z" },
|
{ url = "https://files.pythonhosted.org/packages/c4/08/5250babb0b1b11910f470370ec0cbc67470231f7cdc033cee57d4976f941/ruff-0.14.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9d70721066a296f45786ec31916dc287b44040f553da21564de0ab4d45a869b", size = 13256112, upload-time = "2025-12-04T15:06:23.498Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/21/d2/bcbedbb6bcb9253085981730687ddc0cc7b2e18e8dc13cf4453de905d7a0/ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68", size = 12937641, upload-time = "2025-11-13T19:58:08.345Z" },
|
{ url = "https://files.pythonhosted.org/packages/78/4c/6c588e97a8e8c2d4b522c31a579e1df2b4d003eddfbe23d1f262b1a431ff/ruff-0.14.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c87e09b3cd9d126fc67a9ecd3b5b1d3ded2b9c7fce3f16e315346b9d05cfb52", size = 13227559, upload-time = "2025-12-04T15:06:33.432Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a4/58/e25de28a572bdd60ffc6bb71fc7fd25a94ec6a076942e372437649cbb02a/ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7", size = 13610854, upload-time = "2025-11-13T19:58:11.419Z" },
|
{ url = "https://files.pythonhosted.org/packages/23/ce/5f78cea13eda8eceac71b5f6fa6e9223df9b87bb2c1891c166d1f0dce9f1/ruff-0.14.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d62cb310c4fbcb9ee4ac023fe17f984ae1e12b8a4a02e3d21489f9a2a5f730c", size = 13896379, upload-time = "2025-12-04T15:06:02.687Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7d/24/43bb3fd23ecee9861970978ea1a7a63e12a204d319248a7e8af539984280/ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78", size = 15061088, upload-time = "2025-11-13T19:58:14.551Z" },
|
{ url = "https://files.pythonhosted.org/packages/cf/79/13de4517c4dadce9218a20035b21212a4c180e009507731f0d3b3f5df85a/ruff-0.14.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1af35c2d62633d4da0521178e8a2641c636d2a7153da0bac1b30cfd4ccd91344", size = 15372786, upload-time = "2025-12-04T15:06:29.828Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/44/a022f288d61c2f8c8645b24c364b719aee293ffc7d633a2ca4d116b9c716/ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb", size = 14734717, upload-time = "2025-11-13T19:58:17.518Z" },
|
{ url = "https://files.pythonhosted.org/packages/00/06/33df72b3bb42be8a1c3815fd4fae83fa2945fc725a25d87ba3e42d1cc108/ruff-0.14.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:25add4575ffecc53d60eed3f24b1e934493631b48ebbc6ebaf9d8517924aca4b", size = 14990029, upload-time = "2025-12-04T15:06:36.812Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/58/81/5c6ba44de7e44c91f68073e0658109d8373b0590940efe5bd7753a2585a3/ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2", size = 14028812, upload-time = "2025-11-13T19:58:20.533Z" },
|
{ url = "https://files.pythonhosted.org/packages/64/61/0f34927bd90925880394de0e081ce1afab66d7b3525336f5771dcf0cb46c/ruff-0.14.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c943d847b7f02f7db4201a0600ea7d244d8a404fbb639b439e987edcf2baf9a", size = 14407037, upload-time = "2025-12-04T15:06:39.979Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ad/ef/41a8b60f8462cb320f68615b00299ebb12660097c952c600c762078420f8/ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19", size = 13825656, upload-time = "2025-11-13T19:58:23.345Z" },
|
{ url = "https://files.pythonhosted.org/packages/96/bc/058fe0aefc0fbf0d19614cb6d1a3e2c048f7dc77ca64957f33b12cfdc5ef/ruff-0.14.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb6e8bf7b4f627548daa1b69283dac5a296bfe9ce856703b03130732e20ddfe2", size = 14102390, upload-time = "2025-12-04T15:06:46.372Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7c/00/207e5de737fdb59b39eb1fac806904fe05681981b46d6a6db9468501062e/ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4", size = 13959922, upload-time = "2025-11-13T19:58:26.537Z" },
|
{ url = "https://files.pythonhosted.org/packages/af/a4/e4f77b02b804546f4c17e8b37a524c27012dd6ff05855d2243b49a7d3cb9/ruff-0.14.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7aaf2974f378e6b01d1e257c6948207aec6a9b5ba53fab23d0182efb887a0e4a", size = 14230793, upload-time = "2025-12-04T15:06:20.497Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bc/7e/fa1f5c2776db4be405040293618846a2dece5c70b050874c2d1f10f24776/ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1", size = 12932501, upload-time = "2025-11-13T19:58:29.822Z" },
|
{ url = "https://files.pythonhosted.org/packages/3f/52/bb8c02373f79552e8d087cedaffad76b8892033d2876c2498a2582f09dcf/ruff-0.14.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e5758ca513c43ad8a4ef13f0f081f80f08008f410790f3611a21a92421ab045b", size = 13160039, upload-time = "2025-12-04T15:06:49.06Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/67/d8/d86bf784d693a764b59479a6bbdc9515ae42c340a5dc5ab1dabef847bfaa/ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151", size = 12927319, upload-time = "2025-11-13T19:58:32.923Z" },
|
{ url = "https://files.pythonhosted.org/packages/1f/ad/b69d6962e477842e25c0b11622548df746290cc6d76f9e0f4ed7456c2c31/ruff-0.14.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f74f7ba163b6e85a8d81a590363bf71618847e5078d90827749bfda1d88c9cdf", size = 13205158, upload-time = "2025-12-04T15:06:54.574Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/de/ee0b304d450ae007ce0cb3e455fe24fbcaaedae4ebaad6c23831c6663651/ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465", size = 13206209, upload-time = "2025-11-13T19:58:35.952Z" },
|
{ url = "https://files.pythonhosted.org/packages/06/63/54f23da1315c0b3dfc1bc03fbc34e10378918a20c0b0f086418734e57e74/ruff-0.14.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:eed28f6fafcc9591994c42254f5a5c5ca40e69a30721d2ab18bb0bb3baac3ab6", size = 13469550, upload-time = "2025-12-04T15:05:59.209Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/33/aa/193ca7e3a92d74f17d9d5771a765965d2cf42c86e6f0fd95b13969115723/ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367", size = 13953709, upload-time = "2025-11-13T19:58:39.002Z" },
|
{ url = "https://files.pythonhosted.org/packages/70/7d/a4d7b1961e4903bc37fffb7ddcfaa7beb250f67d97cfd1ee1d5cddb1ec90/ruff-0.14.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:21d48fa744c9d1cb8d71eb0a740c4dd02751a5de9db9a730a8ef75ca34cf138e", size = 14211332, upload-time = "2025-12-04T15:06:06.027Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Reference in New Issue
Block a user