mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-03 08:27:15 +00:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da82f26c14 | ||
|
|
405cad4617 | ||
|
|
3b4db07488 | ||
|
|
d0195f888f | ||
|
|
3003b9e05e | ||
|
|
2fa48494c2 | ||
|
|
b0540b5fdc | ||
|
|
a3851c157a | ||
|
|
853b57c71d | ||
|
|
1e5afe0968 | ||
|
|
78ea120e0e | ||
|
|
6b2a5e1a2e | ||
|
|
bb88453a5f | ||
|
|
135f9f6251 | ||
|
|
1c0fbeb6f3 | ||
|
|
1ba1f2b9c2 | ||
|
|
07f1a356f8 | ||
|
|
8d41d31bd7 | ||
|
|
351892bbab | ||
|
|
5d6ea11828 | ||
|
|
c5765a50a1 | ||
|
|
c2a9532b8f | ||
|
|
713c857a08 |
+1
-1
@@ -247,7 +247,7 @@ per-file-ignores."src/documents/models.py" = [
|
||||
isort.force-single-line = true
|
||||
|
||||
[tool.codespell]
|
||||
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish"
|
||||
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish,NIN,nin"
|
||||
skip = """\
|
||||
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples\
|
||||
/mail/*,src/documents/tests/samples/*,*.po,*.json\
|
||||
|
||||
@@ -48,6 +48,7 @@ def seed_database() -> None:
|
||||
from django.utils import timezone
|
||||
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import Note
|
||||
@@ -81,6 +82,16 @@ def seed_database() -> None:
|
||||
path="e2e/{created_year}/{title}",
|
||||
owner=admin,
|
||||
)
|
||||
CustomField.objects.create(
|
||||
name="Test Select Field",
|
||||
data_type=CustomField.FieldDataType.SELECT,
|
||||
extra_data={
|
||||
"select_options": [
|
||||
{"id": "abc123", "label": "Alpha"},
|
||||
{"id": "def456", "label": "Beta"},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
today = timezone.localdate()
|
||||
documents = []
|
||||
|
||||
@@ -207,3 +207,41 @@ test('bulk edit', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Confirm' }).click()
|
||||
await bulkEditPromise
|
||||
})
|
||||
|
||||
test('permissions dropdown stays open when selecting a user', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/documents')
|
||||
await page.getByRole('button', { name: 'Permissions' }).click()
|
||||
const dropdown = page.locator(
|
||||
'pngx-permissions-filter-dropdown .dropdown-menu'
|
||||
)
|
||||
await expect(dropdown).toBeVisible()
|
||||
await dropdown.locator('ng-select').click()
|
||||
await page.getByRole('option', { name: 'playwright' }).click()
|
||||
await expect(dropdown).toBeVisible()
|
||||
await expect(dropdown.locator('.ng-value-label')).toHaveText('playwright')
|
||||
})
|
||||
|
||||
test('custom fields query dropdown stays open when building a query', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/documents')
|
||||
await page.getByRole('button', { name: 'Custom fields' }).click()
|
||||
const dropdown = page.locator(
|
||||
'pngx-custom-fields-query-dropdown .dropdown-menu'
|
||||
)
|
||||
await expect(dropdown).toBeVisible()
|
||||
|
||||
// field picker
|
||||
await dropdown.locator('ng-select').first().click()
|
||||
await page.getByRole('option', { name: 'Test Select Field' }).click()
|
||||
await expect(dropdown).toBeVisible()
|
||||
|
||||
// value picker, shown once the operator takes a list of options
|
||||
await dropdown.locator('select').first().selectOption({ label: 'In' })
|
||||
await dropdown.locator('ng-select').last().click()
|
||||
await page.getByRole('option', { name: 'Alpha' }).click()
|
||||
await expect(dropdown).toBeVisible()
|
||||
await expect(dropdown.locator('.ng-value-label').last()).toHaveText('Alpha')
|
||||
})
|
||||
|
||||
+61
-65
@@ -5,14 +5,14 @@
|
||||
<trans-unit id="ngb.alert.close" datatype="html">
|
||||
<source>Close</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/alert/alert.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/alert/alert.ts</context>
|
||||
<context context-type="linenumber">39</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.carousel.slide-number" datatype="html">
|
||||
<source> Slide <x id="INTERPOLATION"/> of <x id="INTERPOLATION_1"/> </source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/carousel/carousel.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/carousel/carousel.ts</context>
|
||||
<context context-type="linenumber">129</context>
|
||||
</context-group>
|
||||
<note priority="1" from="description">Currently selected slide number read by screen reader</note>
|
||||
@@ -20,219 +20,219 @@
|
||||
<trans-unit id="ngb.carousel.previous" datatype="html">
|
||||
<source>Previous</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/carousel/carousel.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/carousel/carousel.ts</context>
|
||||
<context context-type="linenumber">147,148</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.carousel.next" datatype="html">
|
||||
<source>Next</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/carousel/carousel.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/carousel/carousel.ts</context>
|
||||
<context context-type="linenumber">187</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.datepicker.select-month" datatype="html">
|
||||
<source>Select month</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||
<context context-type="linenumber">89</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||
<context context-type="linenumber">89</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.datepicker.select-year" datatype="html">
|
||||
<source>Select year</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||
<context context-type="linenumber">89</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/datepicker/datepicker-navigation-select.ts</context>
|
||||
<context context-type="linenumber">89</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.datepicker.previous-month" datatype="html">
|
||||
<source>Previous month</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||
<context context-type="linenumber">74,76</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||
<context context-type="linenumber">103</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.datepicker.next-month" datatype="html">
|
||||
<source>Next month</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||
<context context-type="linenumber">103</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/datepicker/datepicker-navigation.ts</context>
|
||||
<context context-type="linenumber">103</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.pagination.first" datatype="html">
|
||||
<source>««</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/pagination/pagination.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/pagination/pagination.ts</context>
|
||||
<context context-type="linenumber">297</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.pagination.previous" datatype="html">
|
||||
<source>«</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/pagination/pagination.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/pagination/pagination.ts</context>
|
||||
<context context-type="linenumber">322</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.pagination.next" datatype="html">
|
||||
<source>»</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/pagination/pagination.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/pagination/pagination.ts</context>
|
||||
<context context-type="linenumber">358</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.pagination.last" datatype="html">
|
||||
<source>»»</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/pagination/pagination.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/pagination/pagination.ts</context>
|
||||
<context context-type="linenumber">396</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.pagination.first-aria" datatype="html">
|
||||
<source>First</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/pagination/pagination.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/pagination/pagination.ts</context>
|
||||
<context context-type="linenumber">414,415</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.pagination.previous-aria" datatype="html">
|
||||
<source>Previous</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/pagination/pagination.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/pagination/pagination.ts</context>
|
||||
<context context-type="linenumber">444,445</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.pagination.next-aria" datatype="html">
|
||||
<source>Next</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/pagination/pagination.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/pagination/pagination.ts</context>
|
||||
<context context-type="linenumber">479,480</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.pagination.last-aria" datatype="html">
|
||||
<source>Last</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/pagination/pagination.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/pagination/pagination.ts</context>
|
||||
<context context-type="linenumber">506</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.progressbar.value" datatype="html">
|
||||
<source><x id="INTERPOLATION"/></source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/progressbar/progressbar.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/progressbar/progressbar.ts</context>
|
||||
<context context-type="linenumber">40</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.HH" datatype="html">
|
||||
<source>HH</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="linenumber">256,259</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.hours" datatype="html">
|
||||
<source>Hours</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="linenumber">293,295</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.MM" datatype="html">
|
||||
<source>MM</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="linenumber">322</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.minutes" datatype="html">
|
||||
<source>Minutes</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="linenumber">360</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.increment-hours" datatype="html">
|
||||
<source>Increment hours</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="linenumber">397,399</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.decrement-hours" datatype="html">
|
||||
<source>Decrement hours</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="linenumber">435,438</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.increment-minutes" datatype="html">
|
||||
<source>Increment minutes</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="linenumber">442</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.decrement-minutes" datatype="html">
|
||||
<source>Decrement minutes</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="linenumber">442</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.SS" datatype="html">
|
||||
<source>SS</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="linenumber">442</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.seconds" datatype="html">
|
||||
<source>Seconds</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="linenumber">442</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.increment-seconds" datatype="html">
|
||||
<source>Increment seconds</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="linenumber">442</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.decrement-seconds" datatype="html">
|
||||
<source>Decrement seconds</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="linenumber">442</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.timepicker.PM" datatype="html">
|
||||
<source><x id="INTERPOLATION"/></source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/timepicker/timepicker.ts</context>
|
||||
<context context-type="linenumber">442</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="ngb.toast.close-aria" datatype="html">
|
||||
<source>Close</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.1_@angular+core@22.1.1_@angular+_69897756b3f091add524dceba470da9d/node_modules/src/toast/toast.ts</context>
|
||||
<context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@21.0.0_@angular+common@22.1.3_@angular+core@22.1.3_@angular+_6e980a1a1b6a042b5112673ade17a76b/node_modules/src/toast/toast.ts</context>
|
||||
<context context-type="linenumber">110,112</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
@@ -775,7 +775,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||
<context context-type="linenumber">19,20</context>
|
||||
<context context-type="linenumber">20,21</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3894950702316166331" datatype="html">
|
||||
@@ -788,10 +788,6 @@
|
||||
<context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context>
|
||||
<context context-type="linenumber">49</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||
<context context-type="linenumber">26,29</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.html</context>
|
||||
<context context-type="linenumber">47</context>
|
||||
@@ -1854,7 +1850,7 @@
|
||||
<source>Clear selection</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||
<context context-type="linenumber">9</context>
|
||||
<context context-type="linenumber">10</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.html</context>
|
||||
@@ -1869,14 +1865,14 @@
|
||||
<source>Dismiss all</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||
<context context-type="linenumber">15</context>
|
||||
<context context-type="linenumber">16</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1616102757855967475" datatype="html">
|
||||
<source>All</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||
<context context-type="linenumber">39</context>
|
||||
<context context-type="linenumber">35</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||
@@ -1915,14 +1911,14 @@
|
||||
<source>Filter by</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||
<context context-type="linenumber">59</context>
|
||||
<context context-type="linenumber">55</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="424356320420294719" datatype="html">
|
||||
<source>All types</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||
<context context-type="linenumber">64,65</context>
|
||||
<context context-type="linenumber">60,61</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||
@@ -1933,7 +1929,7 @@
|
||||
<source>All sources</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||
<context context-type="linenumber">73,74</context>
|
||||
<context context-type="linenumber">69,70</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||
@@ -1944,7 +1940,7 @@
|
||||
<source>Clear search</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||
<context context-type="linenumber">92</context>
|
||||
<context context-type="linenumber">88</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
|
||||
@@ -1955,14 +1951,14 @@
|
||||
<source>Search tasks</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||
<context context-type="linenumber">96,97</context>
|
||||
<context context-type="linenumber">92,93</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6849725902312323996" datatype="html">
|
||||
<source>Reset filters</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||
<context context-type="linenumber">105,106</context>
|
||||
<context context-type="linenumber">101,102</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
@@ -1977,14 +1973,14 @@
|
||||
<source>{VAR_PLURAL, plural, =1 {1 task} other {<x id="INTERPOLATION"/> tasks}}</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||
<context context-type="linenumber">126</context>
|
||||
<context context-type="linenumber">122</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8953033926734869941" datatype="html">
|
||||
<source>Name</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||
<context context-type="linenumber">148,149</context>
|
||||
<context context-type="linenumber">144,145</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||
@@ -2095,7 +2091,7 @@
|
||||
<source>Created</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||
<context context-type="linenumber">149,150</context>
|
||||
<context context-type="linenumber">145,146</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context>
|
||||
@@ -2126,21 +2122,21 @@
|
||||
<source>Results</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||
<context context-type="linenumber">151,152</context>
|
||||
<context context-type="linenumber">147,148</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="314315645942131479" datatype="html">
|
||||
<source>Info</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||
<context context-type="linenumber">153,154</context>
|
||||
<context context-type="linenumber">149,150</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3193976279273491157" datatype="html">
|
||||
<source>Actions</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||
<context context-type="linenumber">154,155</context>
|
||||
<context context-type="linenumber">150,151</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.html</context>
|
||||
@@ -2255,21 +2251,21 @@
|
||||
<source>click for full output</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||
<context context-type="linenumber">205</context>
|
||||
<context context-type="linenumber">201</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8946500279334661567" datatype="html">
|
||||
<source>View task details</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||
<context context-type="linenumber">211</context>
|
||||
<context context-type="linenumber">207</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1536087519743707362" datatype="html">
|
||||
<source>Dismiss</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||
<context context-type="linenumber">218</context>
|
||||
<context context-type="linenumber">214</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||
@@ -2292,28 +2288,28 @@
|
||||
<source>Open Document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||
<context context-type="linenumber">223</context>
|
||||
<context context-type="linenumber">219</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5404759957685833020" datatype="html">
|
||||
<source>Result message</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||
<context context-type="linenumber">236,237</context>
|
||||
<context context-type="linenumber">232,233</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6621329748219109148" datatype="html">
|
||||
<source>Duplicate</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||
<context context-type="linenumber">243,244</context>
|
||||
<context context-type="linenumber">239,240</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7593555694782789615" datatype="html">
|
||||
<source>Open</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||
<context context-type="linenumber">251</context>
|
||||
<context context-type="linenumber">247</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/global-search/global-search.component.html</context>
|
||||
@@ -2344,21 +2340,21 @@
|
||||
<source>Input data</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||
<context context-type="linenumber">261,262</context>
|
||||
<context context-type="linenumber">257,258</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1585185618099050920" datatype="html">
|
||||
<source>Result data</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||
<context context-type="linenumber">267,268</context>
|
||||
<context context-type="linenumber">263,264</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7976920528153858271" datatype="html">
|
||||
<source>No tasks match the current filters.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
|
||||
<context context-type="linenumber">289,291</context>
|
||||
<context context-type="linenumber">285,287</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2525230676386818985" datatype="html">
|
||||
|
||||
+14
-14
@@ -15,16 +15,16 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^22.1.1",
|
||||
"@angular/common": "~22.1.1",
|
||||
"@angular/compiler": "~22.1.1",
|
||||
"@angular/core": "~22.1.1",
|
||||
"@angular/forms": "~22.1.1",
|
||||
"@angular/localize": "~22.1.1",
|
||||
"@angular/platform-browser": "~22.1.1",
|
||||
"@angular/router": "~22.1.1",
|
||||
"@angular/cdk": "^22.1.4",
|
||||
"@angular/common": "~22.1.3",
|
||||
"@angular/compiler": "~22.1.3",
|
||||
"@angular/core": "~22.1.3",
|
||||
"@angular/forms": "~22.1.3",
|
||||
"@angular/localize": "~22.1.3",
|
||||
"@angular/platform-browser": "~22.1.3",
|
||||
"@angular/router": "~22.1.3",
|
||||
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
||||
"@ng-select/ng-select": "~23.6.0",
|
||||
"@ng-select/ng-select": "~24.0.2",
|
||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"bootstrap": "^5.3.8",
|
||||
@@ -45,16 +45,16 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-builders/jest": "^22.0.1",
|
||||
"@angular-devkit/core": "^22.1.3",
|
||||
"@angular-devkit/schematics": "^22.1.3",
|
||||
"@angular-devkit/core": "^22.1.6",
|
||||
"@angular-devkit/schematics": "^22.1.6",
|
||||
"@angular-eslint/builder": "22.1.0",
|
||||
"@angular-eslint/eslint-plugin": "22.1.0",
|
||||
"@angular-eslint/eslint-plugin-template": "22.1.0",
|
||||
"@angular-eslint/schematics": "22.1.0",
|
||||
"@angular-eslint/template-parser": "22.1.0",
|
||||
"@angular/build": "22.1.3",
|
||||
"@angular/cli": "22.1.3",
|
||||
"@angular/compiler-cli": "~22.1.1",
|
||||
"@angular/build": "22.1.6",
|
||||
"@angular/cli": "22.1.6",
|
||||
"@angular/compiler-cli": "~22.1.3",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^26.2.0",
|
||||
|
||||
Generated
+442
-632
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
i18n-title
|
||||
info="Tasks shows detailed information about document consumption and system tasks."
|
||||
i18n-info
|
||||
[loading]="loading() && pagedTasks().length === 0"
|
||||
>
|
||||
<div class="btn-toolbar col col-md-auto align-items-center gap-2">
|
||||
<button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedTasks.size === 0">
|
||||
@@ -21,11 +22,6 @@
|
||||
</div>
|
||||
</pngx-page-header>
|
||||
|
||||
@if (loading() && pagedTasks().length === 0) {
|
||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||
<div class="visually-hidden" i18n>Loading...</div>
|
||||
}
|
||||
|
||||
<div class="task-controls mb-3 gap-3 btn-toolbar align-items-center" role="toolbar">
|
||||
<div class="task-view-scope btn-group btn-group-sm" role="group">
|
||||
<input
|
||||
@@ -285,6 +281,6 @@
|
||||
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks: tasksForSection(section), section: section}"></ng-container>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
} @else if (!loading() || pagedTasks().length > 0) {
|
||||
<div class="alert alert-secondary fst-italic" i18n>No tasks match the current filters.</div>
|
||||
}
|
||||
|
||||
@@ -34,9 +34,7 @@
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
&.expanded {
|
||||
--pngx-sidebar-width: var(--pngx-sidebar-expanded-width);
|
||||
}
|
||||
--pngx-sidebar-width: var(--pngx-sidebar-expanded-width);
|
||||
}
|
||||
}
|
||||
@media (max-width: 767.98px) {
|
||||
@@ -113,6 +111,13 @@ main {
|
||||
}
|
||||
}
|
||||
|
||||
// only animate when the user toggles slim mode
|
||||
.sidebar:not(.animating),
|
||||
.sidebar:not(.animating) ~ main,
|
||||
.sidebar:not(.animating) .sidebar-slim-toggler {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.sidebar.slim {
|
||||
max-width: 55px;
|
||||
|
||||
@@ -123,8 +128,6 @@ main {
|
||||
}
|
||||
|
||||
.sidebar.slim:not(.animating) {
|
||||
transition: none;
|
||||
|
||||
li.nav-item span,
|
||||
.sidebar-heading span {
|
||||
display: none;
|
||||
@@ -144,10 +147,6 @@ main {
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar.slim:not(.animating) ~ main.col-slim {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.sidebar.animating {
|
||||
li.nav-item span,
|
||||
.sidebar-heading span {
|
||||
@@ -196,6 +195,26 @@ main {
|
||||
--bs-popover-body-padding-y: .5rem;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.sidebar-sticky > ul,
|
||||
.sidebar-sticky > .nav-group {
|
||||
animation: sidebar-nav-in .3s cubic-bezier(.22, .61, .36, 1) backwards;
|
||||
}
|
||||
|
||||
@for $i from 2 through 5 {
|
||||
.sidebar-sticky > :nth-child(#{$i}) {
|
||||
animation-delay: #{($i - 1) * 0.04}s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sidebar-nav-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-sticky {
|
||||
position: relative;
|
||||
top: 0;
|
||||
|
||||
+6
-3
@@ -6,7 +6,7 @@
|
||||
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
|
||||
}
|
||||
</button>
|
||||
<div class="px-3 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown_{{name}}">
|
||||
<div [id]="dropdownMenuId" class="px-3 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown_{{name}}">
|
||||
<ng-container *ngTemplateOutlet="list; context: { queries: selectionModel.queries }"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,10 +64,11 @@
|
||||
[disabled]="disabled"
|
||||
[virtualScroll]="getSelectOptionsForField(atom.field)?.length > 100"
|
||||
[searchFn]="selectOptionSearchFn"
|
||||
[appendTo]="selectAppendTo"
|
||||
(mousedown)="$event.stopImmediatePropagation()"
|
||||
></ng-select>
|
||||
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.DocumentLink) {
|
||||
<pngx-input-document-link [(ngModel)]="atom.value" class="w-25 form-select doc-link-select p-0" placeholder="Search docs..." i18n-placeholder [minimal]="true"></pngx-input-document-link>
|
||||
<pngx-input-document-link [(ngModel)]="atom.value" class="w-25 form-select doc-link-select p-0" placeholder="Search docs..." i18n-placeholder [minimal]="true" [appendTo]="selectAppendTo"></pngx-input-document-link>
|
||||
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Monetary) {
|
||||
<input class="w-25 form-control rounded-end" type="text" inputmode="decimal"
|
||||
[ngModel]="atom.value"
|
||||
@@ -88,6 +89,7 @@
|
||||
bindLabel="name"
|
||||
bindValue="id"
|
||||
[searchFn]="customFieldSearchFn"
|
||||
[appendTo]="selectAppendTo"
|
||||
(mousedown)="$event.stopImmediatePropagation()"
|
||||
></ng-select>
|
||||
<select class="w-25 form-select" [(ngModel)]="atom.operator" [disabled]="disabled">
|
||||
@@ -121,7 +123,7 @@
|
||||
<ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
|
||||
}
|
||||
@case (CustomFieldQueryOperator.Contains) {
|
||||
<pngx-input-document-link [(ngModel)]="atom.value" class="w-25 form-select doc-link-select p-0" placeholder="Search docs..." i18n-placeholder [minimal]="true"></pngx-input-document-link>
|
||||
<pngx-input-document-link [(ngModel)]="atom.value" class="w-25 form-select doc-link-select p-0" placeholder="Search docs..." i18n-placeholder [minimal]="true" [appendTo]="selectAppendTo"></pngx-input-document-link>
|
||||
}
|
||||
@case (CustomFieldQueryOperator.In) {
|
||||
<ng-select
|
||||
@@ -133,6 +135,7 @@
|
||||
[disabled]="disabled"
|
||||
[multiple]="true"
|
||||
[searchFn]="selectOptionSearchFn"
|
||||
[appendTo]="selectAppendTo"
|
||||
(mousedown)="$event.stopImmediatePropagation()"
|
||||
></ng-select>
|
||||
}
|
||||
|
||||
+13
@@ -1,3 +1,4 @@
|
||||
import { _IdGenerator } from '@angular/cdk/a11y'
|
||||
import {
|
||||
getLocaleNumberSymbol,
|
||||
NgClass,
|
||||
@@ -250,6 +251,18 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
||||
@Input()
|
||||
useDropdown: boolean = true
|
||||
|
||||
private readonly idGenerator = inject(_IdGenerator)
|
||||
public readonly dropdownMenuId = this.idGenerator.getId(
|
||||
'pngx-custom-fields-query-dropdown-'
|
||||
)
|
||||
|
||||
/**
|
||||
* Keep ng-select dropdown panels inside the dropdown menu
|
||||
*/
|
||||
get selectAppendTo(): string {
|
||||
return this.useDropdown ? `#${this.dropdownMenuId}` : null
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<i-bs width="1em" height="1em" name="calendar-event-fill"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
|
||||
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
|
||||
</button>
|
||||
<div class="dropdown-menu date-dropdown shadow p-2" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}">
|
||||
<div [id]="dropdownMenuId" class="dropdown-menu date-dropdown shadow p-2" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}">
|
||||
<h6 class="dropdown-header border-bottom" i18n>Created</h6>
|
||||
<div class="list-group list-group-flush">
|
||||
<div class="list-group-item d-flex p-2 select-item" role="menuitem">
|
||||
@@ -21,6 +21,7 @@
|
||||
bindValue="id"
|
||||
bindLabel="name"
|
||||
clearable="false"
|
||||
[appendTo]="'#' + dropdownMenuId"
|
||||
placeholder="Relative dates"
|
||||
i18n-placeholder
|
||||
(change)="onSetCreatedRelativeDate($event)">
|
||||
@@ -97,6 +98,7 @@
|
||||
bindValue="id"
|
||||
bindLabel="name"
|
||||
clearable="false"
|
||||
[appendTo]="'#' + dropdownMenuId"
|
||||
placeholder="Relative dates"
|
||||
i18n-placeholder
|
||||
(change)="onSetAddedRelativeDate($event)">
|
||||
|
||||
@@ -23,6 +23,7 @@ import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
|
||||
import { pngxPopperOptions } from 'src/app/utils/popper-options'
|
||||
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
|
||||
import { _IdGenerator } from '@angular/cdk/a11y'
|
||||
|
||||
export interface DateSelection {
|
||||
createdTo?: string
|
||||
@@ -68,6 +69,10 @@ export enum RelativeDate {
|
||||
})
|
||||
export class DatesDropdownComponent implements OnInit, OnDestroy {
|
||||
public popperOptions = pngxPopperOptions
|
||||
private readonly idGenerator = inject(_IdGenerator)
|
||||
public readonly dropdownMenuId = this.idGenerator.getId(
|
||||
'pngx-dates-dropdown-'
|
||||
)
|
||||
|
||||
constructor() {
|
||||
const settings = inject(SettingsService)
|
||||
|
||||
+6
@@ -7,6 +7,8 @@
|
||||
padding-left: calc(calc(var(--depth) - 2) * 1rem);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
.indicator {
|
||||
display: inline-block;
|
||||
@@ -18,3 +20,7 @@
|
||||
margin-left: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
[compareWith]="compareDocuments"
|
||||
[trackByFn]="trackByFn"
|
||||
[minTermLength]="2"
|
||||
[appendTo]="appendTo"
|
||||
[loading]="loading()"
|
||||
[typeahead]="documentsInput$"
|
||||
(mousedown)="$event.stopImmediatePropagation()"
|
||||
|
||||
@@ -81,6 +81,12 @@ export class DocumentLinkComponent
|
||||
@Input()
|
||||
placeholder: string = $localize`Search for documents`
|
||||
|
||||
/**
|
||||
* Parent for ng-select dropdown, needed to prevent close on click.
|
||||
*/
|
||||
@Input()
|
||||
appendTo: string = null
|
||||
|
||||
get selectedDocumentIDs(): number[] {
|
||||
return this.selectedDocuments.map((d) => d.id)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
}
|
||||
|
||||
// Dropdown hierarchy reveal for ng-select options
|
||||
:host ::ng-deep .ng-dropdown-panel .ng-option {
|
||||
::ng-deep .ng-dropdown-panel .ng-option {
|
||||
overflow-x: auto !important;
|
||||
|
||||
.tag-option-row {
|
||||
@@ -41,12 +41,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
:host ::ng-deep .ng-dropdown-panel .ng-option:hover .hierarchy-reveal,
|
||||
:host ::ng-deep .ng-dropdown-panel .ng-option.ng-option-marked .hierarchy-reveal {
|
||||
::ng-deep .ng-dropdown-panel .ng-option:hover .hierarchy-reveal,
|
||||
::ng-deep .ng-dropdown-panel .ng-option.ng-option-marked .hierarchy-reveal {
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
::ng-deep .ng-dropdown-panel .ng-option:hover .hierarchy-indicator,
|
||||
:host ::ng-deep .ng-dropdown-panel .ng-option.ng-option-marked .hierarchy-indicator {
|
||||
::ng-deep .ng-dropdown-panel .ng-option.ng-option-marked .hierarchy-indicator {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
+6
-2
@@ -3,7 +3,7 @@
|
||||
<i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
|
||||
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
|
||||
</button>
|
||||
<div class="dropdown-menu permission-filter-dropdown shadow py-0 w-2" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}">
|
||||
<div [id]="dropdownMenuId" class="dropdown-menu permission-filter-dropdown shadow py-0 w-2" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}">
|
||||
<div class="list-group list-group-flush">
|
||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NONE)" [disabled]="disabled">
|
||||
<div class="selected-icon me-1">
|
||||
@@ -58,7 +58,10 @@
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }" class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" [disabled]="disabled">
|
||||
<div class="selected-icon me-1">
|
||||
@if (selectionModel.ownerFilter() === OwnerFilterType.OTHERS) {
|
||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||
<a class="text-reset focus-variants" href="javascript:void(0)" (click)="clearIncludeUsers()" aria-label="Clear selected users" i18n-aria-label>
|
||||
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
|
||||
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<div class="me-1 w-100">
|
||||
@@ -69,6 +72,7 @@
|
||||
(ngModelChange)="selectionModel.includeUsers.set($event)"
|
||||
[disabled]="disabled"
|
||||
[clearable]="false"
|
||||
[appendTo]="'#' + dropdownMenuId"
|
||||
[items]="users()"
|
||||
bindLabel="username"
|
||||
multiple="true"
|
||||
|
||||
+15
@@ -6,3 +6,18 @@
|
||||
min-width: 1em;
|
||||
min-height: 1em;
|
||||
}
|
||||
|
||||
.focus-variants {
|
||||
.variant-focused {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover, &:focus {
|
||||
.variant-unfocused {
|
||||
display: none;
|
||||
}
|
||||
.variant-focused {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -171,6 +171,21 @@ describe('PermissionsFilterDropdownComponent', () => {
|
||||
|
||||
expect(component.selectionModel.ownerFilter()).toEqual(OwnerFilterType.NONE)
|
||||
})
|
||||
it('should clear selected users', () => {
|
||||
component.selectionModel.includeUsers.set([12])
|
||||
component.onUserSelect()
|
||||
component.selectionModel.hideUnowned.set(true)
|
||||
expect(component.selectionModel.ownerFilter()).toEqual(
|
||||
OwnerFilterType.OTHERS
|
||||
)
|
||||
|
||||
component.clearIncludeUsers()
|
||||
|
||||
expect(component.selectionModel.includeUsers()).toEqual([])
|
||||
expect(component.selectionModel.ownerFilter()).toEqual(OwnerFilterType.NONE)
|
||||
expect(component.selectionModel.hideUnowned()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should emit a selection model depending on the type of owner filter set', () => {
|
||||
const emitted = () => ({
|
||||
excludeUsers: ownerFilterSetResult.excludeUsers(),
|
||||
|
||||
+11
@@ -1,3 +1,4 @@
|
||||
import { _IdGenerator } from '@angular/cdk/a11y'
|
||||
import { NgClass } from '@angular/common'
|
||||
import {
|
||||
Component,
|
||||
@@ -70,6 +71,11 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
||||
|
||||
public OwnerFilterType = OwnerFilterType
|
||||
|
||||
private readonly idGenerator = inject(_IdGenerator)
|
||||
public readonly dropdownMenuId = this.idGenerator.getId(
|
||||
'pngx-permissions-filter-dropdown-'
|
||||
)
|
||||
|
||||
@Input()
|
||||
title: string
|
||||
|
||||
@@ -205,6 +211,11 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
||||
this.ownerFilterSet.emit(this.selectionModel)
|
||||
}
|
||||
|
||||
clearIncludeUsers() {
|
||||
this.selectionModel.includeUsers.set([])
|
||||
this.onUserSelect()
|
||||
}
|
||||
|
||||
onUserSelect() {
|
||||
this.selectionModel.ownerFilter.set(
|
||||
this.selectionModel.includeUsers()?.length
|
||||
|
||||
+16
-11
@@ -374,17 +374,6 @@ th[pngxSortable] {
|
||||
}
|
||||
}
|
||||
|
||||
.ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-marked {
|
||||
background-color: var(--pngx-bg-darker) !important;
|
||||
color: var(--pngx-body-color-accent) !important;
|
||||
}
|
||||
|
||||
.ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-selected,
|
||||
.ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-selected.ng-option-marked {
|
||||
font-weight: bold;
|
||||
background-color: var(--pngx-bg-alt2) !important;
|
||||
}
|
||||
|
||||
&.ng-select-multiple .ng-select-container .ng-value-container .ng-placeholder,
|
||||
.ng-select-container .ng-value-container .ng-placeholder {
|
||||
top: 7px;
|
||||
@@ -402,6 +391,22 @@ th[pngxSortable] {
|
||||
}
|
||||
}
|
||||
|
||||
// ng-select renders the dropdown panel in a CDK overlay outside the component
|
||||
.ng-dropdown-panel {
|
||||
font-size: var(--pngx-body-font-size);
|
||||
|
||||
.ng-dropdown-panel-items .ng-option.ng-option-marked {
|
||||
background-color: var(--pngx-bg-darker) !important;
|
||||
color: var(--pngx-body-color-accent) !important;
|
||||
}
|
||||
|
||||
.ng-dropdown-panel-items .ng-option.ng-option-selected,
|
||||
.ng-dropdown-panel-items .ng-option.ng-option-selected.ng-option-marked {
|
||||
font-weight: bold;
|
||||
background-color: var(--pngx-bg-alt2) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.paperless-input-tags {
|
||||
.ng-select.ng-select-multiple .ng-select-container .ng-value-container .ng-value {
|
||||
background-color: transparent;
|
||||
|
||||
@@ -237,8 +237,8 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,%3csvg xmlns='h
|
||||
mix-blend-mode: luminosity;
|
||||
}
|
||||
|
||||
.paperless-input-select .ng-select .ng-dropdown-panel .ng-dropdown-panel-items .ng-option:not(.ng-option-selected):hover,
|
||||
.paperless-input-select .ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-marked {
|
||||
.ng-dropdown-panel .ng-dropdown-panel-items .ng-option:not(.ng-option-selected):hover,
|
||||
.ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-marked {
|
||||
background-color: var(--bs-light) !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -314,7 +314,7 @@ def _consume_file(
|
||||
consumption_dir: Path,
|
||||
*,
|
||||
subdirs_as_tags: bool,
|
||||
) -> None:
|
||||
) -> bool:
|
||||
"""
|
||||
Queue a file for consumption.
|
||||
|
||||
@@ -322,15 +322,20 @@ def _consume_file(
|
||||
filepath: Path to the file to consume.
|
||||
consumption_dir: Base consumption directory.
|
||||
subdirs_as_tags: Whether to create tags from subdirectory names.
|
||||
|
||||
Returns:
|
||||
True if the file was successfully handed to Celery, False otherwise.
|
||||
Callers must not record the file as queued on failure, or the rescan
|
||||
will never retry it.
|
||||
"""
|
||||
# Verify file still exists and is accessible
|
||||
try:
|
||||
if not filepath.is_file():
|
||||
logger.debug(f"Not consuming {filepath}: not a file or doesn't exist")
|
||||
return
|
||||
return False
|
||||
except OSError as e:
|
||||
logger.warning(f"Not consuming {filepath}: {e}")
|
||||
return
|
||||
return False
|
||||
|
||||
# Get tags from path if configured
|
||||
tag_ids: list[int] | None = None
|
||||
@@ -355,6 +360,9 @@ def _consume_file(
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(f"Error while queuing document {filepath}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -492,12 +500,12 @@ class Command(BaseCommand):
|
||||
if not consumer_filter(Change.added, str(filepath)):
|
||||
continue
|
||||
|
||||
_consume_file(
|
||||
if _consume_file(
|
||||
filepath=filepath,
|
||||
consumption_dir=directory,
|
||||
subdirs_as_tags=subdirs_as_tags,
|
||||
)
|
||||
queued.add(filepath.resolve())
|
||||
):
|
||||
queued.add(filepath.resolve())
|
||||
|
||||
return queued
|
||||
|
||||
@@ -651,14 +659,16 @@ class Command(BaseCommand):
|
||||
|
||||
# Check for stable files
|
||||
for stable_path in tracker.get_stable_files():
|
||||
_consume_file(
|
||||
# Only remember files that were actually queued, so the
|
||||
# rescan does not re-queue them while the consume task
|
||||
# has yet to remove them from disk, but does retry a
|
||||
# failed publish instead of stranding it
|
||||
if _consume_file(
|
||||
filepath=stable_path,
|
||||
consumption_dir=directory,
|
||||
subdirs_as_tags=subdirs_as_tags,
|
||||
)
|
||||
# Remember it so the rescan does not re-queue it while
|
||||
# the consume task has yet to remove it from disk
|
||||
queued.add(stable_path)
|
||||
):
|
||||
queued.add(stable_path)
|
||||
|
||||
# Exit watch loop to reconfigure timeout
|
||||
break
|
||||
|
||||
@@ -1003,7 +1003,7 @@ def run_workflows(
|
||||
|
||||
# kwargs so the PaperlessTask record can note the
|
||||
# document, see _extract_input_data
|
||||
apply_ai_suggestions.delay(
|
||||
apply_ai_suggestions.delay_on_commit(
|
||||
action_id=action.pk,
|
||||
document_id=document.pk,
|
||||
)
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
|
||||
class TestChatStreamingViewInputValidation(APITestCase):
|
||||
def setUp(self) -> None:
|
||||
@@ -42,3 +49,73 @@ class TestChatStreamingViewInputValidation(APITestCase):
|
||||
format="json",
|
||||
)
|
||||
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestChatStreamingViewUnrestrictedFlag:
|
||||
"""The document id filter may only be skipped (``unrestricted=True``) for
|
||||
an active superuser, never for a regular user -- regardless of what
|
||||
permissions that user holds.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def mocked_stream_chat(self, mocker: MockerFixture) -> mock.MagicMock:
|
||||
"""AI enabled, with stream_chat_with_documents patched so the view
|
||||
never touches the real vector store; returns the patched callable so
|
||||
tests can inspect how it was called.
|
||||
"""
|
||||
mocker.patch("documents.views.AIConfig").return_value.ai_enabled = True
|
||||
return mocker.patch(
|
||||
"documents.views.stream_chat_with_documents",
|
||||
return_value=iter(()),
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def viewer_client(self, user_client: APIClient, regular_user: User) -> APIClient:
|
||||
"""The conftest regular-user client, granted the global
|
||||
view_document permission -- the minimum ViewDocumentsPermissions
|
||||
needs to reach the view at all. Model-level only: says nothing
|
||||
about which documents (if any) this user can actually see.
|
||||
"""
|
||||
regular_user.user_permissions.add(
|
||||
*Permission.objects.filter(codename="view_document"),
|
||||
)
|
||||
return user_client
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("client_fixture", "expected_unrestricted"),
|
||||
[
|
||||
pytest.param("admin_client", True, id="superuser_is_unrestricted"),
|
||||
pytest.param("viewer_client", False, id="regular_user_is_restricted"),
|
||||
],
|
||||
)
|
||||
def test_unrestricted_only_for_superuser(
|
||||
self,
|
||||
request: pytest.FixtureRequest,
|
||||
mocked_stream_chat: mock.MagicMock,
|
||||
client_fixture: str,
|
||||
*,
|
||||
expected_unrestricted: bool,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A superuser, or a regular user holding the global
|
||||
view_document permission (but no object-level document access)
|
||||
WHEN:
|
||||
- They post a chat question with no document_id
|
||||
THEN:
|
||||
- stream_chat_with_documents is called with unrestricted=True
|
||||
only for the superuser; the regular user is always
|
||||
unrestricted=False, regardless of their permissions
|
||||
"""
|
||||
client: APIClient = request.getfixturevalue(client_fixture)
|
||||
|
||||
client.post(
|
||||
"/api/documents/chat/",
|
||||
data={"q": "What's in these documents?"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert (
|
||||
mocked_stream_chat.call_args.kwargs["unrestricted"] is expected_unrestricted
|
||||
)
|
||||
|
||||
@@ -445,12 +445,13 @@ class TestConsumeFile:
|
||||
target = consumption_dir / "document.pdf"
|
||||
shutil.copy(sample_pdf, target)
|
||||
|
||||
_consume_file(
|
||||
result = _consume_file(
|
||||
filepath=target,
|
||||
consumption_dir=consumption_dir,
|
||||
subdirs_as_tags=False,
|
||||
)
|
||||
|
||||
assert result is True
|
||||
mock_consume_file_delay.apply_async.assert_called_once()
|
||||
call_args = mock_consume_file_delay.apply_async.call_args
|
||||
consumable_doc = call_args.kwargs["kwargs"]["input_doc"]
|
||||
@@ -464,11 +465,12 @@ class TestConsumeFile:
|
||||
mock_consume_file_delay: MagicMock,
|
||||
) -> None:
|
||||
"""Test _consume_file handles nonexistent files gracefully."""
|
||||
_consume_file(
|
||||
result = _consume_file(
|
||||
filepath=consumption_dir / "nonexistent.pdf",
|
||||
consumption_dir=consumption_dir,
|
||||
subdirs_as_tags=False,
|
||||
)
|
||||
assert result is False
|
||||
mock_consume_file_delay.apply_async.assert_not_called()
|
||||
|
||||
def test_consume_directory(
|
||||
@@ -480,11 +482,12 @@ class TestConsumeFile:
|
||||
subdir = consumption_dir / "subdir"
|
||||
subdir.mkdir()
|
||||
|
||||
_consume_file(
|
||||
result = _consume_file(
|
||||
filepath=subdir,
|
||||
consumption_dir=consumption_dir,
|
||||
subdirs_as_tags=False,
|
||||
)
|
||||
assert result is False
|
||||
mock_consume_file_delay.apply_async.assert_not_called()
|
||||
|
||||
def test_consume_with_permission_error(
|
||||
@@ -499,13 +502,33 @@ class TestConsumeFile:
|
||||
shutil.copy(sample_pdf, target)
|
||||
|
||||
mocker.patch.object(Path, "is_file", side_effect=PermissionError("denied"))
|
||||
_consume_file(
|
||||
result = _consume_file(
|
||||
filepath=target,
|
||||
consumption_dir=consumption_dir,
|
||||
subdirs_as_tags=False,
|
||||
)
|
||||
assert result is False
|
||||
mock_consume_file_delay.apply_async.assert_not_called()
|
||||
|
||||
def test_consume_with_apply_async_failure(
|
||||
self,
|
||||
consumption_dir: Path,
|
||||
sample_pdf: Path,
|
||||
mock_consume_file_delay: MagicMock,
|
||||
) -> None:
|
||||
"""Test _consume_file reports failure when apply_async raises."""
|
||||
target = consumption_dir / "document.pdf"
|
||||
shutil.copy(sample_pdf, target)
|
||||
|
||||
mock_consume_file_delay.apply_async.side_effect = Exception("broker down")
|
||||
|
||||
result = _consume_file(
|
||||
filepath=target,
|
||||
consumption_dir=consumption_dir,
|
||||
subdirs_as_tags=False,
|
||||
)
|
||||
assert result is False
|
||||
|
||||
def test_consume_with_tags_error(
|
||||
self,
|
||||
consumption_dir: Path,
|
||||
@@ -522,11 +545,12 @@ class TestConsumeFile:
|
||||
side_effect=DatabaseError("Something happened"),
|
||||
)
|
||||
|
||||
_consume_file(
|
||||
result = _consume_file(
|
||||
filepath=target,
|
||||
consumption_dir=consumption_dir,
|
||||
subdirs_as_tags=True,
|
||||
)
|
||||
assert result is True
|
||||
mock_consume_file_delay.apply_async.assert_called_once()
|
||||
call_args = mock_consume_file_delay.apply_async.call_args
|
||||
overrides = call_args.kwargs["kwargs"]["overrides"]
|
||||
@@ -1249,6 +1273,52 @@ class TestProcessExistingFilesQueued:
|
||||
assert target.resolve() in queued
|
||||
|
||||
|
||||
@pytest.mark.management
|
||||
@pytest.mark.django_db
|
||||
class TestCommandRetryAfterQueueFailure:
|
||||
"""
|
||||
Regression test for GH #13923.
|
||||
|
||||
A file whose ``apply_async`` publish fails (e.g. broker briefly down)
|
||||
must not be marked as queued, so the periodic rescan retries it once
|
||||
the broker recovers, instead of stranding it until the consumer
|
||||
process is restarted.
|
||||
"""
|
||||
|
||||
def test_watch_loop_retries_failed_publish_on_rescan(
|
||||
self,
|
||||
consumption_dir: Path,
|
||||
sample_pdf: Path,
|
||||
mock_consume_file_delay: MagicMock,
|
||||
start_consumer: Callable[..., ConsumerThread],
|
||||
) -> None:
|
||||
"""A publish failure from the watch loop is retried by the rescan."""
|
||||
apply_async = mock_consume_file_delay.apply_async
|
||||
|
||||
def fail_first_call(*args: object, **kwargs: object) -> None:
|
||||
if apply_async.call_count == 1:
|
||||
raise Exception("broker down")
|
||||
|
||||
apply_async.side_effect = fail_first_call
|
||||
|
||||
thread = start_consumer(stability_delay=0.1, rescan_interval=0.3)
|
||||
|
||||
target = consumption_dir / "document.pdf"
|
||||
shutil.copy(sample_pdf, target)
|
||||
|
||||
deadline = monotonic() + 5.0
|
||||
while apply_async.call_count < 2 and monotonic() < deadline:
|
||||
sleep(0.1)
|
||||
|
||||
if thread.exception:
|
||||
raise thread.exception
|
||||
|
||||
assert apply_async.call_count >= 2, (
|
||||
"Expected the failed publish to be retried by the rescan, "
|
||||
f"but apply_async was only called {apply_async.call_count} time(s)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.management
|
||||
@pytest.mark.django_db
|
||||
class TestCommandRescanRecovery:
|
||||
|
||||
@@ -5621,11 +5621,15 @@ class TestApplyAISuggestionsWorkflowAction(
|
||||
action = self.make_action()
|
||||
self.make_workflow(action, WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED)
|
||||
|
||||
with mock.patch("documents.tasks.apply_ai_suggestions.delay") as delay:
|
||||
with (
|
||||
mock.patch("documents.tasks.apply_ai_suggestions.delay") as delay,
|
||||
self.captureOnCommitCallbacks(execute=True),
|
||||
):
|
||||
run_workflows(
|
||||
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||
self.doc,
|
||||
)
|
||||
delay.assert_not_called()
|
||||
|
||||
delay.assert_called_once_with(action_id=action.pk, document_id=self.doc.pk)
|
||||
|
||||
|
||||
@@ -180,6 +180,7 @@ from documents.permissions import has_system_status_permission
|
||||
from documents.permissions import permitted_document_ids
|
||||
from documents.permissions import permitted_object_ids
|
||||
from documents.permissions import set_permissions_for_object
|
||||
from documents.permissions import user_is_unrestricted
|
||||
from documents.plugins.date_parsing import get_date_parser
|
||||
from documents.schema import generate_object_with_permissions_schema
|
||||
from documents.search import SearchHit
|
||||
@@ -2329,10 +2330,12 @@ class ChatStreamingView(GenericAPIView[Any]):
|
||||
return HttpResponseForbidden("Insufficient permissions")
|
||||
|
||||
documents = Document.objects.filter(pk=document.pk)
|
||||
unrestricted = False
|
||||
else:
|
||||
documents = Document.objects.filter(
|
||||
id__in=permitted_document_ids(request.user),
|
||||
)
|
||||
unrestricted = user_is_unrestricted(request.user)
|
||||
|
||||
output_language = get_llm_output_language(
|
||||
ai_config=ai_config,
|
||||
@@ -2343,6 +2346,7 @@ class ChatStreamingView(GenericAPIView[Any]):
|
||||
stream_chat_with_documents(
|
||||
query_str=question,
|
||||
documents=documents,
|
||||
unrestricted=unrestricted,
|
||||
output_language=output_language,
|
||||
),
|
||||
content_type="text/event-stream",
|
||||
|
||||
@@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-09-01 19:54+0000\n"
|
||||
"POT-Creation-Date: 2026-09-02 18:09+0000\n"
|
||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
@@ -1628,7 +1628,7 @@ msgid "workflow runs"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:524 documents/serialisers.py:878
|
||||
#: documents/serialisers.py:2830 documents/views.py:313 documents/views.py:2619
|
||||
#: documents/serialisers.py:2830 documents/views.py:314 documents/views.py:2623
|
||||
#: paperless_mail/serialisers.py:156
|
||||
msgid "Insufficient permissions."
|
||||
msgstr ""
|
||||
@@ -1669,7 +1669,7 @@ msgstr ""
|
||||
msgid "Duplicate document identifiers are not allowed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2916 documents/views.py:4620
|
||||
#: documents/serialisers.py:2916 documents/views.py:4624
|
||||
#, python-format
|
||||
msgid "Documents not found: %(ids)s"
|
||||
msgstr ""
|
||||
@@ -1937,36 +1937,36 @@ msgstr ""
|
||||
msgid "Unable to parse URI {value}"
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:306 documents/views.py:2616
|
||||
#: documents/views.py:307 documents/views.py:2620
|
||||
msgid "Invalid more_like_id"
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:1590
|
||||
#: documents/views.py:1591
|
||||
msgid "Invalid AI configuration."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:1601
|
||||
#: documents/views.py:1602
|
||||
msgid "AI backend request timed out."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:2441 documents/views.py:2762
|
||||
#: documents/views.py:2445 documents/views.py:2766
|
||||
msgid "Specify only one of text, title_search, query, or more_like_id."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4633
|
||||
#: documents/views.py:4637
|
||||
#, python-format
|
||||
msgid "Insufficient permissions to share document %(id)s."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4679
|
||||
#: documents/views.py:4683
|
||||
msgid "Bundle is already being processed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4743
|
||||
#: documents/views.py:4747
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4757
|
||||
#: documents/views.py:4761
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ def _rewrite_request_to_pinned_ip(
|
||||
method=request.method,
|
||||
url=new_url,
|
||||
headers=new_headers,
|
||||
content=request.stream,
|
||||
stream=request.stream,
|
||||
extensions=request.extensions,
|
||||
)
|
||||
rewritten_request.extensions["sni_hostname"] = hostname
|
||||
|
||||
@@ -705,6 +705,12 @@ CELERY_BROKER_TRANSPORT_OPTIONS = {
|
||||
CELERY_TASK_TRACK_STARTED = True
|
||||
CELERY_TASK_TIME_LIMIT: Final[int] = get_int_from_env("PAPERLESS_WORKER_TIMEOUT", 1800)
|
||||
|
||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#std-setting-task_allow_error_cb_on_chord_header
|
||||
# Without this, a failing chord header never triggers the errback, so a mail
|
||||
# whose attachments all fail is never recorded and is re-fetched forever.
|
||||
# The errback runs once per failed header task, so it must be idempotent.
|
||||
CELERY_TASK_ALLOW_ERROR_CB_ON_CHORD_HEADER = True
|
||||
|
||||
CELERY_CACHE_BACKEND = "default"
|
||||
|
||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-serializer
|
||||
|
||||
@@ -8,7 +8,8 @@ from documents.models import Document
|
||||
from paperless.config import AIConfig
|
||||
from paperless_ai.client import AIClient
|
||||
from paperless_ai.db import db_connection_released
|
||||
from paperless_ai.indexing import _document_id_filters
|
||||
from paperless_ai.indexing import document_id_filters
|
||||
from paperless_ai.indexing import exclude_document_ids_filter
|
||||
from paperless_ai.indexing import get_rag_prompt_helper
|
||||
from paperless_ai.indexing import load_or_build_index
|
||||
from paperless_ai.indexing import read_store
|
||||
@@ -95,12 +96,15 @@ def _format_chat_metadata_trailer(references: list[dict[str, int | str]]) -> str
|
||||
def stream_chat_with_documents(
|
||||
query_str: str,
|
||||
documents: QuerySet[Document],
|
||||
*,
|
||||
unrestricted: bool = False,
|
||||
output_language: str | None = None,
|
||||
):
|
||||
try:
|
||||
yield from _stream_chat_with_documents(
|
||||
query_str,
|
||||
documents,
|
||||
unrestricted=unrestricted,
|
||||
output_language=output_language,
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -111,6 +115,8 @@ def stream_chat_with_documents(
|
||||
def _stream_chat_with_documents(
|
||||
query_str: str,
|
||||
documents: QuerySet[Document],
|
||||
*,
|
||||
unrestricted: bool = False,
|
||||
output_language: str | None = None,
|
||||
):
|
||||
if not documents.exists():
|
||||
@@ -123,9 +129,18 @@ def _stream_chat_with_documents(
|
||||
from llama_index.core.retrievers import VectorIndexRetriever
|
||||
|
||||
config = AIConfig()
|
||||
filters = _document_id_filters(
|
||||
str(pk) for pk in documents.values_list("pk", flat=True)
|
||||
)
|
||||
if unrestricted:
|
||||
# Exclude trashed ids (usually few) instead of an IN filter over the
|
||||
# full permitted set, which risks the vector store's bound parameter
|
||||
# limit (_MAX_IN_VALUES) on large installs. Trashed documents stay
|
||||
# indexed until permanent deletion (delete_document_from_llm_index
|
||||
# hangs off post_delete, not trash), so must be excluded explicitly.
|
||||
trashed_ids = Document.deleted_objects.values_list("pk", flat=True)
|
||||
filters = exclude_document_ids_filter(str(pk) for pk in trashed_ids)
|
||||
else:
|
||||
filters = document_id_filters(
|
||||
str(pk) for pk in documents.values_list("pk", flat=True)
|
||||
)
|
||||
|
||||
# Hold the shared read lock for the whole operation: the query engine
|
||||
# retrieves from the vector store again during synthesis, so the connection
|
||||
|
||||
@@ -131,11 +131,10 @@ class AIClient:
|
||||
|
||||
from llama_index.core.llms import ChatMessage
|
||||
|
||||
user_msg = ChatMessage(role="user", content=prompt)
|
||||
if self.settings.llm_backend == LLMBackend.OLLAMA:
|
||||
with self._normalize_timeouts():
|
||||
result = self.llm.chat(
|
||||
[user_msg],
|
||||
[ChatMessage(role="user", content=prompt)],
|
||||
format=DocumentClassifierSchema.model_json_schema(),
|
||||
think=False,
|
||||
)
|
||||
@@ -149,6 +148,11 @@ class AIClient:
|
||||
from llama_index.core.program.function_program import get_function_tool
|
||||
|
||||
tool = get_function_tool(DocumentClassifierSchema)
|
||||
user_msg = ChatMessage(
|
||||
role="user",
|
||||
content=f"{prompt}\n\n"
|
||||
f"Answer by calling the {tool.metadata.name} tool. Do not write the answer as text.",
|
||||
)
|
||||
with self._normalize_timeouts():
|
||||
result = self.llm.chat_with_tools(
|
||||
tools=[tool],
|
||||
|
||||
@@ -362,7 +362,7 @@ def _embed_nodes(nodes: list["BaseNode"], embed_model) -> None:
|
||||
node.embedding = emb
|
||||
|
||||
|
||||
def _document_id_filters(doc_ids):
|
||||
def document_id_filters(doc_ids):
|
||||
"""Return a MetadataFilters IN filter scoped to ``doc_ids``."""
|
||||
from llama_index.core.vector_stores.types import FilterOperator
|
||||
from llama_index.core.vector_stores.types import MetadataFilter
|
||||
@@ -396,6 +396,23 @@ def _exclude_document_id_filter(document_id: int | str):
|
||||
)
|
||||
|
||||
|
||||
def exclude_document_ids_filter(doc_ids):
|
||||
"""Return a MetadataFilters NIN filter excluding every id in ``doc_ids``."""
|
||||
from llama_index.core.vector_stores.types import FilterOperator
|
||||
from llama_index.core.vector_stores.types import MetadataFilter
|
||||
from llama_index.core.vector_stores.types import MetadataFilters
|
||||
|
||||
return MetadataFilters(
|
||||
filters=[
|
||||
MetadataFilter(
|
||||
key="document_id",
|
||||
operator=FilterOperator.NIN,
|
||||
value=list(doc_ids),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def update_llm_index(
|
||||
*,
|
||||
iter_wrapper: IterWrapper[Document] = identity,
|
||||
@@ -660,7 +677,7 @@ def retrieve_similar_nodes(
|
||||
|
||||
filter_parts = []
|
||||
if allowed_document_ids is not None:
|
||||
filter_parts.extend(_document_id_filters(allowed_document_ids).filters)
|
||||
filter_parts.extend(document_id_filters(allowed_document_ids).filters)
|
||||
if document.pk is not None:
|
||||
filter_parts.extend(_exclude_document_id_filter(document.pk).filters)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Rewrite only the "title", "tags", "document_types", and "storage_paths" fields i
|
||||
|
||||
Do not translate correspondents or dates.
|
||||
Preserve proper nouns, organization names, product names, and exact official document names. Translate generic category words when a {{ language_name }} equivalent exists.
|
||||
Return the same JSON schema with all fields present.
|
||||
Keep every entry you were given in those four fields, in the same order, using the original wording where no translation applies.
|
||||
|
||||
Suggestions:
|
||||
{{ suggestions_json }}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.db.models.signals import post_init
|
||||
from django.utils import timezone
|
||||
from llama_index.core import settings as llama_settings
|
||||
from llama_index.core.embeddings.mock_embed_model import MockEmbedding
|
||||
from llama_index.core.schema import TextNode
|
||||
@@ -18,6 +23,11 @@ from paperless_ai.chat import _build_chat_prompt
|
||||
from paperless_ai.chat import _build_refine_prompt
|
||||
from paperless_ai.chat import stream_chat_with_documents
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
import pytest_mock
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_embed_model():
|
||||
@@ -310,8 +320,40 @@ def test_stream_chat_unexpected_failure_returns_generic_error(caplog) -> None:
|
||||
assert "private provider detail" in caplog.text
|
||||
|
||||
|
||||
def _retriever_filter_values(captured_filters: list[Any]) -> list[str]:
|
||||
"""The value list of the single MetadataFilter the retriever received."""
|
||||
assert captured_filters, "VectorIndexRetriever was never constructed"
|
||||
filt = captured_filters[0]
|
||||
assert filt is not None, "Retriever must receive a MetadataFilters"
|
||||
return filt.filters[0].value
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestStreamChatRetrieval:
|
||||
@pytest.fixture
|
||||
def captured_filters(self, mocker: pytest_mock.MockerFixture) -> list[Any]:
|
||||
"""Stub out the AI client and the retriever, capturing the ``filters``
|
||||
kwarg of every VectorIndexRetriever construction.
|
||||
|
||||
VectorIndexRetriever is imported inside _stream_chat_with_documents,
|
||||
so it is patched at the llama_index source for the lazy import to
|
||||
pick it up.
|
||||
"""
|
||||
captured: list[Any] = []
|
||||
retriever = mocker.MagicMock()
|
||||
retriever.retrieve.return_value = []
|
||||
|
||||
def capture_retriever(*args, **kwargs) -> pytest_mock.MockType:
|
||||
captured.append(kwargs.get("filters"))
|
||||
return retriever
|
||||
|
||||
mocker.patch("paperless_ai.chat.AIClient")
|
||||
mocker.patch(
|
||||
"llama_index.core.retrievers.VectorIndexRetriever",
|
||||
side_effect=capture_retriever,
|
||||
)
|
||||
return captured
|
||||
|
||||
def test_no_nodes_yields_no_content_message(
|
||||
self,
|
||||
temp_llm_index_dir,
|
||||
@@ -329,9 +371,9 @@ class TestStreamChatRetrieval:
|
||||
|
||||
def test_chat_filter_contains_only_requested_document_ids(
|
||||
self,
|
||||
temp_llm_index_dir,
|
||||
mock_embed_model,
|
||||
mocker,
|
||||
temp_llm_index_dir: Path,
|
||||
mock_embed_model: pytest_mock.MockType,
|
||||
captured_filters: list[Any],
|
||||
) -> None:
|
||||
"""The MetadataFilter passed to the retriever must be scoped to the
|
||||
requested documents only — content from other indexed documents must
|
||||
@@ -342,22 +384,6 @@ class TestStreamChatRetrieval:
|
||||
indexing.llm_index_add_or_update_document(included)
|
||||
indexing.llm_index_add_or_update_document(excluded)
|
||||
|
||||
# VectorIndexRetriever is imported inside _stream_chat_with_documents;
|
||||
# patch it at the llama_index source so the lazy import picks it up.
|
||||
captured_filters = []
|
||||
mock_retriever = mocker.MagicMock()
|
||||
mock_retriever.retrieve.return_value = []
|
||||
|
||||
def capture_retriever(*args, **kwargs):
|
||||
captured_filters.append(kwargs.get("filters"))
|
||||
return mock_retriever
|
||||
|
||||
mocker.patch("paperless_ai.chat.AIClient")
|
||||
mocker.patch(
|
||||
"llama_index.core.retrievers.VectorIndexRetriever",
|
||||
side_effect=capture_retriever,
|
||||
)
|
||||
|
||||
list(
|
||||
chat.stream_chat_with_documents(
|
||||
"question?",
|
||||
@@ -365,13 +391,78 @@ class TestStreamChatRetrieval:
|
||||
),
|
||||
)
|
||||
|
||||
assert captured_filters, "VectorIndexRetriever was never constructed"
|
||||
filt = captured_filters[0]
|
||||
assert filt is not None, "Retriever must receive a MetadataFilters"
|
||||
filter_values = filt.filters[0].value
|
||||
filter_values = _retriever_filter_values(captured_filters)
|
||||
assert str(included.pk) in filter_values
|
||||
assert str(excluded.pk) not in filter_values
|
||||
|
||||
def test_unrestricted_chat_excludes_nothing_when_no_documents_are_trashed(
|
||||
self,
|
||||
temp_llm_index_dir: Path,
|
||||
mock_embed_model: pytest_mock.MockType,
|
||||
captured_filters: list[Any],
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document indexed in the vector store, nothing trashed
|
||||
WHEN:
|
||||
- stream_chat_with_documents is called with unrestricted=True
|
||||
THEN:
|
||||
- The retriever receives a NOT IN filter excluding zero ids, so
|
||||
the whole index is effectively searched -- and no IN-list is
|
||||
built from the full permitted set, which is what risks the
|
||||
vector store's safety limit on large installs
|
||||
"""
|
||||
document = DocumentFactory.create(content="indexed document content")
|
||||
indexing.llm_index_add_or_update_document(document)
|
||||
|
||||
list(
|
||||
chat.stream_chat_with_documents(
|
||||
"question?",
|
||||
Document.objects.filter(pk=document.pk),
|
||||
unrestricted=True,
|
||||
),
|
||||
)
|
||||
|
||||
assert _retriever_filter_values(captured_filters) == []
|
||||
|
||||
def test_unrestricted_chat_excludes_trashed_documents(
|
||||
self,
|
||||
temp_llm_index_dir: Path,
|
||||
mock_embed_model: pytest_mock.MockType,
|
||||
captured_filters: list[Any],
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Two indexed documents, one of them trashed -- trashed documents
|
||||
stay in the vector index until permanently deleted, since
|
||||
delete_document_from_llm_index is wired to post_delete
|
||||
WHEN:
|
||||
- stream_chat_with_documents is called with unrestricted=True
|
||||
THEN:
|
||||
- The retriever receives a NOT IN filter excluding the trashed
|
||||
document's id, so an unrestricted caller (e.g. a superuser)
|
||||
never has trashed content surfaced in a chat answer
|
||||
"""
|
||||
kept = DocumentFactory.create(content="kept document content")
|
||||
trashed = DocumentFactory.create(content="trashed document content")
|
||||
indexing.llm_index_add_or_update_document(kept)
|
||||
indexing.llm_index_add_or_update_document(trashed)
|
||||
Document.global_objects.filter(pk=trashed.pk).update(
|
||||
deleted_at=timezone.now(),
|
||||
)
|
||||
|
||||
list(
|
||||
chat.stream_chat_with_documents(
|
||||
"question?",
|
||||
Document.objects.filter(pk=kept.pk),
|
||||
unrestricted=True,
|
||||
),
|
||||
)
|
||||
|
||||
filter_values = _retriever_filter_values(captured_filters)
|
||||
assert str(trashed.pk) in filter_values
|
||||
assert str(kept.pk) not in filter_values
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_document_references_only_queries_referenced_documents(
|
||||
self,
|
||||
|
||||
@@ -146,6 +146,8 @@ def test_run_llm_query_ollama_uses_structured_json(mock_ai_config, mock_ollama_l
|
||||
format=ANY,
|
||||
think=False,
|
||||
)
|
||||
messages = mock_llm_instance.chat.call_args.args[0]
|
||||
assert messages[0].content == "test_prompt"
|
||||
|
||||
|
||||
def test_run_llm_query_openai_uses_tools(mock_ai_config, mock_openai_llm):
|
||||
@@ -183,6 +185,13 @@ def test_run_llm_query_openai_uses_tools(mock_ai_config, mock_openai_llm):
|
||||
assert result["title"] == "Test Title"
|
||||
assert result["tags"] == {"existing_ids": [1], "new_names": []}
|
||||
mock_llm_instance.chat_with_tools.assert_called_once()
|
||||
kwargs = mock_llm_instance.chat_with_tools.call_args.kwargs
|
||||
offered_tool_name = kwargs["tools"][0].metadata.name
|
||||
assert kwargs["user_msg"].content == (
|
||||
"test_prompt\n\n"
|
||||
f"Answer by calling the {offered_tool_name} tool. "
|
||||
"Do not write the answer as text."
|
||||
)
|
||||
|
||||
|
||||
def test_run_llm_query_openai_timeout_raises_local_error(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import inspect
|
||||
import sqlite3
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
|
||||
@@ -97,6 +98,18 @@ def _ne_filter(document_id: int):
|
||||
)
|
||||
|
||||
|
||||
def _nin_filter(document_ids: list[int]):
|
||||
return MetadataFilters(
|
||||
filters=[
|
||||
MetadataFilter(
|
||||
key="document_id",
|
||||
operator=FilterOperator.NIN,
|
||||
value=document_ids,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class TestCrud:
|
||||
def test_add_then_query_returns_node(self, store) -> None:
|
||||
node = make_node("n1", 1)
|
||||
@@ -280,6 +293,47 @@ class TestBuildWhere:
|
||||
"b1",
|
||||
]
|
||||
|
||||
def test_nin_filter_translates_to_not_in_clause(self) -> None:
|
||||
where, params = _build_where(_nin_filter([1, 2]))
|
||||
assert where == "(document_id NOT IN (?,?))"
|
||||
assert params == [1, 2]
|
||||
|
||||
def test_query_with_nin_filter_excludes_matching_documents(self, store) -> None:
|
||||
store.add([make_node("a1", 1), make_node("b1", 2), make_node("c1", 3)])
|
||||
assert sorted(
|
||||
_query(store, [0.0] * DIM, top_k=5, filters=_nin_filter([1, 2])).ids,
|
||||
) == ["c1"]
|
||||
|
||||
def test_empty_in_filter_excludes_everything(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- An IN filter with an empty value list
|
||||
WHEN:
|
||||
- _build_where() translates it to SQL
|
||||
THEN:
|
||||
- It excludes everything (the opposite of an empty NOT IN
|
||||
filter) -- an empty inclusion list must never widen results
|
||||
"""
|
||||
where, params = _build_where(_in_filter([]))
|
||||
assert where == "(1 = 0)"
|
||||
assert params == []
|
||||
|
||||
def test_empty_nin_filter_excludes_nothing(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A NOT IN filter with an empty value list -- e.g. an
|
||||
unrestricted chat caller when nothing is currently trashed
|
||||
WHEN:
|
||||
- _build_where() translates it to SQL
|
||||
THEN:
|
||||
- It excludes nothing (unlike an empty IN filter, which
|
||||
excludes everything) -- an empty exclusion list must never
|
||||
narrow results
|
||||
"""
|
||||
where, params = _build_where(_nin_filter([]))
|
||||
assert where == "(1 = 1)"
|
||||
assert params == []
|
||||
|
||||
def test_fails_closed_when_no_filter_is_translatable(self) -> None:
|
||||
# A nested MetadataFilters is not a MetadataFilter, so it is skipped.
|
||||
# With no translatable clauses, the function must fail closed rather
|
||||
@@ -297,24 +351,31 @@ class TestBuildWhere:
|
||||
assert where == "1 = 0"
|
||||
assert params == []
|
||||
|
||||
def test_fails_closed_when_in_filter_exceeds_max_values(
|
||||
@pytest.mark.parametrize(
|
||||
"build_filter",
|
||||
[_in_filter, _nin_filter],
|
||||
ids=["in", "nin"],
|
||||
)
|
||||
def test_fails_closed_when_filter_exceeds_max_values(
|
||||
self,
|
||||
build_filter: Callable[[list[str]], MetadataFilters],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- An IN filter with more values than _MAX_IN_VALUES (SQLite's
|
||||
own bound-parameter limit is 32766; this guard sits below
|
||||
that with headroom for the query's other bound parameters)
|
||||
- An IN or NOT IN filter with more values than _MAX_IN_VALUES
|
||||
(SQLite's own bound-parameter limit is 32766; this guard sits
|
||||
below that with headroom for the query's other bound parameters)
|
||||
WHEN:
|
||||
- _build_where() translates it to SQL
|
||||
THEN:
|
||||
- It fails closed ("1 = 0", no params) instead of building an
|
||||
IN clause SQLite would reject, and logs a warning -- this
|
||||
filter scopes document access, so refusing to build it must
|
||||
never widen the scope to "everything" by accident
|
||||
- It fails closed ("1 = 0", no params) instead of building a
|
||||
clause SQLite would reject, and logs a warning -- this filter
|
||||
scopes document access, so refusing to build it must never
|
||||
widen the scope to "everything" by accident. Failing open on
|
||||
a NOT IN would surface exactly the excluded rows
|
||||
"""
|
||||
oversized = _in_filter([str(i) for i in range(_MAX_IN_VALUES + 1)])
|
||||
oversized = build_filter([str(i) for i in range(_MAX_IN_VALUES + 1)])
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
where, params = _build_where(oversized)
|
||||
|
||||
@@ -107,12 +107,13 @@ def _vec0_params(rows: list[_Row]) -> list[tuple[str, int, str, bytes]]:
|
||||
|
||||
|
||||
def _build_where(filters: MetadataFilters | None) -> tuple[str, list[int]]:
|
||||
"""Translate the EQ / IN / NE filters we use into a parameterized SQL
|
||||
clause on vec0 metadata columns. Returns ("", []) when there is nothing
|
||||
to filter. document_id is vec0's only filterable column and is INTEGER;
|
||||
every value is coerced via int() here so callers (which today still pass
|
||||
strings in places, e.g. indexing.py's MetadataFilter construction) don't
|
||||
have to be individually correct -- vec0 doesn't coerce types itself.
|
||||
"""Translate the EQ / IN / NIN / NE filters we use into a parameterized
|
||||
SQL clause on vec0 metadata columns. Returns ("", []) when there is
|
||||
nothing to filter. document_id is vec0's only filterable column and is
|
||||
INTEGER; every value is coerced via int() here so callers (which today
|
||||
still pass strings in places, e.g. indexing.py's MetadataFilter
|
||||
construction) don't have to be individually correct -- vec0 doesn't
|
||||
coerce types itself.
|
||||
"""
|
||||
if filters is None or not filters.filters:
|
||||
return "", []
|
||||
@@ -125,20 +126,25 @@ def _build_where(filters: MetadataFilters | None) -> tuple[str, list[int]]:
|
||||
continue
|
||||
if f.key not in _FILTER_COLUMNS: # pragma: no cover - we build the keys
|
||||
raise NotImplementedError(f"Unsupported filter column: {f.key}")
|
||||
if f.operator == FilterOperator.IN:
|
||||
if f.operator in (FilterOperator.IN, FilterOperator.NIN):
|
||||
is_in = f.operator == FilterOperator.IN
|
||||
sql_op = "IN" if is_in else "NOT IN"
|
||||
values = [int(v) for v in f.value] # type: ignore[union-attr]
|
||||
if not values: # pragma: no cover
|
||||
clauses.append("1 = 0")
|
||||
if not values:
|
||||
# An empty IN list matches nothing; an empty NOT IN list
|
||||
# excludes nothing, so it matches everything.
|
||||
clauses.append("1 = 0" if is_in else "1 = 1")
|
||||
continue
|
||||
if len(values) > _MAX_IN_VALUES:
|
||||
# Fail closed (see the empty-clauses case below) rather than
|
||||
# let SQLite raise "too many SQL variables" past its own
|
||||
# limit: this filter scopes document access, so an IN list
|
||||
# too large to safely bind must match no rows, never widen
|
||||
# the scope to "everything" by accident.
|
||||
# Refuse rather than risk SQLite's own bound-parameter limit
|
||||
# ("too many SQL variables"): a list this large must match no
|
||||
# rows, never widen the scope to "everything" -- true for
|
||||
# NOT IN too, where failing open would surface every
|
||||
# excluded row.
|
||||
logger.warning(
|
||||
"Refusing to build an IN filter on %r with %d values "
|
||||
"Refusing to build a %s filter on %r with %d values "
|
||||
"(over the %d-value safety limit); returning no rows.",
|
||||
sql_op,
|
||||
f.key,
|
||||
len(values),
|
||||
_MAX_IN_VALUES,
|
||||
@@ -146,7 +152,7 @@ def _build_where(filters: MetadataFilters | None) -> tuple[str, list[int]]:
|
||||
clauses.append("1 = 0")
|
||||
continue
|
||||
placeholders = ",".join("?" for _ in values)
|
||||
clauses.append(f"{f.key} IN ({placeholders})")
|
||||
clauses.append(f"{f.key} {sql_op} ({placeholders})")
|
||||
params.extend(values)
|
||||
elif f.operator == FilterOperator.EQ:
|
||||
clauses.append(f"{f.key} = ?")
|
||||
@@ -154,7 +160,7 @@ def _build_where(filters: MetadataFilters | None) -> tuple[str, list[int]]:
|
||||
elif f.operator == FilterOperator.NE:
|
||||
clauses.append(f"{f.key} != ?")
|
||||
params.append(int(f.value))
|
||||
else: # pragma: no cover - we only ever build EQ/IN/NE filters
|
||||
else: # pragma: no cover - we only ever build EQ/IN/NIN/NE filters
|
||||
raise NotImplementedError(f"Unsupported filter operator: {f.operator}")
|
||||
if not clauses:
|
||||
# Filters were requested but none could be translated. Fail closed
|
||||
|
||||
@@ -334,18 +334,24 @@ def error_callback(
|
||||
"""
|
||||
A shared task that is called whenever something goes wrong during
|
||||
consumption of a file. See queue_consumption_tasks.
|
||||
|
||||
With CELERY_TASK_ALLOW_ERROR_CB_ON_CHORD_HEADER enabled this runs once per
|
||||
failed header task, not once per chord, so it must be idempotent.
|
||||
"""
|
||||
rule = MailRule.objects.get(pk=rule_id)
|
||||
received = make_aware(message_date) if is_naive(message_date) else message_date
|
||||
|
||||
ProcessedMail.objects.create(
|
||||
ProcessedMail.objects.get_or_create(
|
||||
rule=rule,
|
||||
folder=rule.folder,
|
||||
uid=message_uid,
|
||||
uid_validity=uid_validity,
|
||||
subject=message_subject,
|
||||
received=make_aware(message_date) if is_naive(message_date) else message_date,
|
||||
status="FAILED",
|
||||
error=traceback.format_exc(),
|
||||
defaults={
|
||||
"subject": message_subject,
|
||||
"received": received,
|
||||
"status": "FAILED",
|
||||
"error": traceback.format_exc(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
+3
-3
@@ -708,9 +708,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-selector-parser": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
|
||||
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz",
|
||||
"integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -36,6 +36,7 @@ from paperless_mail.mail import MailAccountHandler
|
||||
from paperless_mail.mail import MailError
|
||||
from paperless_mail.mail import TagMailAction
|
||||
from paperless_mail.mail import apply_mail_action
|
||||
from paperless_mail.mail import error_callback
|
||||
from paperless_mail.mail import get_mailbox
|
||||
from paperless_mail.models import MailAccount
|
||||
from paperless_mail.models import MailRule
|
||||
@@ -2045,6 +2046,44 @@ class TestPostConsumeAction(TestCase):
|
||||
self.assertIn("Test Exception", processed_mail.error)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestErrorCallback:
|
||||
def test_error_callback_is_idempotent_for_same_mail(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A mail rule and a mail that failed to be consumed
|
||||
WHEN:
|
||||
- error_callback is invoked more than once for the same mail, as
|
||||
happens when task_allow_error_cb_on_chord_header fires the
|
||||
errback once per failed header task in a chord
|
||||
THEN:
|
||||
- Only one ProcessedMail row is created for that mail
|
||||
"""
|
||||
rule = MailRuleFactory()
|
||||
message_uid = "12345"
|
||||
|
||||
for _ in range(2):
|
||||
error_callback(
|
||||
None,
|
||||
Exception("Test Exception"),
|
||||
None,
|
||||
rule_id=rule.pk,
|
||||
message_uid=message_uid,
|
||||
message_subject="Test Subject",
|
||||
message_date=timezone.make_aware(
|
||||
timezone.datetime(2023, 1, 1, 12, 0, 0),
|
||||
),
|
||||
)
|
||||
|
||||
processed_mails = ProcessedMail.objects.filter(
|
||||
rule=rule,
|
||||
uid=message_uid,
|
||||
folder=rule.folder,
|
||||
)
|
||||
assert processed_mails.count() == 1
|
||||
assert processed_mails.get().status == "FAILED"
|
||||
|
||||
|
||||
class TestManagementCommand(TestCase):
|
||||
@mock.patch(
|
||||
"paperless_mail.management.commands.mail_fetcher.tasks.process_mail_accounts",
|
||||
|
||||
@@ -4,11 +4,11 @@ requires-python = ">=3.11"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.15' and sys_platform == 'darwin'",
|
||||
"python_full_version >= '3.15' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"python_full_version >= '3.12' and python_full_version < '3.15' and sys_platform == 'darwin'",
|
||||
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')",
|
||||
"python_full_version < '3.12' and sys_platform == 'darwin'",
|
||||
"python_full_version < '3.12' and sys_platform == 'linux'",
|
||||
@@ -1116,14 +1116,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "djangorestframework"
|
||||
version = "3.17.1"
|
||||
version = "3.17.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ca/d7/c016e69fac19ff8afdc89db9d31d9ae43ae031e4d1993b20aca179b8301a/djangorestframework-3.17.1.tar.gz", hash = "sha256:a6def5f447fe78ff853bff1d47a3c59bf38f5434b031780b351b0c73a62db1a5", size = 905742, upload-time = "2026-03-24T16:58:33.705Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/35/c96055e700fdff25da3a7b7756cfd1d4dc54f38b9bc6d6c5e19e3a0fdc20/djangorestframework-3.17.2.tar.gz", hash = "sha256:89ed713b6dc83e1539f214b7d10808ae19bb8511004beba886225da6d5c9dafa", size = 906683, upload-time = "2026-08-05T07:47:22.5Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/e1/2c516bdc83652b1a60c6119366ac2c0607b479ed05cd6093f916ca8928f8/djangorestframework-3.17.1-py3-none-any.whl", hash = "sha256:c3c74dd3e83a5a3efc37b3c18d92bd6f86a6791c7b7d4dff62bb068500e76457", size = 898844, upload-time = "2026-03-24T16:58:31.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/46/c14108e400b208c394325eb63fbae06c81341b6447fa1a6f9da718b17fe7/djangorestframework-3.17.2-py3-none-any.whl", hash = "sha256:cb0546a7415d5b46c04e0f4fe0a54b2109f4fdd5e83ca773c8c6183a6493d042", size = 899109, upload-time = "2026-08-05T07:47:20.853Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2718,7 +2718,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "nltk"
|
||||
version = "3.10.0"
|
||||
version = "3.10.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -2727,9 +2727,9 @@ dependencies = [
|
||||
{ name = "regex" },
|
||||
{ name = "tqdm" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/02/df4f105b28a7c16b0e41423bc09cf0f1b8a305df4ef0b10ca74a2e4c648c/nltk-3.10.0.tar.gz", hash = "sha256:4fbac1d98203cbcd1b5d94a2877fb822300072d80604a5e7fae49d2c5f84e8c1", size = 3089244, upload-time = "2026-07-08T02:39:13.562Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e0/e6/fe51d2bb1a3b446f59c5c8165999a9fee208bc346af90a7cbf7657bc0d75/nltk-3.10.3.tar.gz", hash = "sha256:bb9327a461c3811c2fa4900e03840401f2126adfb30c0072827c433bd2444ea4", size = 5137152, upload-time = "2026-08-12T23:46:37.258Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/89/a0b0f35e2820d6a99d75ea1c11977ee6d5c9e6658eceb45b0c7620881faa/nltk-3.10.0-py3-none-any.whl", hash = "sha256:54ff84d4916d3ef127e8953bee0023f6a6b320b75d634a19e06ef056d3d244bf", size = 1716144, upload-time = "2026-07-08T02:39:09.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/6d/ebd2af4640b12168fdf0cb74b6118df2f32a2f62ec7e0c06fbfd80706639/nltk-3.10.3-py3-none-any.whl", hash = "sha256:ff9598a8e20518ee0d557745890cc4435b9578489e2dcbc69c4f81fa060caf7c", size = 1798643, upload-time = "2026-08-12T23:44:13.478Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5014,10 +5014,10 @@ version = "2.13.0+cpu"
|
||||
source = { registry = "https://download.pytorch.org/whl/cpu" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.15' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')",
|
||||
"python_full_version < '3.12' and sys_platform == 'linux'",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user