mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-01-02 14:28:14 -06:00
Compare commits
8 Commits
feature-pw
...
11691-docu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8912d749f7 | ||
|
|
cd760906fa | ||
|
|
9e744a0665 | ||
|
|
d49d9f3b16 | ||
|
|
72fd05501b | ||
|
|
a3c19b1e2d | ||
|
|
2e6458dbcc | ||
|
|
8471507115 |
4
.github/workflows/translate-strings.yml
vendored
4
.github/workflows/translate-strings.yml
vendored
@@ -12,9 +12,11 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
env:
|
||||
GH_REF: ${{ github.ref }} # sonar rule:githubactions:S7630 - avoid injection
|
||||
with:
|
||||
token: ${{ secrets.PNGX_BOT_PAT }}
|
||||
ref: ${{ github.head_ref }}
|
||||
ref: ${{ env.GH_REF }}
|
||||
- name: Set up Python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@v6
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import {
|
||||
NgbDropdownModule,
|
||||
NgbPaginationModule,
|
||||
@@ -29,6 +30,7 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
||||
TitleCasePipe,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule,
|
||||
NgClass,
|
||||
NgTemplateOutlet,
|
||||
NgbDropdownModule,
|
||||
|
||||
@@ -42,7 +42,13 @@
|
||||
<button (click)="editField(field)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" ngbDropdownItem i18n>Edit</button>
|
||||
<button class="text-danger" (click)="deleteField(field)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" ngbDropdownItem i18n>Delete</button>
|
||||
@if (field.document_count > 0) {
|
||||
<button (click)="filterDocuments(field)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }" ngbDropdownItem i18n>Filter Documents ({{ field.document_count }})</button>
|
||||
<a
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
|
||||
ngbDropdownItem
|
||||
[routerLink]="getDocumentFilterUrl(field)"
|
||||
i18n
|
||||
>Filter Documents ({{ field.document_count }})</a
|
||||
>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,9 +63,13 @@
|
||||
</div>
|
||||
@if (field.document_count > 0) {
|
||||
<div class="btn-group d-none d-sm-inline-block ms-2">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="filterDocuments(field)">
|
||||
<i-bs width="1em" height="1em" name="filter"></i-bs> <ng-container i18n>Documents</ng-container><span class="badge bg-light text-secondary ms-2">{{ field.document_count }}</span>
|
||||
</button>
|
||||
<a
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
[routerLink]="getDocumentFilterUrl(field)"
|
||||
>
|
||||
<i-bs width="1em" height="1em" name="filter"></i-bs> <ng-container i18n>Documents</ng-container
|
||||
><span class="badge bg-light text-secondary ms-2">{{ field.document_count }}</span>
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import {
|
||||
NgbModal,
|
||||
NgbModalModule,
|
||||
@@ -61,6 +62,7 @@ describe('CustomFieldsComponent', () => {
|
||||
NgbModalModule,
|
||||
NgbPopoverModule,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
RouterTestingModule,
|
||||
CustomFieldsComponent,
|
||||
IfPermissionsDirective,
|
||||
PageHeaderComponent,
|
||||
@@ -108,7 +110,9 @@ describe('CustomFieldsComponent', () => {
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const reloadSpy = jest.spyOn(component, 'reload')
|
||||
|
||||
const createButton = fixture.debugElement.queryAll(By.css('button'))[1]
|
||||
const createButton = fixture.debugElement
|
||||
.queryAll(By.css('button'))
|
||||
.find((btn) => btn.nativeElement.textContent.trim().includes('Add Field'))
|
||||
createButton.triggerEventHandler('click')
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
@@ -133,7 +137,11 @@ describe('CustomFieldsComponent', () => {
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const reloadSpy = jest.spyOn(component, 'reload')
|
||||
|
||||
const editButton = fixture.debugElement.queryAll(By.css('button'))[2]
|
||||
const editButton = fixture.debugElement
|
||||
.queryAll(By.css('button'))
|
||||
.find((btn) =>
|
||||
btn.nativeElement.textContent.trim().includes(fields[0].name)
|
||||
)
|
||||
editButton.triggerEventHandler('click')
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
@@ -158,7 +166,9 @@ describe('CustomFieldsComponent', () => {
|
||||
const deleteSpy = jest.spyOn(customFieldsService, 'delete')
|
||||
const reloadSpy = jest.spyOn(component, 'reload')
|
||||
|
||||
const deleteButton = fixture.debugElement.queryAll(By.css('button'))[5]
|
||||
const deleteButton = fixture.debugElement
|
||||
.queryAll(By.css('button'))
|
||||
.find((btn) => btn.nativeElement.textContent.trim().includes('Delete'))
|
||||
deleteButton.triggerEventHandler('click')
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
@@ -176,10 +186,10 @@ describe('CustomFieldsComponent', () => {
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support filter documents', () => {
|
||||
const filterSpy = jest.spyOn(listViewService, 'quickFilter')
|
||||
component.filterDocuments(fields[0])
|
||||
expect(filterSpy).toHaveBeenCalledWith([
|
||||
it('should provide document filter url', () => {
|
||||
const urlSpy = jest.spyOn(listViewService, 'getQuickFilterUrl')
|
||||
component.getDocumentFilterUrl(fields[0])
|
||||
expect(urlSpy).toHaveBeenCalledWith([
|
||||
{
|
||||
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
|
||||
value: JSON.stringify([
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, OnInit, inject } from '@angular/core'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import {
|
||||
NgbDropdownModule,
|
||||
NgbModal,
|
||||
@@ -36,6 +37,7 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
|
||||
NgbDropdownModule,
|
||||
NgbPaginationModule,
|
||||
NgxBootstrapIconsModule,
|
||||
RouterModule,
|
||||
],
|
||||
})
|
||||
export class CustomFieldsComponent
|
||||
@@ -130,8 +132,8 @@ export class CustomFieldsComponent
|
||||
return DATA_TYPE_LABELS.find((l) => l.id === field.data_type).name
|
||||
}
|
||||
|
||||
filterDocuments(field: CustomField) {
|
||||
this.documentListViewService.quickFilter([
|
||||
getDocumentFilterUrl(field: CustomField) {
|
||||
return this.documentListViewService.getQuickFilterUrl([
|
||||
{
|
||||
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
|
||||
value: JSON.stringify([
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import {
|
||||
NgbDropdownModule,
|
||||
NgbPaginationModule,
|
||||
@@ -27,6 +28,7 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
||||
IfPermissionsDirective,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule,
|
||||
NgClass,
|
||||
NgTemplateOutlet,
|
||||
NgbDropdownModule,
|
||||
|
||||
@@ -120,7 +120,14 @@
|
||||
<button (click)="openEditDialog(object)" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" ngbDropdownItem i18n>Edit</button>
|
||||
<button class="text-danger" (click)="openDeleteDialog(object)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" ngbDropdownItem i18n>Delete</button>
|
||||
@if (getDocumentCount(object) > 0) {
|
||||
<button (click)="filterDocuments(object)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }" ngbDropdownItem i18n>Filter Documents ({{ getDocumentCount(object) }})</button>
|
||||
<a
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
|
||||
ngbDropdownItem
|
||||
[routerLink]="getDocumentFilterUrl(object)"
|
||||
(click)="$event?.stopPropagation()"
|
||||
i18n
|
||||
>Filter Documents ({{ getDocumentCount(object) }})</a
|
||||
>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -135,9 +142,15 @@
|
||||
</div>
|
||||
@if (getDocumentCount(object) > 0) {
|
||||
<div class="btn-group d-none d-sm-inline-block">
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="filterDocuments(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||
<i-bs width="1em" height="1em" name="filter"></i-bs> <ng-container i18n>Documents</ng-container><span class="badge bg-light text-secondary ms-2">{{ getDocumentCount(object) }}</span>
|
||||
</button>
|
||||
<a
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
|
||||
[routerLink]="getDocumentFilterUrl(object)"
|
||||
(click)="$event?.stopPropagation()"
|
||||
>
|
||||
<i-bs width="1em" height="1em" name="filter"></i-bs> <ng-container i18n>Documents</ng-container
|
||||
><span class="badge bg-light text-secondary ms-2">{{ getDocumentCount(object) }}</span>
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@angular/core/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { RouterLinkWithHref } from '@angular/router'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import {
|
||||
NgbModal,
|
||||
@@ -230,12 +231,15 @@ describe('ManagementListComponent', () => {
|
||||
})
|
||||
|
||||
it('should support quick filter for objects', () => {
|
||||
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
|
||||
const filterButton = fixture.debugElement.queryAll(By.css('button'))[9]
|
||||
filterButton.triggerEventHandler('click')
|
||||
expect(qfSpy).toHaveBeenCalledWith([
|
||||
const expectedUrl = documentListViewService.getQuickFilterUrl([
|
||||
{ rule_type: FILTER_HAS_TAGS_ALL, value: tags[0].id.toString() },
|
||||
]) // subclasses set the filter rule type
|
||||
])
|
||||
const filterLink = fixture.debugElement.query(
|
||||
By.css('a.btn-outline-secondary')
|
||||
)
|
||||
expect(filterLink).toBeTruthy()
|
||||
const routerLink = filterLink.injector.get(RouterLinkWithHref)
|
||||
expect(routerLink.urlTree).toEqual(expectedUrl)
|
||||
})
|
||||
|
||||
it('should reload on sort', () => {
|
||||
|
||||
@@ -230,8 +230,8 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
||||
|
||||
abstract getDeleteMessage(object: T)
|
||||
|
||||
filterDocuments(object: MatchingModel) {
|
||||
this.documentListViewService.quickFilter([
|
||||
getDocumentFilterUrl(object: MatchingModel) {
|
||||
return this.documentListViewService.getQuickFilterUrl([
|
||||
{ rule_type: this.filterRuleType, value: object.id.toString() },
|
||||
])
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import {
|
||||
NgbDropdownModule,
|
||||
NgbPaginationModule,
|
||||
@@ -27,6 +28,7 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
||||
IfPermissionsDirective,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule,
|
||||
NgClass,
|
||||
NgTemplateOutlet,
|
||||
NgbDropdownModule,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import {
|
||||
NgbDropdownModule,
|
||||
NgbPaginationModule,
|
||||
@@ -27,6 +28,7 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
||||
IfPermissionsDirective,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule,
|
||||
NgClass,
|
||||
NgTemplateOutlet,
|
||||
NgbDropdownModule,
|
||||
|
||||
@@ -651,4 +651,25 @@ describe('DocumentListViewService', () => {
|
||||
documentListViewService.displayFields = customFields as any
|
||||
expect(documentListViewService.displayFields).toEqual(['custom_field_1'])
|
||||
})
|
||||
|
||||
it('should generate quick filter URL with filter rules', () => {
|
||||
const routerSpy = jest.spyOn(router, 'createUrlTree')
|
||||
const urlTree = documentListViewService.getQuickFilterUrl(filterRules)
|
||||
expect(routerSpy).toHaveBeenCalledWith(['/documents'], {
|
||||
queryParams: expect.objectContaining({
|
||||
tags__id__all: tags__id__all,
|
||||
}),
|
||||
})
|
||||
expect(urlTree).toBeDefined()
|
||||
})
|
||||
|
||||
it('should generate quick filter URL preserving default state', () => {
|
||||
documentListViewService.reload()
|
||||
httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||
)
|
||||
const urlTree = documentListViewService.getQuickFilterUrl(filterRules)
|
||||
expect(urlTree).toBeDefined()
|
||||
expect(router.createUrlTree).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, inject } from '@angular/core'
|
||||
import { ParamMap, Router } from '@angular/router'
|
||||
import { ParamMap, Router, UrlTree } from '@angular/router'
|
||||
import { Observable, Subject, first, takeUntil } from 'rxjs'
|
||||
import {
|
||||
DEFAULT_DISPLAY_FIELDS,
|
||||
@@ -483,6 +483,18 @@ export class DocumentListViewService {
|
||||
this.router.navigate(['documents'])
|
||||
}
|
||||
|
||||
getQuickFilterUrl(filterRules: FilterRule[]): UrlTree {
|
||||
const defaultState = {
|
||||
...this.defaultListViewState(),
|
||||
...this.listViewStates.get(null),
|
||||
filterRules,
|
||||
}
|
||||
const params = paramsFromViewState(defaultState)
|
||||
return this.router.createUrlTree(['/documents'], {
|
||||
queryParams: params,
|
||||
})
|
||||
}
|
||||
|
||||
getLastPage(): number {
|
||||
return Math.ceil(this.collectionSize / this.pageSize)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ from django.core.exceptions import ValidationError
|
||||
from django.core.validators import DecimalValidator
|
||||
from django.core.validators import EmailValidator
|
||||
from django.core.validators import MaxLengthValidator
|
||||
from django.core.validators import MaxValueValidator
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.core.validators import RegexValidator
|
||||
from django.core.validators import integer_validator
|
||||
from django.db.models import Count
|
||||
@@ -875,6 +877,13 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer):
|
||||
uri_validator(data["value"])
|
||||
elif field.data_type == CustomField.FieldDataType.INT:
|
||||
integer_validator(data["value"])
|
||||
try:
|
||||
value_int = int(data["value"])
|
||||
except (TypeError, ValueError):
|
||||
raise serializers.ValidationError("Enter a valid integer.")
|
||||
# Keep values within the PostgreSQL integer range
|
||||
MinValueValidator(-2147483648)(value_int)
|
||||
MaxValueValidator(2147483647)(value_int)
|
||||
elif (
|
||||
field.data_type == CustomField.FieldDataType.MONETARY
|
||||
and data["value"] != ""
|
||||
|
||||
@@ -1664,6 +1664,44 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
|
||||
self.consume_file_mock.assert_not_called()
|
||||
|
||||
def test_patch_document_integer_custom_field_out_of_range(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- An integer custom field
|
||||
- A document
|
||||
WHEN:
|
||||
- Patching the document with an integer value exceeding PostgreSQL's range
|
||||
THEN:
|
||||
- HTTP 400 is returned (validation catches the overflow)
|
||||
- No custom field instance is created
|
||||
"""
|
||||
cf_int = CustomField.objects.create(
|
||||
name="intfield",
|
||||
data_type=CustomField.FieldDataType.INT,
|
||||
)
|
||||
doc = Document.objects.create(
|
||||
title="Doc",
|
||||
checksum="123",
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
|
||||
response = self.client.patch(
|
||||
f"/api/documents/{doc.pk}/",
|
||||
{
|
||||
"custom_fields": [
|
||||
{
|
||||
"field": cf_int.pk,
|
||||
"value": 2**31, # overflow for PostgreSQL integer fields
|
||||
},
|
||||
],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("custom_fields", response.data)
|
||||
self.assertEqual(CustomFieldInstance.objects.count(), 0)
|
||||
|
||||
def test_upload_with_webui_source(self):
|
||||
"""
|
||||
GIVEN: A document with a source file
|
||||
|
||||
@@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-12-24 05:27+0000\n"
|
||||
"POT-Creation-Date: 2025-12-29 14:49+0000\n"
|
||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
@@ -1219,35 +1219,35 @@ msgstr ""
|
||||
msgid "workflow runs"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:640
|
||||
#: documents/serialisers.py:642
|
||||
msgid "Invalid color."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:1826
|
||||
#: documents/serialisers.py:1835
|
||||
#, python-format
|
||||
msgid "File type %(type)s not supported"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:1870
|
||||
#: documents/serialisers.py:1879
|
||||
#, python-format
|
||||
msgid "Custom field id must be an integer: %(id)s"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:1877
|
||||
#: documents/serialisers.py:1886
|
||||
#, python-format
|
||||
msgid "Custom field with id %(id)s does not exist"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:1894 documents/serialisers.py:1904
|
||||
#: documents/serialisers.py:1903 documents/serialisers.py:1913
|
||||
msgid ""
|
||||
"Custom fields must be a list of integers or an object mapping ids to values."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:1899
|
||||
#: documents/serialisers.py:1908
|
||||
msgid "Some custom fields don't exist or were specified twice."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2014
|
||||
#: documents/serialisers.py:2023
|
||||
msgid "Invalid variable detected."
|
||||
msgstr ""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user